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:
parent
bd1fbdce85
commit
a26ef4ebeb
11 changed files with 964 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
117
web/standalone/src/wasm/open-flow.test.ts
Normal file
117
web/standalone/src/wasm/open-flow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue