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
This commit is contained in:
Gergő Törcsvári 2026-07-03 18:08:37 +02:00
commit ae5d41a3d7
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
18 changed files with 527 additions and 109 deletions

View file

@ -31,6 +31,14 @@ constraints. The findings below are the places where it breaks or leaks.
## Verdict / suggested order of attack ## Verdict / suggested order of attack
**UPDATE 2026-07-03: bugs 0107 are all FIXED** — see
[17](17-fixes-bugs-01-07.md) for the fix record (done in the batch order below),
two additional findings (F5: doc 16's F4 was an artifact of bug 01, file-seeded
bodies are now editor-normalized; F6: the 0008 "asyncify-fragile envelope parse"
was a layer-name bug, now fixed), and the remaining follow-ups. Every repro
below now runs as a plain regression test. Misses 0810 and opts 1214 (beyond
what the dirty-set emit already delivers) remain open.
Every bug below has a runnable expected-fail reproduction test, and the v2 e2e port Every bug below has a runnable expected-fail reproduction test, and the v2 e2e port
(miss 11) is DONE — see [16](16-repro-suite-results-and-empirical-findings.md) for (miss 11) is DONE — see [16](16-repro-suite-results-and-empirical-findings.md) for
the suite map and four empirical findings the running system added (notably: bug 03's the suite map and four empirical findings the running system added (notably: bug 03's
@ -84,3 +92,4 @@ sending half emits NOTHING, not the bare removal the doc predicted).
|---|------|-----------| |---|------|-----------|
| 15 | [15-plan-repro-tests-and-v2-e2e.md](15-plan-repro-tests-and-v2-e2e.md) | The approved plan: repro tests for bugs 0107 + the v2 e2e port | | 15 | [15-plan-repro-tests-and-v2-e2e.md](15-plan-repro-tests-and-v2-e2e.md) | The approved plan: repro tests for bugs 0107 + the v2 e2e port |
| 16 | [16-repro-suite-results-and-empirical-findings.md](16-repro-suite-results-and-empirical-findings.md) | Plan 15 executed (2026-07-03): suite map, verified failure sites, empirical findings F1F4 | | 16 | [16-repro-suite-results-and-empirical-findings.md](16-repro-suite-results-and-empirical-findings.md) | Plan 15 executed (2026-07-03): suite map, verified failure sites, empirical findings F1F4 |
| 17 | [17-fixes-bugs-01-07.md](17-fixes-bugs-01-07.md) | Bugs 0107 fixed & verified (2026-07-03); findings F5F6; remaining follow-ups |

View file

@ -1,7 +1,7 @@
# Bug 01 — First-ever tab never registers the C++ change listener; the seeding session can't send # Bug 01 — First-ever tab never registers the C++ change listener; the seeding session can't send
**Severity:** critical (breaks the primary first-session flow for all three tools) **Severity:** critical (breaks the primary first-session flow for all three tools)
**Status:** open **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 1)
**Fix size:** one line **Fix size:** one line
## Where ## Where

View file

@ -1,7 +1,7 @@
# Bug 02 — Footprint blobs zero pad net codes; net data loss propagates to peers, the Y.Doc, and materialized files # Bug 02 — Footprint blobs zero pad net codes; net data loss propagates to peers, the Y.Doc, and materialized files
**Severity:** high (silent, converging data corruption on boards with nets) **Severity:** high (silent, converging data corruption on boards with nets)
**Status:** open — runtime-CONFIRMED 2026-07-03: the snapshot blob carries the pads **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 2)
with `(net 1 "SIG")` stripped (repro below) with `(net 1 "SIG")` stripped (repro below)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Bug 03 — Child-only deletions leave a dangling `{item}` slot in the parent's Y body # Bug 03 — Child-only deletions leave a dangling `{item}` slot in the parent's Y body
**Severity:** high (poisons a room's render/materialize path; three-way divergence) **Severity:** high (poisons a room's render/materialize path; three-way divergence)
**Status:** open **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 2; three layers + the F1 board_commit notification gap)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Bug 04 — Change detection is a lossy scalar projection; a whole class of edits silently never syncs # Bug 04 — Change detection is a lossy scalar projection; a whole class of edits silently never syncs
**Severity:** high (broad, silent non-replication of everyday edits) **Severity:** high (broad, silent non-replication of everyday edits)
**Status:** open **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 3: listener dirty-set → unconditional v2 blob emit)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Bug 05 — Global rebaseline after a remote apply can swallow concurrent local edits and receiver-side cleanup # 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) **Severity:** medium-high (silent permanent peer divergence; probability scales with edit rate × remote traffic)
**Status:** open **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 3: targeted rebaseline + post-apply flush)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Bug 06 — Concurrent first-seed duplicates `kdoc_layout`; corrupt materialization with no self-heal # 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) **Severity:** medium (race window is small but the damage is durable file corruption)
**Status:** open **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 4: arbitrated seedDocToY, fix direction 2)
## Where ## Where

View file

@ -1,7 +1,7 @@
# Bug 07 — Sheet switch leaves the DOWN hook pointing at the old room; cross-room contamination window # Bug 07 — Sheet switch leaves the DOWN hook pointing at the old room; cross-room contamination window
**Severity:** medium (small window on warm rooms; a full network round-trip — or forever — on cold/failed switches) **Severity:** medium (small window on warm rooms; a full network round-trip — or forever — on cold/failed switches)
**Status:** open **Status:** FIXED 2026-07-03 — see [17](17-fixes-bugs-01-07.md) (batch 4: destroyed-flag hook + switch retry; fix direction 2 still open)
## Where ## Where

View file

