fix(load): open-settle gate — kicadOpenFileBusy probe + collab entry guards for the parked-open embind trap (indirect call signature mismatch) + deterministic collab-load-fuzz e2e

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
Gergő Törcsvári 2026-07-30 14:17:48 +02:00
commit a26ef4ebeb
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 964 additions and 17 deletions

View file

@ -1514,7 +1514,7 @@ export function WasmTool({
const docResult = await docSessionReady;
if ("error" in docResult) throw docResult.error;
const { session, targetBytes } = docResult;
await driveProjectIntoTool(win, {
const openResult = await driveProjectIntoTool(win, {
tool,
slug,
files,
@ -1556,6 +1556,12 @@ export function WasmTool({
);
}
}
// Everything below drives BARE embind entries that walk the loaded
// model (collab snapshot/adopt, presence bind, drift). Deferred until
// the open chain settled (openResult) — calling them while the
// kicadOpenFile Asyncify chain is still parked mid-load walks a
// half-built model and traps ("indirect call signature mismatch").
const attachCollabAndPresence = async () => {
// Drift detection: while a sheet is collaboratively edited, periodically (every N
// edits + at session end) compare the WASM serialization to the Y.Doc and report
// divergence. Gated on a real collab session; re-targeted per active sheet below.
@ -1662,6 +1668,23 @@ export function WasmTool({
});
}
}
};
if (openResult === "failed") {
// The load never settled (or a legacy-wasm open timed out): entering
// the wasm now would race the parked open chain. Boot on without
// collab/presence — the board stays viewable, saves still route.
append("[collab] file open never settled — collab/presence disabled for this session");
} else {
try {
await attachCollabAndPresence();
} catch (err) {
// Version-skew refusal must still surface as the boot error.
if ((err as { name?: string } | undefined)?.name === "SexprVersionError") throw err;
// Degrade, don't die: a residual wasm trap here (reentrancy during
// some other parked chain) used to fail the whole boot.
append(`[collab] attach failed — continuing without collab: ${String(err)}`);
}
}
// Lib editors: the enumerate gate holds their whole-set hydrate until
// the presync settles — wait for it here too, so the boot overlay (with
// its ticking lib line) stays up instead of revealing an empty tree.

View file

@ -141,11 +141,17 @@ function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void {
* Drive a project into an already-booting tool runtime (booted into `win` by
* bootKicadTool the top-level window). Waits for the Emscripten FS, syncs the
* project tree into MEMFS, then auto-opens the target file.
*
* Returns the open outcome: "failed" means the load never settled the caller
* must NOT drive further bare embind entries that walk the model (collab
* snapshot, presence bind); they'd race the still-parked open chain and can
* trap ("indirect call signature mismatch"). "none" = no open was attempted
* (fileless tool / no target).
*/
export async function driveProjectIntoTool(
win: ToolWindow,
opts: DriveOptions,
): Promise<void> {
): Promise<"programmatic" | "ui" | "failed" | "none"> {
const { log, onStatus } = opts;
onStatus("Waiting for runtime…");
@ -159,11 +165,13 @@ export async function driveProjectIntoTool(
await syncProjectToMemfs(win, opts);
synthesizeProjectFile(win, opts);
let result: "programmatic" | "ui" | "failed" | "none" = "none";
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
onStatus("Opening file…");
const abs = memfsFilePath(opts.slug, opts.targetPath);
const result = await openFileInTool(win, abs, { log });
result = await openFileInTool(win, abs, { log });
log(`[open] result: ${result}`);
}
onStatus("");
return result;
}

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { openFileInTool } from "./open-flow";
/**
* The programmatic-open settle gate (open_gate.h / kicadOpenFileBusy): the
* shell must not report the open finished and so must not go on to drive
* bare embind entries (collab snapshot, presence bind) while the
* kicadOpenFile Asyncify chain is still parked mid-load. Regression tests for
* the prod "indirect call signature mismatch" trap at board load.
*/
function makeElement(partial: Partial<WxElementInfo>): WxElementInfo {
return {
id: "e1",
typeName: "wxFrame",
name: "MainFrame",
label: "",
visible: true,
enabled: true,
screenX: 0,
screenY: 0,
centerX: 0,
centerY: 0,
width: 100,
height: 100,
...partial,
};
}
function makeWin(opts: {
busy?: () => boolean;
elements?: () => WxElementInfo[];
title?: () => string;
}) {
const opened: string[] = [];
const elements =
opts.elements ?? (() => [makeElement({ typeName: "PCB_EDIT_FRAME" })]);
const win = {
document: {
get title() {
return opts.title ? opts.title() : "PCB Editor";
},
},
Module: {
kicadOpenFile: (p: string) => {
opened.push(p);
return false; // asyncify placeholder return — callers must ignore it
},
...(opts.busy ? { kicadOpenFileBusy: opts.busy } : {}),
},
wxElementRegistry: {
findAll: (filter?: { visible?: boolean }) =>
elements().filter((e) => (filter?.visible === undefined ? true : e.visible)),
findByLabel: () => [],
findRenderedByLabel: () => [],
},
};
return { win: win as unknown as ToolWindow, opened };
}
const log = () => {};
describe("openFileInTool settle gate", () => {
it("waits for kicadOpenFileBusy to clear before reporting success", async () => {
let busy = true;
setTimeout(() => (busy = false), 350);
const { win, opened } = makeWin({ busy: () => busy });
const result = await openFileInTool(win, "/p/board.kicad_pcb", { log });
expect(result).toBe("programmatic");
expect(opened).toEqual(["/p/board.kicad_pcb"]);
expect(busy).toBe(false); // returned only after the chain settled
});
it("returns failed when the open chain never settles", async () => {
const { win } = makeWin({ busy: () => true });
const result = await openFileInTool(win, "/p/board.kicad_pcb", {
log,
settleTimeoutMs: 300,
});
expect(result).toBe("failed");
});
it("proceeds when a modal input dialog is up (must stay answerable)", async () => {
const elements = [makeElement({ typeName: "PCB_EDIT_FRAME" })];
setTimeout(
() => elements.push(makeElement({ id: "d1", typeName: "wxRichMessageDialog" })),
250,
);
const { win } = makeWin({ busy: () => true, elements: () => elements });
const result = await openFileInTool(win, "/p/board.kicad_pcb", {
log,
settleTimeoutMs: 5000,
});
expect(result).toBe("programmatic");
});
it("does NOT treat the load's own progress dialog as an input dialog", async () => {
const elements = [
makeElement({ typeName: "PCB_EDIT_FRAME" }),
makeElement({ id: "d1", typeName: "wxGenericProgressDialog" }),
];
const { win } = makeWin({ busy: () => true, elements: () => elements });
const result = await openFileInTool(win, "/p/board.kicad_pcb", {
log,
settleTimeoutMs: 300,
});
expect(result).toBe("failed"); // progress dialog must not open the gate
});
it("falls back to the legacy title heuristic on wasm without the probe", async () => {
let title = "untitled [Unsaved] — Schematic Editor";
setTimeout(() => (title = "board — Schematic Editor"), 250);
const { win } = makeWin({ title: () => title });
const result = await openFileInTool(win, "/p/main.kicad_sch", { log });
expect(result).toBe("programmatic");
});
});

View file

@ -15,6 +15,8 @@
export interface OpenFlowOptions {
log: (msg: string) => void;
timeoutMs?: number;
/** Override the load-settle budget (kicadOpenFileBusy poll) — tests only. */
settleTimeoutMs?: number;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@ -111,6 +113,79 @@ function schematicLoaded(win: ToolWindow): boolean {
return title.length > 0 && !/untitled/i.test(title);
}
/**
* How long a load may stay in flight before we give up waiting and boot on
* without collab (slow Firefox + big board loads run minutes, and the poll is
* free see waitForOpenSettled).
*/
const OPEN_SETTLE_TIMEOUT_MS = 300_000;
/**
* A modal dialog other than the load's own progress dialog is up the open
* chain is parked waiting for USER input (file-version confirm, remap). We
* must not keep the shell blocked (the boot overlay would sit on top of the
* dialog, unanswerable), so the settle wait treats this as "proceed".
*/
function inputDialogVisible(win: ToolWindow): boolean {
return visible(win, {}).some(
(e) => /Dialog/.test(e.typeName) && !/Progress/i.test(e.typeName),
);
}
/**
* Wait until the kicadOpenFile Asyncify chain has TRULY completed.
*
* kicadOpenFile suspends and unwinds back to JS long before the load finishes;
* for the whole load the chain stays parked mid-mutation of the board/schematic.
* Any bare embind entry that walks the model during such a park (collab
* snapshot, presence bind) can virtual-dispatch through a half-built item and
* trap with "indirect call signature mismatch" the same reentrancy class the
* wx dispatch interlock guards, but through a JS entry it cannot see. The old
* readiness signal (the "untitled" title heuristic below) passes IMMEDIATELY
* for any real project (the pre-open title is just "PCB Editor"), so it never
* actually gated anything.
*
* The truthful signal is the wasm's kicadOpenFileBusy probe (open_gate.h): an
* RAII counter on the open's C++ stack, held across every park, dropped when
* OpenProjectFiles really returns. Feature-detected wasm builds predating it
* fall back to the legacy title poll. Returns false when the load never
* settled (caller reports "failed"; the shell then skips the wasm-entering
* collab/presence attach instead of trapping).
*/
async function waitForOpenSettled(
win: ToolWindow,
log: (m: string) => void,
legacyTimeoutMs: number,
settleTimeoutMs = OPEN_SETTLE_TIMEOUT_MS,
): Promise<boolean> {
const mod = win.Module as { kicadOpenFileBusy?: () => boolean } | undefined;
const busyFn = mod?.kicadOpenFileBusy;
if (typeof busyFn === "function") {
const settled = await waitFor(
() => !busyFn.call(mod) || inputDialogVisible(win),
settleTimeoutMs,
);
if (!settled) {
log("[open] load chain never settled (kicadOpenFileBusy stuck) — giving up");
return false;
}
if (busyFn.call(mod)) {
log("[open] modal dialog during load — proceeding so it stays answerable");
} else {
log("[open] load chain settled (kicadOpenFileBusy cleared)");
}
return true;
}
// Legacy wasm without the probe: the old title heuristic.
const loaded = await waitFor(() => schematicLoaded(win), legacyTimeoutMs);
if (!loaded) {
log("[open] kicadOpenFile did not load the schematic within timeout");
return false;
}
log(`[open] schematic loaded: ${win.document.title}`);
return true;
}
export async function openFileInTool(
win: ToolWindow,
absPath: string,
@ -141,18 +216,14 @@ export async function openFileInTool(
// Strategy 1: programmatic hook (preferred — deterministic, no UI automation).
// Because the call is Asyncify-async we can't trust its return value; instead
// we invoke it and poll the frame title until the schematic loads. We must NOT
// fall back to UI automation while the hook is in flight — synthesizing input
// would re-enter the suspended Asyncify call and corrupt it.
// we invoke it and wait for the open chain to settle (kicadOpenFileBusy — see
// waitForOpenSettled). We must NOT fall back to UI automation while the hook
// is in flight — synthesizing input would re-enter the suspended Asyncify
// call and corrupt it.
if (hasProgrammaticHook(win)) {
invokeProgrammaticOpen(win, absPath, log);
const loaded = await waitFor(() => schematicLoaded(win), timeoutMs);
if (loaded) {
log(`[open] schematic loaded: ${win.document.title}`);
return "programmatic";
}
log("[open] kicadOpenFile did not load the schematic within timeout");
return "failed";
const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs);
return settled ? "programmatic" : "failed";
}
// Strategy 2: UI automation fallback (EXPERIMENTAL, fragile). Only when the