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

@ -3,13 +3,15 @@ import {
applyDeltaToY,
deltaFromYEvents,
deltaToItemsWire,
docToY,
isEmptyItemsWireDelta,
isEmptyKicadDelta,
itemsWireToDelta,
kicadItemsMap,
parseItemsWireDelta,
renderItem,
seedDocToY,
Y_KDOC_META,
Y_KDOC_SEED_NONCE,
ydocHasState,
yToItem,
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
// everything those early events contained.
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). */
const itemsView = (): Record<string, KicadItem> => {
@ -89,6 +98,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
// DOWN: local editor change → Y.Doc
bridge.onItems((json: string) => {
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert
let wire: ItemsWireDelta;
try {
wire = parseItemsWireDelta(json);
@ -149,7 +159,40 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
clog(
`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;
}
@ -190,7 +233,12 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
return {
seed,
destroy: () => items.unobserveDeep(observer),
destroy: () => {
destroyed = true; // gates the DOWN hook — see bug 07 note above
detachSeedArbitration?.();
detachSeedArbitration = undefined;
items.unobserveDeep(observer);
},
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.
let requestedPath: string | null = null;
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) {
const { sheetPath, session, editorMatchesDoc } = opts.initial;
@ -204,16 +209,34 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
function switchTo(sheetPath: string): Promise<void> {
requestedPath = sheetPath;
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
queue = queue
.then(() => {
// 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
// seed/snapshot then reads the right screen).
if (requestedPath !== sheetPath) return;
return doSwitch(sheetPath);
return doSwitch(sheetPath).then(() => {
retryDelayMs = 2000; // bound succeeded — reset the backoff
});
})
.catch((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;
}
@ -246,6 +269,10 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
}
function destroy(): void {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
for (const [path, room] of rooms) {
try {
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 };
}
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();
// The one-line fix's contract: like the editorMatchesDoc branch, the
// 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);
});
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();
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 →
// the peer never receives it (the first-session "seeder can't send" hole).
// Regression cover for bug 01: without the listener registration the edit
// was never emitted (the first-session "seeder can't send" hole).
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.
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 ed = new CppFaithfulEditor();
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.
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[] = [];
let releaseB!: () => void;
const gateB = new Promise<void>((resolve) => (releaseB = resolve));