ysync bug 07 UP side: superseded sheet switch never adopts onto the new screen + apply envelope sheet tag/guard (subsheet self-reference fix)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013MnySXALJiYsRQ5mCrxgwX
This commit is contained in:
Gergő Törcsvári 2026-08-28 18:19:29 +02:00
commit af07a413ee
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 306 additions and 7 deletions

@ -1 +1 @@
Subproject commit 0f4d3a1e336fd5398fc071bd3a4181983b7eb3fb
Subproject commit 50143831109cd59e32db0e2c62c0e1f2320dd005

View file

@ -134,6 +134,33 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
}
});
it("stamps every apply envelope (adopt + remote change) with the binding's sheetPath", () => {
const { a, b } = pair();
const edA = new FakeEditor();
const edB = new FakeEditor();
const bindA = bindKicadCollab(a, edA, { sheetPath: "Arduino Mega 2560/root.kicad_sch" });
const bindB = bindKicadCollab(b, edB, { sheetPath: "Arduino Mega 2560/root.kicad_sch" });
seedEditor(edA, FP);
bindA.seed();
bindB.seed(); // adopt apply
edA.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-1"))`, null, "added"); // remote apply
expect(edB.applied.length).toBeGreaterThanOrEqual(2);
for (const json of edB.applied) {
expect((JSON.parse(json) as { sheet?: string }).sheet).toBe("Arduino Mega 2560/root.kicad_sch");
}
// Untagged binding (single-file tools) leaves the envelope alone.
const { a: c, b: d } = pair();
const edC = new FakeEditor();
const edD = new FakeEditor();
bindKicadCollab(c, edC).seed();
seedEditor(edC, FP);
bindKicadCollab(d, edD).seed();
edC.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-2"))`, null, "added");
for (const json of edD.applied) {
expect("sheet" in (JSON.parse(json) as object)).toBe(false);
}
});
it("a remote apply does not bounce back to the originator", () => {
const { edA, edB, bindA, bindB } = setup();
seedEditor(edA, FP);

View file

@ -115,9 +115,19 @@ export function bindKicadCollab(
* server-side; this keeps the client honest and quiet.
*/
readOnly?: boolean;
/**
* Project-relative path of the sheet this binding serves. Stamped on
* every applyItems envelope so the C++ side can refuse to apply it onto
* a different (now-active) screen ysync bug 07 UP side, the 8/28
* root-items-in-subsheet corruption.
*/
sheetPath?: string;
},
): KicadBinding {
const readOnly = opts?.readOnly === true;
const sheetPath = opts?.sheetPath;
const tagged = (wire: ItemsWireDelta): ItemsWireDelta =>
sheetPath === undefined ? wire : { ...wire, sheet: sheetPath };
// Version skew guard — callers bind AFTER the provider's initial sync, so the
// doc's version is authoritative here (an empty room reads as v1 and is
// stamped CURRENT by the first write). A read-only viewer never writes, but
@ -218,7 +228,7 @@ export function bindKicadCollab(
removed: wire.removed.length,
});
try {
bridge.applyItems(JSON.stringify(wire));
bridge.applyItems(JSON.stringify(tagged(wire)));
} catch (err) {
// Symmetric with the DOWN hook's backstop above (findings C-7): a throw
// here would otherwise unwind through Yjs's transaction cleanup inside
@ -461,7 +471,7 @@ export function bindKicadCollab(
`+${adoptWire.added.length} ~${adoptWire.changed.length} -${adoptWire.removed.length}`,
);
if (isEmptyItemsWireDelta(adoptWire)) return; // editor already matches — baseline only
bridge.applyItems(JSON.stringify(adoptWire));
bridge.applyItems(JSON.stringify(tagged(adoptWire)));
}
return {

View file

@ -109,6 +109,16 @@ describe("sheet-manager warm pool", () => {
expect(connectKicadDoc).toHaveBeenCalledTimes(2);
});
it("binds each room with its own sheetPath (apply envelope tag, bug 07 UP side)", async () => {
const m = makeManager();
await m.switchTo("a.kicad_sch");
await m.switchTo("sub/b.kicad_sch");
expect(bindKicadCollab.mock.calls.map((c) => (c[2] as { sheetPath?: string }).sheetPath)).toEqual([
"a.kicad_sch",
"sub/b.kicad_sch",
]);
});
it("first switch binds + seeds the active sheet", async () => {
const m = makeManager();
await m.switchTo("a.kicad_sch");
@ -307,6 +317,42 @@ describe("sheet-manager lifecycle hardening (findings C-1/C-4/C-5)", () => {
expect(events).toEqual(["bind:a.kicad_sch", "clear", "bind:b.kicad_sch"]);
});
it("a switch superseded DURING its connect never binds/adopts onto the new screen (ysync bug 07 UP-side)", async () => {
// Real-world corruption (8/28, mega-demo-v2): the root's switch was still
// awaiting its room connect when the user entered a subsheet. The root
// switch then completed, bound the ROOT doc while the editor showed the
// SUBSHEET, and its adopt replaced the subsheet's items with the root's
// (incl. the `(sheet …)` pointing at the subsheet itself) — which the
// subsheet's own bind then wrote into the subsheet's room.
const m = makeManager();
let releaseRoot!: () => void;
connectKicadDoc.mockImplementationOnce(
({ room }: { room: string }) =>
new Promise((res) => {
releaseRoot = () => {
const session = { room, doc: makeDoc(), provider: { destroy: vi.fn() } };
sessions.push(session);
res(session);
};
}),
);
const rootSwitch = m.switchTo("root.kicad_sch");
await new Promise((r) => setTimeout(r, 0)); // root switch parked on its connect
const subSwitch = m.switchTo("sub.kicad_sch"); // C++ already shows `sub`
releaseRoot();
await Promise.all([rootSwitch, subSwitch]);
// Nothing was ever bound/seeded while the editor showed a different sheet.
expect(bindings).toHaveLength(1);
expect(bindings[0]!.seed).toHaveBeenCalledTimes(1);
expect(m.active()?.sheetPath).toBe("sub.kicad_sch");
// The root room stays warm (parked) — the superseded switch is not a failure.
expect(sessions.map((s) => s.room).sort()).toEqual([
"S:P:root.kicad_sch",
"S:P:sub.kicad_sch",
]);
});
it("switchTo rejects SexprVersionError terminally — no retry, queue stays usable (C-5)", async () => {
vi.useFakeTimers();
try {

View file

@ -256,6 +256,17 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
async function doSwitch(sheetPath: string): Promise<void> {
if (activePath === sheetPath) return;
// The editor navigated AGAIN while this switch was awaiting (ysync bug 07,
// UP side): binding + adopting now would apply THIS room's doc onto the
// screen of the newer sheet — the 8/28 mega-demo corruption, where the
// root doc's items (its `(sheet …)` entries included) replaced a subsheet's
// and were then written into the subsheet's room. Bail; the queued switch
// for the newer path binds the right room. The room stays warm (parked).
const superseded = (): boolean => {
if (requestedPath === sheetPath) return false;
clog(`[sheet] switch to ${sheetPath} superseded by ${requestedPath} — not binding`);
return true;
};
// Detach the OLD binding FIRST (before any await): the editor already navigated to
// the new sheet, so the old binding's observer must stop applying remote edits onto
@ -279,19 +290,19 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
if (hadActive) opts.onActiveChange?.(null);
const room = await ensureRoom(sheetPath);
if (destroyed) return;
if (destroyed || superseded()) return;
// Gateway transport: a passively-warmed sheet holds no doc state yet —
// activate() is the real sync barrier (no-op for other providers and on
// revisits). Runs BEFORE the watch detaches so catch-up updates still
// mark `dirty` for the adopt decision below.
await room.session.provider.activate?.();
if (destroyed) return;
if (destroyed || superseded()) return;
// Activating: stop tracking parked updates and bind the (warm) doc to the editor.
room.detachWatch?.();
room.detachWatch = undefined;
const binding = bindKicadCollab(room.doc, bridge, { readOnly: opts.readOnly });
const binding = bindKicadCollab(room.doc, bridge, { readOnly: opts.readOnly, sheetPath });
room.binding = binding;
if (!room.seeded) {