All seven ysync-review bugs fixed and verified; every repro's expected-fail marker removed (they now run as regression tests). Also fixes two bugs found while verifying (doc 17 F5/F6): file-seeded Y bodies are re-upserted in the editor's serialization (doc-16 F4 was an artifact of bug 01), and the 0008-era "asyncify-fragile envelope parse" was really wrapInBoardEnvelope emitting display layer names — canonical LSET::Name() fixes track/via/zone v2 applies; makeFromBlob now logs parse errors instead of swallowing them. Verified: shared 98, standalone collab 37, ysync e2e 20/20 (chromium), collab regression set 21 passed / 3 pre-existing skips (firefox). Bumps: kicad (board_commit child-removal listener notification), web/pcbjam-shared (slot prune + arbitrated seed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgThWXtdvrYLK47EDFoGdq
4.2 KiB
Bug 05 — Global rebaseline after a remote apply can swallow concurrent local edits and receiver-side cleanup
Severity: medium-high (silent permanent peer divergence; probability scales with edit rate × remote traffic) Status: FIXED 2026-07-03 — see 17 (batch 3: targeted rebaseline + post-apply flush)
Where
wasm/bindings/eeschema_embind.cpp:726-731(doApply) and:846-849(doApplyItems) —rebaseline()at the end of every applywasm/bindings/pcbnew_embind.cpp:811-816,:889-894— samerebaseline()itself: snapshots the entire current model intog_baseline
The race
Applies and flushes are both CallAfter pending events on the same frame handler, so
they are FIFO. This ordering is realistic:
- A remote batch arrives; JS calls
kicadCollabApplyItems→ apply queued. - Before the pending queue drains, the user's in-flight edit commits (e.g. mouse-up of
a drag processed in the same frame).
SCH/BOARD_COMMIT::Pushfires the listener →scheduleFlush→ flush queued behind the apply. - Pending drain: apply runs first.
doApplyItemsapplies the remote items, thenrebaseline()snapshots the whole model — which already contains the user's local edit. - flush runs: diff(current, baseline) is empty. The local edit is never broadcast.
The edit stays in the local editor but never reaches the Y.Doc or peers — permanent, silent divergence. Drift-detect will eventually report it; nothing repairs it (10-miss-no-repair-path.md).
Second casualty: receiver-side cleanup
The convergence argument for the post-settle diff is "the sender broadcasts its FINAL,
post-cleanup geometry; the receiver re-cleaning already-clean geometry is idempotent."
That holds when the receiver's surroundings are identical. When they are not —
concurrent local geometry, e.g. a remotely-moved wire now crossing the receiver's
junction — the receiver's RecalculateConnections produces genuinely new state
(splits/merges the sender never saw). Because rebaseline() runs after Push, that
cleanup is folded into the baseline and never broadcast: the receiver has split
wires, the sender doesn't, and no future diff will notice.
Fix
Make the post-apply rebaseline targeted instead of global: update g_baseline
entries only for the uuids the apply actually added/changed/removed (recompute their
post-apply json/blob-hash; drop removed ones). Everything else the apply's Push
mutated as a side effect — receiver-side cleanup, and any concurrently committed local
edit — then still differs from the baseline and flushes as a normal local diff:
- the swallowed-local-edit race disappears (the edit's uuids weren't touched by the apply, so their baseline entries are still pre-edit);
- receiver-side cleanup is broadcast and both sides converge on it. Re-application on the original sender is idempotent, so the echo is bounded (one extra hop, then empty diffs).
This composes with the blob-hash baseline proposed in 04-bug-lossy-change-detection.md: with a uuid→hash baseline, "targeted" is just updating the hashes for the applied uuids.
Verification
Deterministic interleave test via the existing test hooks: queue an apply
(kicadCollabApplyItems) and a local kicadCollabTestMoveFirst such that the commit
lands between apply-enqueue and apply-run; assert the local move still reaches the
peer. For the cleanup half: two tabs, tab B draws a wire crossing where tab A is about
to move a wire; after A's move syncs, assert both tabs converge on the same segment
set (currently B's split stays local).
Repro (2026-07-03): implemented exactly as the first half above in
tests/kicad/ysync-repros-pcbnew.spec.ts ("a local edit committed while a remote
apply is queued…", test.fail): one JS turn queues TestMoveFirst then
ApplyItems(added footprint) → FIFO drain [move, apply, flush] → runtime-confirmed
that both land and the moved uuid is never emitted. The receiver-side-cleanup half
remains un-reproduced (needs the two-tab wire-crossing setup). See
16.