@ -0,0 +1,126 @@
# Fixes for bugs 0107 (2026-07-03)
**Status:** all seven review bugs FIXED and verified — every repro from plan
[15](15-plan-repro-tests-and-v2-e2e.md) had its expected-fail marker removed and now
runs as a plain regression test. Final state: ysync unit suites green
(pcbjam-shared 98, standalone collab 37), ysync e2e **20/20** on chromium
(two-tab incl. both bug-01 tools, concurrent seed, bare child removal), collab
regression set (items-bridge, roundtrip, legacy collab specs, save-hook) 21
passed / 3 pre-existing skips on firefox.
The fixes were done in the review's suggested batches; fixing them also surfaced
and fixed **two bugs the review missed** (F5, F6 below).
## Batch 1 — bug 01 (one line, as advertised)
`kicad-binding.ts` seed()'s file-seed branch now calls `bridge.snapshotItems()`
`ensureBridge()` registers the C++ change listener, and the differ gets a
baseline. See F5 for why the snapshot's *result* is now also used.
## Batch 2 — bugs 02 + 03
- **02:** dropped `blobForItem`'s footprint `pad->SetNetCode(0)` loop
(`pcbnew_embind.cpp`). KiCad 10 formats pad nets by NAME and the parser
resolves names against the receiving board (creating the net if absent), so
the doc's "remap by name on apply" concern is satisfied by the format itself.
The repro's `(net 1 "SIG")` assertion was updated to accept the KiCad 10
name-only form.
- **03 (root cause, F1):** `BOARD_COMMIT::Push`'s CHT_REMOVE branch for footprint
children never touched any listener vector — `OnItemsCompositeUpdate` was
simply never called for a child-only delete. The kicad fork now pushes the
parent footprint into `itemsChanged` there (a genuine upstream notification
gap; candidate for upstreaming).
- **03 (emit):** `itemToJson` carries a `parent` uuid; `flushDiff`'s removal loop
lifts a removed child whose parent survives to a parent re-blob on the v2 wire
(`wRemoved` now diverges from the legacy `removed`).
- **03 (receive):** `doApplyItems`' removed loop only skips a child uuid when its
parent footprint is itself in the same wire's removed set; a BARE child
removal now removes the child.
- **03 (Y defense):** `applyDeltaToY` deep-prunes `{item: uuid}` slots from a
surviving parent's body before deleting a child item — "file recoverable from
the Y.Doc alone" holds regardless of emitter behavior.
## Batch 3 — bugs 04 + 05 (one change, both tools)
- The `COLLAB_LISTENER`s now CAPTURE the touched items (uuid strings at callback
time; children lifted to their root — `GetParentFootprint()` on pcbnew, the
symbol/sheet/label/table parent chain on eeschema) into a `g_dirty` set.
- `flushDiff` emits every dirty root's v2 blob unconditionally (deduped against
the scalar-diff emits). Rotations, field text edits, pad/zone property edits,
endpoint drags — anything serializer-visible — now reach the wire. The
full-model scalar scan REMAINS: it feeds the legacy wire and catches
eeschema's post-`Push` connectivity cleanup, which never reaches listeners.
A no-op commit costs one blob serialization; the TS layer drops unchanged
bodies, so nothing echoes.
- **05:** `doApplyItems` replaced the global `rebaseline()` with
`rebaselineTouched(applied uuids)` (pcbnew also refreshes/erases the child
entries via the baseline's `parent` field) and ends with `scheduleFlush()`.
A local edit committed while the apply was queued keeps its pre-edit baseline
and still emits; receiver-side cleanup broadcasts (idempotent on the sender).
- Global `rebaseline()` clears `g_dirty` (a sheet switch must not leak the old
sheet's dirty marks into the new room).
- Legacy `doApply` keeps the global rebaseline (dead in production, apply-leg
tests only).
## Batch 4 — bugs 06 + 07
- **06:** new shared primitive `seedDocToY(doc, ydoc, origin, nonce)` writes the
seed nonce into `kdoc_meta` inside the seed transaction and returns a
retractor that deletes exactly the layout slots that seed inserted (matched by
insertion id — this client, this clock window — so slots appended by later
edits survive; unit-covered). The binding's file-seed branch arbitrates via a
`kdoc_meta` observer: if a foreign nonce wins the LWW merge, the loser
retracts, leaving the winner's single clean layout. `kdoc_items` converges per
key on its own. (Server-side seeding — fix direction 1 — remains the cleaner
long-term design; this closes the race client-side without backend changes.)
- **07a:** `bindKicadCollab` gates the DOWN hook with a `destroyed` flag —
`destroy()` makes the binding inert both ways. Gap emits during a sheet switch
are dropped; C++ has already rebaselined to the new sheet, so applying them to
the old room was pure contamination.
- **07b:** failed `switchTo` now retries with backoff (2s doubling to 30s,
event-driven, reset on success) instead of leaving the editor unbound forever.
- Fix direction 2 (generation-tagging the apply envelope for the UP-side
sub-frame window) was NOT done — still open, low priority.
## New findings while verifying (beyond docs 0116)
### F5 — F4 was an artifact of bug 01; file-seeded bodies must be editor-normalized
Doc 16's F4 ("drift-detect is item-silent on the green path") only held because
bug 01 left the differ with NO baseline: the seeder's first edit re-emitted the
FULL model, silently overwriting the file-seeded Y bodies with the editor's
serialization. With bug 01 fixed, the pl_editor baseline drifted on every
file-seeded item (`(name border)` vs `(name "border")`, normalized geometry…).
Fix: the file-seed branch now uses `snapshotItems()`' RESULT — item bodies are
re-upserted in the editor's serialization (the steady-state form every future
emit produces; also what makes `upsertYItem`'s no-op skip effective). Meta +
layout stay file-derived. Consequence: a room's materialization is
editor-normalized, not file-verbatim — the bug-06 e2e was updated accordingly
(convergence + no-duplication, not byte-equality with the raw file render).
### F6 — the "asyncify-fragile envelope parse" was actually a layer-name bug
The 0008-era decision to keep track/via/zone/text APPLY off the v2 wire cited an
"asyncify-fragile `(kicad_pcb …)` envelope parse". Root cause found while fixing
the pcbnew bug-01 two-tab repro (whose apply leg it blocked):
`wrapInBoardEnvelope` wrote the envelope's layer table with `GetLayerName()`
(user-visible names), but the parser validates canonical names against the fixed
layer hash — any board with a layer whose display name differs from its
canonical one (every KiCad 10 board: "F.Courtyard" vs "F.CrtYd") threw
`Layer 'B.Courtyard' … is not in fixed layer hash`. Fixed with `LSET::Name()`.
`makeFromBlob` also no longer swallows parse errors — it logs
`[collab] pcbnew blob parse error: <IO_ERROR.What()>`, which is how this was
found. Bare track/via/zone/text changes now apply fine through the envelope
(verified: via moves converge across two tabs); the items-bridge PCB config's
"changed footprint only" scope note is stale and those types can gain coverage.
## Follow-ups (unchanged from 16, minus what this closed)
- Misses 0810 (non-item state sync, collab-aware undo, drift repair) and
optimizations 1214 (beyond what the dirty set already delivers on the emit
hot path) remain open.
- Retire (or un-skip) the legacy two-tab specs; add `localEdit` + track/via
changed coverage to items-bridge (F3 + F6).
- Bug 07 fix direction 2 (envelope generation tag) if presence/multi-sheet work
widens the UP-side window.
- Upstream the `board_commit.cpp` child-removal notification.

View file

@ -196,8 +196,6 @@ test.describe("eeschema ysync repros (v2 items wire, single tab)", () => {
} }
test("an in-place symbol rotation reaches the wire", async ({ page, testLogger }) => { test("an in-place symbol rotation reaches the wire", async ({ page, testLogger }) => {
test.fail(); // bug 04 — GetPosition() unchanged; no orientation in the json
const ok = await armed(page, "kicadCollabTestRotateItem"); const ok = await armed(page, "kicadCollabTestRotateItem");
test.skip(!ok, "wasm build predates the ysync repro hooks"); test.skip(!ok, "wasm build predates the ysync repro hooks");
@ -226,8 +224,6 @@ test.describe("eeschema ysync repros (v2 items wire, single tab)", () => {
}); });
test("a symbol Value field edit reaches the wire", async ({ page, testLogger }) => { test("a symbol Value field edit reaches the wire", async ({ page, testLogger }) => {
test.fail(); // bug 04 — fields are invisible to the snapshot entirely
const ok = await armed(page, "kicadCollabTestSetFieldText"); const ok = await armed(page, "kicadCollabTestSetFieldText");
test.skip(!ok, "wasm build predates the ysync repro hooks"); test.skip(!ok, "wasm build predates the ysync repro hooks");

View file

@ -192,15 +192,13 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
}); });
test("footprint blob preserves pad nets", async ({ page, testLogger }) => { test("footprint blob preserves pad nets", async ({ page, testLogger }) => {
test.fail(); // bug 02 — blobForItem's pad->SetNetCode(0) loop strips them
await bootOpen(page); await bootOpen(page);
const blob = await fp1Blob(page); const blob = await fp1Blob(page);
// CORRECT: peers share the same board/net lineage — identity-by-uuid, not // Peers share the same board/net lineage — identity-by-uuid, not a
// a foreign-board paste — so `(net 1 "SIG")` must survive the wire // foreign-board paste — so the SIG net must survive the wire
// (02-bug-footprint-blob-zeroes-pad-nets.md). TODAY: pads go out net-0 and // (02-bug-footprint-blob-zeroes-pad-nets.md). KiCad 10 formats pad nets by
// the loss propagates to the Y.Doc, peers, and materialized files. // NAME only, `(net "SIG")`; accept the legacy code+name form too.
expect(blob).toContain(`(net 1 "SIG")`); expect(blob).toMatch(/\(net (1 )?"SIG"\)/);
expect(hasAbort(testLogger), "no WASM abort").toBe(false); expect(hasAbort(testLogger), "no WASM abort").toBe(false);
}); });
@ -226,8 +224,6 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
}); });
test("applyItems removes a footprint CHILD by uuid", async ({ page, testLogger }) => { test("applyItems removes a footprint CHILD by uuid", async ({ page, testLogger }) => {
test.fail(); // bug 03 (receiving half) — the parent-footprint guard skips it
await bootOpen(page); await bootOpen(page);
expect(await saveRead(page)).toContain(FP1_TXT); expect(await saveRead(page)).toContain(FP1_TXT);
// The wire a peer sends after deleting the fp_text: a bare child removal // The wire a peer sends after deleting the fp_text: a bare child removal
@ -278,8 +274,6 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
page, page,
testLogger, testLogger,
}) => { }) => {
test.fail(); // bug 05 — the post-apply GLOBAL rebaseline swallows it
await bootOpen(page); await bootOpen(page);
await page.evaluate(() => window.Module.kicadCollabSnapshotItems()); await page.evaluate(() => window.Module.kicadCollabSnapshotItems());
await captureEmits(page); await captureEmits(page);
@ -349,8 +343,6 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
} }
test("an anchor-centred footprint rotation reaches the wire", async ({ page, testLogger }) => { test("an anchor-centred footprint rotation reaches the wire", async ({ page, testLogger }) => {
test.fail(); // bug 04 — no orientation in the footprint's scalar json
const ok = await armed(page, "kicadCollabTestRotateItem"); const ok = await armed(page, "kicadCollabTestRotateItem");
test.skip(!ok, "wasm build predates the ysync repro hooks"); test.skip(!ok, "wasm build predates the ysync repro hooks");
@ -381,8 +373,6 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
}); });
test("a pad size edit reaches the wire", async ({ page, testLogger }) => { test("a pad size edit reaches the wire", async ({ page, testLogger }) => {
test.fail(); // bug 04 — pads are not visited by forEachTopItem at all
const ok = await armed(page, "kicadCollabTestSetPadSize"); const ok = await armed(page, "kicadCollabTestSetPadSize");
test.skip(!ok, "wasm build predates the ysync repro hooks"); test.skip(!ok, "wasm build predates the ysync repro hooks");
@ -412,8 +402,6 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
}); });
test("a graphic-shape endpoint drag reaches the wire", async ({ page, testLogger }) => { test("a graphic-shape endpoint drag reaches the wire", async ({ page, testLogger }) => {
test.fail(); // bug 04 — Drawings' json is position-only; GetPosition() is the start
const ok = await armed(page, "kicadCollabTestMoveEndpoint"); const ok = await armed(page, "kicadCollabTestMoveEndpoint");
test.skip(!ok, "wasm build predates the ysync repro hooks"); test.skip(!ok, "wasm build predates the ysync repro hooks");
@ -471,8 +459,6 @@ test.describe("pcbnew ysync repros (v2 items wire, single tab)", () => {
}); });
test("a child deletion goes out as the parent's re-blob", async ({ page, testLogger }) => { test("a child deletion goes out as the parent's re-blob", async ({ page, testLogger }) => {
test.fail(); // bug 03 (sending half) — flushDiff has no liftBlob for removals
const ok = await armed(page, "kicadCollabTestRemoveItem"); const ok = await armed(page, "kicadCollabTestRemoveItem");
test.skip(!ok, "wasm build predates the ysync repro hooks"); test.skip(!ok, "wasm build predates the ysync repro hooks");

View file

@ -348,7 +348,6 @@ for (const [cfg, label] of [
context, context,
testLogger, testLogger,
}) => { }) => {
test.fail(); // bug 01 — first tab never registers the C++ change listener
// TWO kicad_editor instances exceed Firefox's per-content-process wasm // TWO kicad_editor instances exceed Firefox's per-content-process wasm
// budget (the 2nd tab's #canvas never appears, even serial/isolated — // budget (the 2nd tab's #canvas never appears, even serial/isolated —
// same SpiderMonkey wall playwright-kicad.config.ts documents for x86 // same SpiderMonkey wall playwright-kicad.config.ts documents for x86
@ -403,20 +402,18 @@ for (const [cfg, label] of [
// ── Bug 06 — concurrent first-seed duplicates kdoc_layout ──────────────────── // ── Bug 06 — concurrent first-seed duplicates kdoc_layout ────────────────────
// 06-bug-concurrent-seed-duplicates-layout.md: seed-vs-adopt is client-side // 06-bug-concurrent-seed-duplicates-layout.md: seed-vs-adopt is client-side
// check-then-act; two tabs opening the same fresh room inside the settle // check-then-act; two tabs opening the same fresh room inside the settle
// window both file-seed, and the two kdoc_layout inserts BOTH survive the // window can both file-seed. FIXED by the arbitrated seed (seedDocToY nonce +
// Y.Array merge. The deterministic repro is the unit test // LWW-loser layout retraction — deterministic unit coverage in
// (web/pcbjam-shared/test/ysync-repros.test.ts); this is the real-window // web/pcbjam-shared/test/ysync-repros.test.ts); this drives the real window
// trigger, skipped on runs where the race happens not to fire. // and asserts the room ends on one clean sequence either way.
test.describe("v2 items wire — concurrent seed (bug 06 repro)", () => { test.describe("v2 items wire — concurrent seed (bug 06 repro)", () => {
test.describe.configure({ timeout: 420000 }); test.describe.configure({ timeout: 420000 });
test("both tabs seed a fresh room at once: the room must materialize the single-seed output", async ({ test("both tabs seed a fresh room at once: the room converges on one clean sequence", async ({
context, context,
testLogger, testLogger,
}) => { }) => {
test.fail(); // bug 06 — concurrent first-seed duplicates kdoc_layout
const room = `ysync-v2-pl-race-${test.info().workerIndex}`; const room = `ysync-v2-pl-race-${test.info().workerIndex}`;
const tabA = await context.newPage(); const tabA = await context.newPage();
const tabB = await context.newPage(); const tabB = await context.newPage();
@ -441,21 +438,17 @@ test.describe("v2 items wire — concurrent seed (bug 06 repro)", () => {
.toBe(true); .toBe(true);
const merged = (await renderDoc(tabA)).ok!; const merged = (await renderDoc(tabA)).ok!;
const single = (await tabA.evaluate(
(txt) =>
(window as unknown as { KicadCollabV2: { singleSeedRender(t: string): string } })
.KicadCollabV2.singleSeedRender(txt),
PL.fixture,
)) as string;
// If one tab happened to see the other's seed first (race not triggered), // Whether or not both tabs raced into the seed branch this run, the
// the run is inconclusive — skip rather than "pass unexpectedly" and turn // arbitrated seed (seedDocToY + LWW-loser layout retraction) must leave a
// CI red while the bug is still open. The unit repro is the deterministic one. // SINGLE clean sequence: each root exactly once, the preamble not doubled.
test.skip(merged === single, "seed race did not trigger this run — inconclusive"); // (Byte-comparing against singleSeedRender(fixture) is no longer valid: the
// file-seed path deliberately re-upserts item BODIES in the editor's
// CORRECT: same file, same room → the single-seed materialization. // serialization, so the room's render is editor-normalized, not file-verbatim.)
// TODAY: every root slot + preamble form is doubled, permanently. expect(merged.match(new RegExp(U_RECT, "g")), "border rect appears once").toHaveLength(1);
expect(merged).toBe(single); expect(merged.match(new RegExp(U_TITLE, "g")), "title text appears once").toHaveLength(1);
expect(merged.match(/\(version 20220228\)/g), "preamble not doubled").toHaveLength(1);
expect(merged.match(/\(setup /g), "setup block not doubled").toHaveLength(1);
expect(hasAbort(testLogger), "no WASM abort").toBe(false); expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await tabA.close(); await tabA.close();
@ -476,8 +469,6 @@ test.describe("v2 items wire — pcbnew bare child removal (bug 03 Y-half repro)
context, context,
testLogger, testLogger,
}) => { }) => {
test.fail(); // bug 03 — dangling {item} slot in the parent's Y body
const room = `ysync-v2-pcb-bug03-${test.info().workerIndex}`; const room = `ysync-v2-pcb-bug03-${test.info().workerIndex}`;
const tabA = await context.newPage(); const tabA = await context.newPage();
await bootOpen(tabA, PCB, "tabA"); await bootOpen(tabA, PCB, "tabA");

View file

@ -387,12 +387,62 @@ std::map<std::string, json> snapshotByUuid( SCH_EDIT_FRAME* aFrame )
std::map<std::string, json> g_baseline; std::map<std::string, json> g_baseline;
bool g_flushScheduled = false; bool g_flushScheduled = false;
// Roots the listener saw change since the last flush (uuids, captured at callback
// time — removed items may be freed before the flush runs). The scalar snapshot
// diff is a LOSSY projection: rotations/mirrors (anchor unchanged), field text
// edits (the commit stages the SCH_FIELD, which is not a screen item), stroke
// properties etc. never move it (bug 04). Dirty roots emit their v2 blob
// unconditionally; the apply is an idempotent upsert and the TS layer drops
// no-op bodies, so a false positive costs one local serialization.
std::set<std::string> g_dirty;
// Lift a commit-staged item to the SCREEN item the differ tracks (a field/pin/cell
// lifts to its symbol/sheet/label/table — same promotion sch_commit's undo uses).
void noteDirty( SCH_ITEM* aItem )
{
if( !aItem )
return;
while( EDA_ITEM* p = aItem->GetParent() )
{
if( !p->IsType( { SCH_SYMBOL_T, SCH_TABLE_T, SCH_SHEET_T, SCH_LABEL_LOCATE_ANY_T } ) )
break;
aItem = static_cast<SCH_ITEM*>( p );
}
g_dirty.insert( toUtf8( aItem->m_Uuid.AsString() ) );
}
// Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after // Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after
// applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo). // applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo).
// Declares "current model == broadcast state", so pending dirty marks are stale too — on a
// sheet switch they'd otherwise emit the OLD sheet's items into the new sheet's room.
void rebaseline() void rebaseline()
{ {
if( SCH_EDIT_FRAME* fr = schFrame() ) if( SCH_EDIT_FRAME* fr = schFrame() )
g_baseline = snapshotByUuid( fr ); g_baseline = snapshotByUuid( fr );
g_dirty.clear();
}
// TARGETED rebaseline (bug 05): refresh baseline entries ONLY for the uuids a remote
// apply touched. A global rebaseline() would fold a concurrently-committed local edit
// (its flush is queued BEHIND the apply on the same pending-event list) into the
// baseline and silently swallow it; targeted, the edit still diffs and emits. The
// connectivity cleanup the apply's Push produced likewise stays diffable — the
// post-apply flush broadcasts it (idempotent on the original sender).
void rebaselineTouched( SCH_EDIT_FRAME* aFrame, const std::vector<std::string>& aIds )
{
for( const std::string& id : aIds )
{
g_baseline.erase( id );
KIID kid( wxString::FromUTF8( id.c_str() ) );
if( SCH_ITEM* live = aFrame->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) )
g_baseline[id] = itemToJson( live );
}
} }
// Diff the current (settled, post-cleanup) model against the baseline and broadcast the change. // Diff the current (settled, post-cleanup) model against the baseline and broadcast the change.
@ -411,10 +461,14 @@ void flushDiff()
// v2 items wire (per-item s-expr blobs), built from the same diff. Screen items // v2 items wire (per-item s-expr blobs), built from the same diff. Screen items
// are already root-level (fields live inside their symbols), so no lifting. // are already root-level (fields live inside their symbols), so no lifting.
json wAdded = json::array(), wChanged = json::array(); json wAdded = json::array(), wChanged = json::array();
std::set<std::string> wDone;
auto blobFor = [&]( const std::string& id, json& aArr ) auto blobFor = [&]( const std::string& id, json& aArr )
{ {
if( !wDone.insert( id ).second )
return;
KIID kid( wxString::FromUTF8( id.c_str() ) ); KIID kid( wxString::FromUTF8( id.c_str() ) );
if( SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) ) if( SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) )
@ -443,13 +497,22 @@ void flushDiff()
removed.push_back( id ); removed.push_back( id );
} }
// Dirty roots (bug 04): whatever the listener saw commit emits its blob on the
// v2 wire even when the scalar projection didn't move (rotation, field text,
// stroke edits). wDone dedups against the scalar-diff emits; deleted ids
// resolve null inside blobFor and skip (the removal loop covered them).
for( const std::string& id : g_dirty )
blobFor( id, wChanged );
g_dirty.clear();
g_baseline = std::move( cur ); g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() ) if( !added.empty() || !changed.empty() || !removed.empty() )
{
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } ); emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
if( !wAdded.empty() || !wChanged.empty() || !removed.empty() )
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } ); emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
}
} }
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop // Coalesce all the listener callbacks of one commit (and any other edits in the same loop
@ -548,10 +611,10 @@ public:
if( item->Type() == SCH_SHEET_T ) if( item->Type() == SCH_SHEET_T )
scheduleSheetSave( static_cast<SCH_SHEET*>( item ) ); scheduleSheetSave( static_cast<SCH_SHEET*>( item ) );
} }
trigger(); trigger( aItems );
} }
void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& ) override { trigger(); } void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& v ) override { trigger( v ); }
void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& ) override { trigger(); } void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& v ) override { trigger( v ); }
// The editor switched to a different sheet (a different .kicad_sch == a different // The editor switched to a different sheet (a different .kicad_sch == a different
// collab room). Re-baseline so the first edit on the new sheet diffs against ITS // collab room). Re-baseline so the first edit on the new sheet diffs against ITS
@ -565,10 +628,17 @@ public:
} }
private: private:
void trigger() // Capture the touched roots at callback time (fields/pins lift to their
// symbol — noteDirty), then coalesce into one post-settle flush.
void trigger( const std::vector<SCH_ITEM*>& aItems )
{ {
if( !s_applyingRemote ) if( s_applyingRemote )
scheduleFlush(); return;
for( SCH_ITEM* item : aItems )
noteDirty( item );
scheduleFlush();
} }
}; };
@ -744,11 +814,15 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
SCH_COMMIT commit( aFrame ); SCH_COMMIT commit( aFrame );
bool staged = false; bool staged = false;
std::vector<std::string> touched; // uuids this apply acts on (targeted rebaseline)
for( const json& rid : aWire.value( "removed", json::array() ) ) for( const json& rid : aWire.value( "removed", json::array() ) )
{ {
SCH_SHEET_PATH path; SCH_SHEET_PATH path;
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) ); KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
touched.push_back( rid.get<std::string>() );
if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) ) if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) )
{ {
commit.Remove( item, path.LastScreen() ); commit.Remove( item, path.LastScreen() );
@ -830,6 +904,7 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
} }
item->SetParent( &sch ); item->SetParent( &sch );
touched.push_back( toUtf8( item->m_Uuid.AsString() ) );
commit.Add( item, aFrame->GetScreen() ); commit.Add( item, aFrame->GetScreen() );
staged = true; staged = true;
} }
@ -843,10 +918,13 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
if( staged ) if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) ); commit.Push( wxT( "Collaborative edit (items)" ) );
// Fold the applied state into the baseline so the post-apply listener flush // Fold ONLY the applied uuids into the baseline (echo suppression), then flush:
// doesn't re-broadcast it as a local diff (echo). // anything else that now differs — a concurrent local edit, the connectivity
rebaseline(); // cleanup this apply's Push produced — broadcasts as a normal local diff
// instead of being swallowed (bug 05).
rebaselineTouched( aFrame, touched );
s_applyingRemote = false; s_applyingRemote = false;
scheduleFlush();
} }
// Test/PoC move (the SCH_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter). // Test/PoC move (the SCH_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter).

