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:
parent
4e92baf5d0
commit
ae5d41a3d7
18 changed files with 527 additions and 109 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue