New doc 16: the full repro-suite map (per-bug unit/e2e paths with verified failure sites), the phase-C probe outcome, and four findings only the RUNNING system revealed: - F1: bug 03's sending half emits NOTHING — a child-only delete commit never triggers a flush at all (worse than the doc's predicted bare removed-wire); the GetWidth-assert tracer evidence and the fix implication. - F2: Firefox cannot host two kicad_editor tabs in one context (per-process wasm budget) — bug-01 two-tab repros are Chromium-only. - F3: headless emit WORKS on both pcbnew and eeschema — the legacy two-tab skip rationale and items-bridge localEdit omissions are stale. - F4: drift-detect is strictly ITEM-silent on the green path (no writer- formatting false positives). Cross-updates: 00 index + verdict note; 01/04/05/06/07 Verification sections gain their repro paths; 02 upgraded to runtime-CONFIRMED; 03 gains the F1 empirical correction; 11 (no v2 e2e coverage) CLOSED with a point-by-point status update — only the legacy retirement remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPfrVhfYgPPgtawjSssZfn
4.5 KiB
Bug 06 — Concurrent first-seed duplicates kdoc_layout; corrupt materialization with no self-heal
Severity: medium (race window is small but the damage is durable file corruption) Status: open
Where
web/pcbjam-shared/src/kicad-y.ts:79-94—docToY:layout.delete(0, length)thenlayout.insert(0, doc.layout)inside one transaction- Seed decision points:
web/standalone/src/wasm/collab/kicad-binding.ts:127-175(seed()checksydocHasStatethen seeds),provider.ts:56-68(BroadcastChannelsettleMs, default 300 ms),index.ts:85-98(whenSynced()→seed())
The race
Seed-vs-adopt is decided by a check-then-act on the client: after whenSynced(),
if ydocHasState(doc) is false, the client seeds. Two clients opening the same fresh
room concurrently can both observe "empty" before receiving each other's seed
transaction:
- network providers (partykit / hocuspocus): window ≈ one server round-trip between sync-complete and the peer's update arriving;
- BroadcastChannel: window ≈ the whole
settleMs(300 ms) if both tabs open together.
Two simultaneous docToY calls then merge as follows:
kdoc_meta.root— Y.Map LWW → converges fine.kdoc_items— same uuid keys, (typically) identical values → LWW per key → converges fine.kdoc_layout— breaks. Each client'sdeletesees only its own (empty) view; eachinsertis an independent CRDT op, and Y.Array keeps both sequences. The merged layout holds two(version …), two(paper …), two(lib_symbols …), and two{item: uuid}slots per root item.
Consequences
docToFilerenders every root item twice (each{item}slot resolves; the per-renderseenset only guards cycles within one path, not repeated slots) and duplicates the preamble forms → the materialized file is invalid or at best semantically doubled.- ydoc-mode opens materialize this corruption directly.
- Nothing heals it:
ydocHasStateis now true, so no client ever re-seeds;applyDeltaToYonly appends/removes individual root slots, it never rewrites the layout. The duplication is permanent for the room's lifetime. - If the two seeders had different file versions (one stale), items also interleave arbitrarily per-key — messier still, but the layout duplication alone is enough to corrupt.
Fix directions (pick one)
- Server-side seeding (cleanest): the sync server creates/initializes the room
document from the stored file exactly once (it already owns the
.ydocblob); clients never file-seed. Kills the race by construction, and also removes the client's seed-authority special cases. - Client-side seed arbitration: write a random
seedNonceintokdoc_metainside the samedocToYtransaction. After the transaction has round-tripped (next sync / short settle), re-read the nonce: LWW means exactly one seeder "won". A loser that sees a foreign nonce re-runsdocToY? No — re-running re-inserts. The loser must instead retract its own layout inserts (delete slots whose insertion client-id is its own) or simply rewrite layout in a fresh transaction after observing the winner's state (delete-all + insert is safe once only one client does it, so gate the rewrite on "my nonce lost"). - Read-side dedup (mitigation, not a fix):
yToDoc/docToFilecould drop duplicate{item}slots and duplicate preamble heads. This masks the corruption for materialization but leaves the doc itself dirty; only worth doing as a safety net on top of 1 or 2.
Option 1 is recommended — it also solves stale-file double-seeding and removes the
settleMs heuristic from the BroadcastChannel path.
Verification
Unit: two Y.Docs, docToY the same KicadDoc into both, sync updates both ways,
assert docToFile output equals the single-seed output (currently it doesn't — layout
doubles). Integration: open the same fresh project in two tabs simultaneously
(Promise.all in the harness) and assert the room materializes cleanly.
Repro (2026-07-03): unit in web/pcbjam-shared/test/ysync-repros.test.ts (doubled
{item} slot + materialization ≠ single-seed, plus a green CRDT-determinism baseline
— both docs corrupt IDENTICALLY, which is why nothing downstream notices); e2e race
in tests/kicad/ysync-two-tab.spec.ts (Promise.all start, equal settleMs,
skip-guarded when the race happens not to fire — it fired on every observed run).
See 16.