View file

@ -185,6 +185,12 @@ json itemToJson( BOARD_ITEM* aItem )
{ "layer", itemLayer( aItem ) }, // devirtualized — aItem->GetLayer() mis-dispatches here { "layer", itemLayer( aItem ) }, // devirtualized — aItem->GetLayer() mis-dispatches here
}; };
// Parent footprint uuid (or absent for roots). Carried in the baseline so a
// REMOVED child can still be attributed to its parent after the live item is
// gone — flushDiff lifts such removals to a parent re-blob on the v2 wire.
if( FOOTPRINT* fp = aItem->GetParentFootprint() )
j["parent"] = toUtf8( fp->m_Uuid.AsString() );
if( isTrackType( aItem->Type() ) ) if( isTrackType( aItem->Type() ) )
{ {
auto* tr = static_cast<PCB_TRACK*>( aItem ); auto* tr = static_cast<PCB_TRACK*>( aItem );
@ -290,10 +296,11 @@ std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
} }
// The rest of SaveSelection's footprint safety steps, minus the refPoint move // The rest of SaveSelection's footprint safety steps, minus the refPoint move
// (the wire carries absolute positions). // (the wire carries absolute positions) and minus SetNetCode(0): zeroing pad
for( PAD* pad : copy.Pads() ) // nets is a paste-into-FOREIGN-board safety, but collab peers edit the SAME
pad->SetNetCode( 0 ); // board — nets must survive the wire. KiCad 10 formats pad nets by NAME and
// the parser resolves by name against the receiver's board (creating the net
// if missing), so no code remapping is needed on apply.
copy.SetLocked( false ); copy.SetLocked( false );
CLIPBOARD_IO io; CLIPBOARD_IO io;
@ -333,9 +340,26 @@ BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlob )
CLIPBOARD_IO io; CLIPBOARD_IO io;
io.SetBoard( &aBoard ); io.SetBoard( &aBoard );
io.SetReader( [&aBlob]() -> wxString { return wxString::FromUTF8( aBlob.c_str() ); } );
BOARD_ITEM* parsed = io.Parse(); // FOOTPRINT* (bare) | BOARD* (envelope) | nullptr // Parse directly (not io.Parse(), whose catch(...) swallows the error): a
// failed apply must say WHY, or wire bugs surface as silent non-convergence.
BOARD_ITEM* parsed = nullptr; // FOOTPRINT* (bare) | BOARD* (envelope) | nullptr
try
{
parsed = io.PCB_IO_KICAD_SEXPR::Parse( wxString::FromUTF8( aBlob.c_str() ) );
}
catch( const IO_ERROR& e )
{
EM_ASM( { console.log( "[collab] pcbnew blob parse error: " + UTF8ToString( $0 ) ); },
std::string( e.What().utf8_str() ).c_str() );
return nullptr;
}
catch( ... )
{
EM_ASM( { console.log( "[collab] pcbnew blob parse error: unknown exception" ); } );
return nullptr;
}
if( !parsed ) if( !parsed )
return nullptr; return nullptr;
@ -384,8 +408,12 @@ std::string wrapInBoardEnvelope( BOARD& aBoard, const std::string& aItemSexpr )
const char* type = IsCopperLayer( id ) ? LAYER::ShowType( aBoard.GetLayerType( id ) ) const char* type = IsCopperLayer( id ) ? LAYER::ShowType( aBoard.GetLayerType( id ) )
: "user"; : "user";
// CANONICAL name (LSET::Name), NOT GetLayerName(): the parser validates
// position 2 against the fixed layer hash, and user-visible names differ
// from canonical ones (e.g. "B.Courtyard" vs "B.CrtYd") — the envelope
// parse threw "not in fixed layer hash" for any board with such layers.
s += " (" + std::to_string( (int) id ) + " \"" s += " (" + std::to_string( (int) id ) + " \""
+ std::string( aBoard.GetLayerName( id ).utf8_str() ) + "\" " + type + ")"; + std::string( LSET::Name( id ).utf8_str() ) + "\" " + type + ")";
} }
s += ") " + aItemSexpr + ")"; s += ") " + aItemSexpr + ")";
@ -562,12 +590,85 @@ std::map<std::string, json> snapshotByUuid( BOARD& aBoard )
std::map<std::string, json> g_baseline; std::map<std::string, json> g_baseline;
bool g_flushScheduled = false; bool g_flushScheduled = false;
// Roots the listener saw change since the last flush (uuids, children lifted to
// their footprint at capture time). The scalar snapshot diff below is a LOSSY
// projection (id/type/x/y/layer + a few extras) — edits that don't move the
// projection (pad/zone property edits, anchor-centred rotations, endpoint drags)
// would otherwise never emit (bug 04). Dirty roots emit their v2 blob
// unconditionally; the wire apply is an idempotent upsert, so a false positive
// (a commit that changed nothing) costs one no-op echo.
std::set<std::string> g_dirty;
void noteDirty( BOARD_ITEM* aItem )
{
if( !aItem )
return;
if( FOOTPRINT* fp = aItem->GetParentFootprint() )
aItem = fp;
g_dirty.insert( toUtf8( aItem->m_Uuid.AsString() ) );
}
// Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after // Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after
// applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo). // applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo).
// Declares "current model == broadcast state", so pending dirty marks are stale too.
void rebaseline() void rebaseline()
{ {
if( PCB_EDIT_FRAME* fr = pcbFrame() ) if( PCB_EDIT_FRAME* fr = pcbFrame() )
g_baseline = snapshotByUuid( *fr->GetBoard() ); g_baseline = snapshotByUuid( *fr->GetBoard() );
g_dirty.clear();
}
// TARGETED rebaseline (bug 05): refresh baseline entries ONLY for the uuids a remote
// apply touched. A global rebaseline() here would fold a concurrently-committed local
// edit (its flush is queued BEHIND the apply on the same pending-event list) into the
// baseline and silently swallow it; with the targeted update the edit's uuids keep
// their pre-edit entries and the queued flush still emits it. Receiver-side cleanup
// the apply's Push produced likewise stays diffable — the post-apply flush broadcasts
// it, and re-application on the original sender is idempotent.
void rebaselineTouched( BOARD* aBoard, const std::vector<std::string>& aIds )
{
for( const std::string& id : aIds )
{
// Drop the stale entry — and any child entries it owned (their parent
// field carries the root uuid) — then re-snapshot whatever is live now.
g_baseline.erase( id );
for( auto it = g_baseline.begin(); it != g_baseline.end(); )
{
if( it->second.value( "parent", std::string() ) == id )
it = g_baseline.erase( it );
else
++it;
}
BOARD_ITEM* live = aBoard->ResolveItem( KIID( wxString::FromUTF8( id.c_str() ) ),
/*allowNull*/ true );
if( !live )
continue;
g_baseline[id] = itemToJson( live );
if( live->Type() == PCB_FOOTPRINT_T )
{
FOOTPRINT* f = static_cast<FOOTPRINT*>( live );
for( PCB_FIELD* fld : f->GetFields() )
{
if( fld )
g_baseline[toUtf8( fld->m_Uuid.AsString() )] = itemToJson( fld );
}
for( BOARD_ITEM* g : f->GraphicalItems() )
{
if( g->Type() == PCB_TEXT_T )
g_baseline[toUtf8( g->m_Uuid.AsString() )] = itemToJson( g );
}
}
}
} }
// Diff the current (settled, post-cleanup) model against the baseline and broadcast the change. // Diff the current (settled, post-cleanup) model against the baseline and broadcast the change.
@ -654,19 +755,44 @@ void flushDiff()
} }
} }
// v2 removals diverge from the legacy wire: a removed footprint CHILD whose
// parent survives lifts to the parent's re-blob (wChanged) — the new body
// carries the post-delete child set, and the receiver's parent-replace covers
// the deletion. A bare child removal would strand a dangling {item} slot in
// the Y-side parent body (bug 03). The legacy wire keeps the raw uuid list
// (its receiver skips footprint children anyway).
json wRemoved = json::array();
for( const auto& [id, j] : g_baseline ) for( const auto& [id, j] : g_baseline )
{ {
if( !cur.count( id ) ) if( cur.count( id ) )
removed.push_back( id ); continue;
removed.push_back( id );
std::string parentId = j.value( "parent", std::string() );
if( !parentId.empty() && cur.count( parentId ) )
liftBlob( parentId, wChanged );
else
wRemoved.push_back( id );
} }
// Dirty roots (bug 04): whatever the listener saw commit emits its blob on
// the v2 wire even when the scalar projection didn't move. wDone dedups
// against the scalar-diff emits above; deleted ids resolve null and skip.
for( const std::string& id : g_dirty )
liftBlob( id, wChanged );
g_dirty.clear();
g_baseline = std::move( cur ); g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() ) if( !added.empty() || !changed.empty() || !removed.empty() )
{
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } ); emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
} if( !wAdded.empty() || !wChanged.empty() || !wRemoved.empty() )
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", wRemoved } } );
} }
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop // Coalesce all the listener callbacks of one commit (and any other edits in the same loop
@ -706,21 +832,33 @@ void scheduleFlush()
class COLLAB_LISTENER : public BOARD_LISTENER class COLLAB_LISTENER : public BOARD_LISTENER
{ {
public: public:
void OnBoardItemAdded( BOARD&, BOARD_ITEM* ) override { trigger(); } void OnBoardItemAdded( BOARD&, BOARD_ITEM* i ) override { trigger( { i } ); }
void OnBoardItemsAdded( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); } void OnBoardItemsAdded( BOARD&, std::vector<BOARD_ITEM*>& v ) override { trigger( v ); }
void OnBoardItemRemoved( BOARD&, BOARD_ITEM* ) override { trigger(); } void OnBoardItemRemoved( BOARD&, BOARD_ITEM* i ) override { trigger( { i } ); }
void OnBoardItemsRemoved( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); } void OnBoardItemsRemoved( BOARD&, std::vector<BOARD_ITEM*>& v ) override { trigger( v ); }
void OnBoardItemChanged( BOARD&, BOARD_ITEM* ) override { trigger(); } void OnBoardItemChanged( BOARD&, BOARD_ITEM* i ) override { trigger( { i } ); }
void OnBoardItemsChanged( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); } void OnBoardItemsChanged( BOARD&, std::vector<BOARD_ITEM*>& v ) override { trigger( v ); }
void OnBoardCompositeUpdate( BOARD&, std::vector<BOARD_ITEM*>&, void OnBoardCompositeUpdate( BOARD&, std::vector<BOARD_ITEM*>& a,
std::vector<BOARD_ITEM*>&, std::vector<BOARD_ITEM*>& r,
std::vector<BOARD_ITEM*>& ) override { trigger(); } std::vector<BOARD_ITEM*>& c ) override
{
trigger( a );
trigger( r );
trigger( c );
}
private: private:
void trigger() // Capture the touched roots at callback time (uuid strings — removed items
// may be freed before the flush runs), then coalesce into one flush.
void trigger( const std::vector<BOARD_ITEM*>& aItems )
{ {
if( !s_applyingRemote ) if( s_applyingRemote )
scheduleFlush(); return;
for( BOARD_ITEM* item : aItems )
noteDirty( item );
scheduleFlush();
} }
}; };
@ -829,15 +967,30 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
BOARD_COMMIT commit( aFrame ); BOARD_COMMIT commit( aFrame );
bool staged = false; bool staged = false;
std::vector<std::string> touched; // root uuids this apply acts on (targeted rebaseline)
std::set<std::string> removedIds;
for( const json& rid : aWire.value( "removed", json::array() ) ) for( const json& rid : aWire.value( "removed", json::array() ) )
removedIds.insert( rid.get<std::string>() );
for( const std::string& rid : removedIds )
{ {
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) ); KIID id( wxString::FromUTF8( rid.c_str() ) );
touched.push_back( rid );
if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) ) if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) )
{ {
// A child uuid in `removed` is covered by its parent's replace/remove. if( FOOTPRINT* pfp = item->GetParentFootprint() )
if( item->GetParentFootprint() ) {
continue; // Covered by the parent's own removal when the whole footprint
// goes. A BARE child removal must remove the child itself
// (bug 03 receiving half) — the sender now lifts these to a
// parent re-blob, but Y-rendered wires can still carry them.
if( removedIds.count( toUtf8( pfp->m_Uuid.AsString() ) ) )
continue;
}
commit.Remove( item ); commit.Remove( item );
staged = true; staged = true;
@ -876,6 +1029,8 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
commit.Remove( existing ); commit.Remove( existing );
} }
touched.push_back( toUtf8( parsed->m_Uuid.AsString() ) );
commit.Add( parsed ); commit.Add( parsed );
staged = true; staged = true;
}; };
@ -888,10 +1043,12 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
if( staged ) if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) ); commit.Push( wxT( "Collaborative edit (items)" ) );
// Fold the applied state into the baseline so the post-apply listener flush // Fold ONLY the applied uuids into the baseline (echo suppression), then flush:
// doesn't re-broadcast it as a local diff (echo). // anything else that now differs — a concurrent local edit, cleanup this apply's
rebaseline(); // Push produced — broadcasts as a normal local diff instead of being swallowed.
rebaselineTouched( board, touched );
s_applyingRemote = false; s_applyingRemote = false;
scheduleFlush();
} }
// Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by // Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by

