pcbjam/docs/features/ysync-review/05-bug-rebaseline-swallows-local-edits.md
Gergő Törcsvári ae5d41a3d7
fix(ysync): review bugs 01–07 — listener registration, pad nets, child removals, dirty-set emit, targeted rebaseline, seed arbitration, stale hook (doc 17)
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
2026-07-06 08:57:57 +02:00

4.2 KiB
Raw Blame History

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 apply
  • wasm/bindings/pcbnew_embind.cpp:811-816, :889-894 — same
  • rebaseline() itself: snapshots the entire current model into g_baseline

The race

Applies and flushes are both CallAfter pending events on the same frame handler, so they are FIFO. This ordering is realistic:

  1. A remote batch arrives; JS calls kicadCollabApplyItemsapply queued.
  2. 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::Push fires the listener → scheduleFlushflush queued behind the apply.
  3. Pending drain: apply runs first. doApplyItems applies the remote items, then rebaseline() snapshots the whole model — which already contains the user's local edit.
  4. 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.