@ -1 +1 @@
Subproject commit 2eca2c31a2dd7a89e8a4a919beb65f465d9e6d0f Subproject commit f12d9af9595c915bb17a31bb59d156d38b256cbf

View file

@ -3,13 +3,15 @@ import {
applyDeltaToY, applyDeltaToY,
deltaFromYEvents, deltaFromYEvents,
deltaToItemsWire, deltaToItemsWire,
docToY,
isEmptyItemsWireDelta, isEmptyItemsWireDelta,
isEmptyKicadDelta, isEmptyKicadDelta,
itemsWireToDelta, itemsWireToDelta,
kicadItemsMap, kicadItemsMap,
parseItemsWireDelta, parseItemsWireDelta,
renderItem, renderItem,
seedDocToY,
Y_KDOC_META,
Y_KDOC_SEED_NONCE,
ydocHasState, ydocHasState,
yToItem, yToItem,
type ItemsWireDelta, type ItemsWireDelta,
@ -77,6 +79,13 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
// to trap eeschema's paste path in the real app). seed()'s adopt branch covers // to trap eeschema's paste path in the real app). seed()'s adopt branch covers
// everything those early events contained. // everything those early events contained.
let seeded = false; let seeded = false;
// Flipped by destroy(): the DOWN hook (window.kicadCollab.onItems) can't be
// unregistered from the C++ side, so a stale emit after destroy — e.g. in the
// sheet-switch gap, when C++ has already rebaselined to the NEW sheet — must
// be dropped here or it writes the new sheet's items into the OLD room (bug 07).
let destroyed = false;
// Concurrent double-seed arbitration cleanup (bug 06); set by the file-seed branch.
let detachSeedArbitration: (() => void) | undefined;
/** Plain snapshot of the Y items (the `current`/`view` the conversions need). */ /** Plain snapshot of the Y items (the `current`/`view` the conversions need). */
const itemsView = (): Record<string, KicadItem> => { const itemsView = (): Record<string, KicadItem> => {
@ -89,6 +98,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
// DOWN: local editor change → Y.Doc // DOWN: local editor change → Y.Doc
bridge.onItems((json: string) => { bridge.onItems((json: string) => {
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert
let wire: ItemsWireDelta; let wire: ItemsWireDelta;
try { try {
wire = parseItemsWireDelta(json); wire = parseItemsWireDelta(json);
@ -149,7 +159,40 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
clog( clog(
`seed: doc empty → SEEDING from file (${Object.keys(seedDoc.items).length} item(s), root ${seedDoc.root})`, `seed: doc empty → SEEDING from file (${Object.keys(seedDoc.items).length} item(s), root ${seedDoc.root})`,
); );
docToY(seedDoc, doc, ORIGIN); // Arbitrated seed (bug 06): the empty-room check above is check-then-act,
// so a peer may be seeding concurrently. seedDocToY stamps our nonce; if a
// FOREIGN nonce wins the meta LWW merge, our layout inserts are retracted
// (kdoc_items converges per key on its own) leaving the winner's single
// clean sequence.
const nonce = `${doc.clientID}:${Math.random().toString(36).slice(2)}`;
const retract = seedDocToY(seedDoc, doc, ORIGIN, nonce);
const meta = doc.getMap(Y_KDOC_META);
const onMeta = () => {
const winner = meta.get(Y_KDOC_SEED_NONCE);
if (winner !== undefined && winner !== nonce) {
detachSeedArbitration?.();
detachSeedArbitration = undefined;
retract();
clog("seed: concurrent double-seed lost LWW — retracted our layout inserts");
}
};
meta.observe(onMeta);
detachSeedArbitration = () => meta.unobserve(onMeta);
// snapshotItems() does double duty here. Its side effects register the
// C++ change listener (bug 01 — without it this tab would receive but
// never SEND) and rebaseline the wasm differ. Its RESULT re-upserts the
// item bodies in the EDITOR's serialization: the file's formatting and
// the writer's normalized output can differ textually, and every future
// emit/drift-compare uses the writer's form — keeping file-formatted
// bodies would false-positive drift-detect on every file-seeded room
// and defeat upsertYItem's no-op skip. Meta + layout stay file-derived.
try {
const wire = parseItemsWireDelta(bridge.snapshotItems());
const local = itemsWireToDelta(wire, itemsView());
if (!isEmptyKicadDelta(local)) applyDeltaToY(doc, local, ORIGIN);
} catch (err) {
cwarn("seed: post-file-seed baseline failed", err);
}
return; return;
} }
@ -190,7 +233,12 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
return { return {
seed, seed,
destroy: () => items.unobserveDeep(observer), destroy: () => {
destroyed = true; // gates the DOWN hook — see bug 07 note above
detachSeedArbitration?.();
detachSeedArbitration = undefined;
items.unobserveDeep(observer);
},
items, items,
}; };
} }

View file

@ -101,6 +101,11 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
// switches run one-at-a-time so concurrent `onSheetChanged` events can't interleave. // switches run one-at-a-time so concurrent `onSheetChanged` events can't interleave.
let requestedPath: string | null = null; let requestedPath: string | null = null;
let queue: Promise<void> = Promise.resolve(); let queue: Promise<void> = Promise.resolve();
// Failed-switch retry backoff (bug 07): a failed ensureRoom used to leave the
// editor unbound forever — every subsequent edit unsynced until the next manual
// navigation. Event-driven retry, doubling 2s→30s, reset on any success.
let retryDelayMs = 2000;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
if (opts.initial) { if (opts.initial) {
const { sheetPath, session, editorMatchesDoc } = opts.initial; const { sheetPath, session, editorMatchesDoc } = opts.initial;
@ -204,16 +209,34 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
function switchTo(sheetPath: string): Promise<void> { function switchTo(sheetPath: string): Promise<void> {
requestedPath = sheetPath; requestedPath = sheetPath;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
queue = queue queue = queue
.then(() => { .then(() => {
// Superseded by a newer navigation — skip this stale switch. The editor's active // Superseded by a newer navigation — skip this stale switch. The editor's active
// screen always reflects `requestedPath`, so we only bind when they agree (the // screen always reflects `requestedPath`, so we only bind when they agree (the
// seed/snapshot then reads the right screen). // seed/snapshot then reads the right screen).
if (requestedPath !== sheetPath) return; if (requestedPath !== sheetPath) return;
return doSwitch(sheetPath); return doSwitch(sheetPath).then(() => {
retryDelayMs = 2000; // bound succeeded — reset the backoff
});
}) })
.catch((err) => { .catch((err) => {
cwarn(`[sheet] switchTo(${sheetPath}) failed`, err); cwarn(`[sheet] switchTo(${sheetPath}) failed`, err);
// Still the sheet the editor shows and not yet bound → retry with backoff,
// else the editor stays unbound and every edit silently never syncs.
if (requestedPath === sheetPath && activePath !== sheetPath) {
retryTimer = setTimeout(() => {
retryTimer = undefined;
if (requestedPath === sheetPath && activePath !== sheetPath) {
log(`[sheet] retrying switch to ${sheetPath}`);
void switchTo(sheetPath);
}
}, retryDelayMs);
retryDelayMs = Math.min(retryDelayMs * 2, 30000);
}
}); });
return queue; return queue;
} }
@ -246,6 +269,10 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
} }
function destroy(): void { function destroy(): void {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
for (const [path, room] of rooms) { for (const [path, room] of rooms) {
try { try {
room.detachWatch?.(); room.detachWatch?.();

View file

@ -128,19 +128,19 @@ describe("bug 01 — first-ever tab (file-seed branch) never registers the C++ l
return { edA, edB }; return { edA, edB };
} }
it.fails("the file-seed branch calls snapshotItems (the listener-registration contract)", () => { it("the file-seed branch calls snapshotItems (the listener-registration contract)", () => {
const { edA } = freshRoomFileSeed(); const { edA } = freshRoomFileSeed();
// The one-line fix's contract: like the editorMatchesDoc branch, the // The one-line fix's contract: like the editorMatchesDoc branch, the
// file-seed branch must call snapshotItems() for its SIDE EFFECTS // file-seed branch must call snapshotItems() for its SIDE EFFECTS
// (ensureBridge listener registration + differ baseline). TODAY: 0 calls. // (ensureBridge listener registration + differ baseline).
expect(edA.snapshotCalls).toBeGreaterThan(0); expect(edA.snapshotCalls).toBeGreaterThan(0);
}); });
it.fails("a local edit on the seeding tab reaches the joining peer", () => { it("a local edit on the seeding tab reaches the joining peer", () => {
const { edA, edB } = freshRoomFileSeed(); const { edA, edB } = freshRoomFileSeed();
edA.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-new"))`, null, "added"); edA.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-new"))`, null, "added");
// TODAY: A's listener was never registered → the edit is never emitted → // Regression cover for bug 01: without the listener registration the edit
// the peer never receives it (the first-session "seeder can't send" hole). // was never emitted (the first-session "seeder can't send" hole).
expect(edB.store["seg-new"]).toBeDefined(); expect(edB.store["seg-new"]).toBeDefined();
}); });
@ -159,7 +159,7 @@ describe("bug 01 — first-ever tab (file-seed branch) never registers the C++ l
// writes into the (now supposedly detached) doc. // writes into the (now supposedly detached) doc.
describe("bug 07a — destroy() leaves the DOWN hook (onItems) attached", () => { describe("bug 07a — destroy() leaves the DOWN hook (onItems) attached", () => {
it.fails("an emit after destroy() must not write into the doc", () => { it("an emit after destroy() must not write into the doc", () => {
const doc = new Y.Doc(); const doc = new Y.Doc();
const ed = new CppFaithfulEditor(); const ed = new CppFaithfulEditor();
const binding = bindKicadCollab(doc, ed); const binding = bindKicadCollab(doc, ed);
@ -190,7 +190,7 @@ describe("bug 07a — destroy() leaves the DOWN hook (onItems) attached", () =>
// to the new sheet, so a local edit emits a new-sheet diff into the OLD room. // to the new sheet, so a local edit emits a new-sheet diff into the OLD room.
describe("bug 07b — sheet-switch gap: stale onItems writes into the old sheet's room", () => { describe("bug 07b — sheet-switch gap: stale onItems writes into the old sheet's room", () => {
it.fails("an emit during a cold-room switch gap must not land in the old doc", async () => { it("an emit during a cold-room switch gap must not land in the old doc", async () => {
const docs: Y.Doc[] = []; const docs: Y.Doc[] = [];
let releaseB!: () => void; let releaseB!: () => void;
const gateB = new Promise<void>((resolve) => (releaseB = resolve)); const gateB = new Promise<void>((resolve) => (releaseB = resolve));