diff --git a/docs/features/async/14-open-settle-gate.md b/docs/features/async/14-open-settle-gate.md new file mode 100644 index 0000000..7f61571 --- /dev/null +++ b/docs/features/async/14-open-settle-gate.md @@ -0,0 +1,116 @@ +# 14 — The open-settle gate: prod "indirect call signature mismatch" at board load + +## Symptom + +Intermittent, prod, mostly Firefox: loading an editor URL (observed on +`…/Arduino Mega 2560.kicad_pcb`) sometimes fails the boot with + +``` +Error: RuntimeError: indirect call signature mismatch +``` + +right around the `[collab] maybeStartCollab gate:` console line. Previously +suspected (wrongly) to be related to spaces in filenames. + +## Root cause + +Three stacked defects: + +1. **The "file loaded" signal was a no-op.** `openFileInTool` fires + `Module.kicadOpenFile(path)` — which runs `OpenProjectFiles` under Asyncify, + i.e. the embind call unwinds back to JS long before the load finishes — and + then polls `schematicLoaded()`: *"title is non-empty and does not contain + 'untitled'"*. But pcbnew's pre-open title is `"PCB Editor"` (a fresh frame; + `PCB_EDIT_FRAME` ctor title), and a real project's title never contains + "untitled" at any point. The poll therefore passed **on the first check**, + while the load had barely started. (The heuristic only ever waited for the + `@local` new-file flow, whose files are literally named `untitled.*`.) + +2. **`driveProjectIntoTool` ignored the result** — even an honest `"failed"` + (60 s timeout) let the boot IIFE continue. + +3. **Everything after it drives bare embind entries that walk the model.** + `maybeStartCollab` → `attachKicadCollab` → `seed()` → + `kicadCollabSnapshotItems()` (inline board walk + per-item `Format()`), then + `bindKicadPresence` (GAL `VIEW_OVERLAY` bind). These run while the + `kicadOpenFile` chain is still **parked mid-mutation of the same + BOARD/SCH_SCREEN** (progress-reporter yields, futex yields, lib bridges). A + virtual call that lands on a half-built item reads a garbage vtable slot → + `call_indirect` hits a wrong-typed table entry → Firefox reports + `indirect call signature mismatch`. The boot IIFE catches it → + `Error: RuntimeError: …` in the status overlay. + +This is the same reentrancy class as two already-fixed bugs — the wxWidgets +**dispatch interlock** (`wxwidgets/docs/wasm/dispatch-interlock.md`: no event +dispatch while another chain is parked) and drift-trio **finding #10b** +(`kicadCollabFiberBusy`: no bare-embind scratch save while a collab fiber is in +flight) — but through the one entry family neither guard covers: **web-shell +JS → embind calls during the open park**. Timing-dependent, hence "sometimes"; +slow Firefox loads widen the window enormously; filename spaces were never +involved. + +## Fix + +Three layers, mirroring the shape of the earlier interlocks: + +1. **Truthful completion signal** — `wasm/bindings/open_gate.h`: + `pcbjam_open::BusyGuard`, an RAII counter on `kicadOpenFile`'s C++ stack + frame. Under Asyncify an unwind does not run destructors and a rewind + resumes past the constructor, so the count is held across every park and + drops exactly when `OpenProjectFiles` truly returns (the same primitive as + `wxWasmDispatchGuard`). Exported as `Module.kicadOpenFileBusy()` from all + four `kicadOpenFile` definitions (pcbnew / eeschema standalone, merged + kicad_editor, pl_editor), mirroring the `kicadCollabFiberBusy` probe. + +2. **JS waits for it** — `open-flow.ts` `waitForOpenSettled()`: after invoking + the open, poll `kicadOpenFileBusy()` until clear (5 min budget — slow loads + are real; the poll is free). Escape hatch: a visible **non-progress** dialog + means the load is parked awaiting user input (file-version confirm, remap…) + — proceed rather than leave the dialog unanswerable under the boot overlay. + Feature-detected: wasm builds without the probe fall back to the legacy + title poll unchanged. + +3. **Degrade, don't die** — `driveProjectIntoTool` returns the open outcome; + on `"failed"` the shell skips the whole collab/presence/drift attach + (board stays viewable, saves still route). The attach block is additionally + wrapped so a residual trap logs `[collab] attach failed — continuing + without collab` instead of failing the boot; `SexprVersionError` ("update + required") still rethrows. + +## Layer 4 — entry guards + the regression spec + +The shell gate alone leaves the raw embind entries trappable if anything else +calls them mid-load, and is untestable end-to-end (see below). So the collab +snapshot/apply entries themselves early-return while `pcbjam_open::busy()`: +snapshots return the empty delta, applies drop. `kicadTestSetOpenPark(ms)` +(test-only, default off) makes `kicadOpenFile` Asyncify-park for a fixed time +on entry and again after `OpenProjectFiles` returns — model fully loaded, gate +still closed. + +`tests/kicad/collab-load-fuzz.spec.ts` uses that window deterministically: it +opens a ~13k-item generated board and hammers all four entries the whole time +`kicadOpenFileBusy()` is true, asserting the gate engages, mid-load snapshots +are EMPTY (an unguarded build returns the full board → deterministic red), +mid-load applies are dropped (probe segment must not move), nothing traps, and +everything works after settle. + +**Why the window must be synthetic:** the wasm port's `wxYield`/progress pump +never parks — the only natural in-load parks are thread-pool waits +(`futex_yield`/`nanosleep_yield`), which on a fast idle machine never happen +(the whole open runs synchronously and JS cannot interleave at all). That is +also why the prod trap correlates with slow machines/Firefox. A second, +`PCBJAM_FUZZ_STRESS=1`-gated test in the same spec hunts those natural parks +under spinning-worker CPU starvation; it cannot gate CI (window engagement is +scheduler-dependent) but is the honest reproducer to loop on a loaded box. + +## Residuals / notes + +- A trap escaping the open leaves the busy count stuck → the JS poll times out + (5 min) and boots without collab; same end state as before, minus the trap. +- `kicadSetReadOnly` polling during the load is unaffected (leaf flag flip, no + model walk) — it has always run during parks, like `kicadCollabFiberBusy`. +- The mid-load modal escape accepts the status-quo risk for that rare case: + parked-at-a-dialog is a stable park point, not a mid-container-append one. +- Unit coverage: `web/standalone/src/wasm/open-flow.test.ts` (settle wait, + stuck-busy failure, dialog escape incl. progress-dialog exclusion, legacy + fallback). diff --git a/tests/kicad/collab-load-fuzz.spec.ts b/tests/kicad/collab-load-fuzz.spec.ts new file mode 100644 index 0000000..527fed4 --- /dev/null +++ b/tests/kicad/collab-load-fuzz.spec.ts @@ -0,0 +1,388 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; + +/** + * Collab-entry-during-load gate test + fuzz (docs/features/async/14-open-settle-gate.md). + * + * The prod trap: `kicadOpenFile` runs `OpenProjectFiles` under Asyncify; on a + * slow machine the chain parks mid-load (thread-pool futex waits), and any bare + * embind entry that walks the model during such a park (the collab seed + * snapshot, an adopt apply) can virtual-dispatch through half-mutated state and + * trap with "indirect call signature mismatch". The fix is two-layered: the + * shell defers the attach on `kicadOpenFileBusy` (open-flow.ts), and the + * snapshot/apply entries themselves early-return while the open is in flight + * (open_gate.h guards). + * + * Natural in-load parks are scheduler-dependent — on a fast idle machine the + * whole open runs synchronously and NO window exists — so the deterministic + * test arms `kicadTestSetOpenPark`: kicadOpenFile then Asyncify-parks for a + * fixed time on entry AND after OpenProjectFiles returns (model fully loaded, + * gate still closed). Hammering the entries inside that window asserts the + * guard contract sharply: + * - `kicadOpenFileBusy()` reads true during the parks, false after; + * - mid-load snapshots return the EMPTY delta (an unguarded build would + * return the full board — deterministic red); + * - mid-load applies are DROPPED (the probe segment must not move); + * - after settle the entries work normally (guard released). + * + * The second test is the scheduler-dependent stress fuzz (spinning-worker CPU + * starvation to force real futex-wait parks, hammering throughout the load). + * It is skipped unless PCBJAM_FUZZ_STRESS=1: engagement of the window is not + * guaranteed on a fast machine, so it cannot gate CI — it exists to hunt this + * reentrancy class by hand (loop it on a loaded box). + */ + +const SEG_TARGET = "fa220000-0000-0000-0000-00000000cafe"; // apply probe +const PROBE_HOME = "10000000,10000000"; // its on-disk position (IU) + +/** Deterministic large board (~13k items) — a realistic snapshot/apply load. */ +function bigBoard(): string { + const lines: string[] = []; + lines.push("(kicad_pcb"); + lines.push("\t(version 20241229)"); + lines.push('\t(generator "pcbnew")'); + lines.push('\t(generator_version "9.0")'); + lines.push("\t(general (thickness 1.6))"); + lines.push('\t(paper "A4")'); + lines.push("\t(layers"); + lines.push('\t\t(0 "F.Cu" signal)'); + lines.push('\t\t(2 "B.Cu" signal)'); + lines.push('\t\t(37 "F.SilkS" user)'); + lines.push('\t\t(25 "Edge.Cuts" user)'); + lines.push("\t)"); + lines.push("\t(setup)"); + lines.push('\t(net 0 "")'); + const NETS = 40; + for (let i = 1; i <= NETS; i++) lines.push(`\t(net ${i} "N${i}")`); + const uuid = (n: number) => `fa2${(n + 1).toString(16).padStart(5, "0")}-0000-0000-0000-000000000000`; + let n = 0; + for (let i = 0; i < 12000; i++) { + const x = 20 + (i % 120) * 1.5; + const y = 20 + Math.floor(i / 120) * 1; + lines.push( + `\t(segment (start ${x} ${y}) (end ${x + 1.2} ${y}) (width 0.2) (layer "F.Cu") (net ${ + (i % NETS) + 1 + }) (uuid "${uuid(n++)}"))`, + ); + } + for (let i = 0; i < 800; i++) { + const x = 21 + (i % 80) * 2; + const y = 21 + Math.floor(i / 80) * 10; + lines.push( + `\t(via (at ${x} ${y}) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net ${ + (i % NETS) + 1 + }) (uuid "${uuid(n++)}"))`, + ); + } + // Footprints with text children (the field/text walk of the snapshot). + for (let i = 0; i < 200; i++) { + const x = 30 + (i % 20) * 8; + const y = 140 + Math.floor(i / 20) * 6; + lines.push(`\t(footprint "TestLib:R" +\t\t(layer "F.Cu") +\t\t(uuid "${uuid(n++)}") +\t\t(at ${x} ${y}) +\t\t(attr smd) +\t\t(property "Reference" "R${i}" +\t\t\t(at 0 -2 0) +\t\t\t(layer "F.SilkS") +\t\t\t(uuid "${uuid(n++)}") +\t\t\t(effects (font (size 1 1) (thickness 0.15))) +\t\t) +\t\t(property "Value" "R" +\t\t\t(at 0 2 0) +\t\t\t(layer "F.Fab") +\t\t\t(uuid "${uuid(n++)}") +\t\t\t(effects (font (size 1 1) (thickness 0.15))) +\t\t) +\t)`); + } + lines.push( + `\t(segment (start 10 10) (end 15 10) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG_TARGET}"))`, + ); + lines.push(")"); + return lines.join("\n"); +} + +type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; +type Mod = { + kicadOpenFile(p: string): unknown; + kicadOpenFileBusy(): boolean; + kicadTestSetOpenPark(ms: number): void; + kicadCollabSnapshot(): string; + kicadCollabSnapshotItems(): string; + kicadCollabApply(j: string): unknown; + kicadCollabApplyItems(j: string): unknown; + kicadCollabGetPos(id: string): string; +}; + +interface FuzzStats { + busySamples: number; + iterations: number; + errors: string[]; + /** Largest `added` length any mid-load snapshot returned (guard ⇒ 0). */ + maxBusySnapshotItems: number; + settled: boolean; + loadMs: number; +} + +function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { + return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); +} + +async function bootHarness(page: Page): Promise { + await page.goto("/kicad/pcbnew-collab.html"); + await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 }); + await page.waitForFunction( + () => { + const m = (window as unknown as { Module?: Partial }).Module; + return ( + typeof m?.kicadOpenFile === "function" && + typeof m?.kicadCollabSnapshotItems === "function" && + typeof m?.kicadCollabApply === "function" + ); + }, + null, + { timeout: 90000 }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: 90000 }, + ); +} + +/** + * In-page: write the board, open it, and hammer every collab entry for as long + * as `kicadOpenFileBusy()` reports the open in flight. Mid-load applies try to + * MOVE the probe segment — the guard must drop them (asserted by the caller via + * the probe's position). `starve` additionally saturates every core with + * spinning workers so natural thread-pool waits park too (stress mode). + */ +async function openAndHammer( + page: Page, + opts: { content: string; probeUuid: string; parkMs: number; starve: boolean }, +): Promise { + return page.evaluate(async ({ content, probeUuid, parkMs, starve }) => { + const w = window as unknown as { + FS: FS; + Module: Mod & { kicadTestSetOpenPark?: (ms: number) => void }; + }; + const dir = "/home/kicad/documents"; + try { + w.FS.mkdirTree(dir); + } catch { + /* exists */ + } + const path = `${dir}/fuzz.kicad_pcb`; + w.FS.writeFile(path, content); + if (parkMs > 0) w.Module.kicadTestSetOpenPark!(parkMs); + + // Mid-load applies try to move the probe AWAY from home; both wire families. + const wireDelta = JSON.stringify({ + added: [], + changed: [ + { + sexpr: `(segment (start 55 55) (end 60 55) (width 0.2) (layer "F.Cu") (net 0) (uuid "${probeUuid}"))`, + parent: null, + }, + ], + removed: [], + }); + const scalarDelta = JSON.stringify({ + added: [], + changed: [ + { + id: probeUuid, + type: "PCB_TRACK", + sx: 55_000_000, + sy: 55_000_000, + ex: 60_000_000, + ey: 55_000_000, + width: 200000, + }, + ], + removed: [], + }); + + const burners: Worker[] = []; + let burnUrl = ""; + if (starve) { + burnUrl = URL.createObjectURL( + new Blob(["for(;;){let x=0;for(let i=0;i<1e7;i++)x+=i;}"], { + type: "text/javascript", + }), + ); + const cores = navigator.hardwareConcurrency || 8; + for (let i = 0; i < cores * 2; i++) burners.push(new Worker(burnUrl)); + } + + w.Module.kicadOpenFile(path); + + const t0 = performance.now(); + let busySamples = 0; + let iterations = 0; + let maxBusySnapshotItems = 0; + const errors: string[] = []; + // Every Asyncify park of the open chain hands the event loop to this + // timer — exactly how the prod shell's collab attach interleaved. + while (performance.now() - t0 < 120000) { + if (!w.Module.kicadOpenFileBusy()) break; + busySamples++; + iterations++; + for (const [name, fn] of [ + ["snapshotItems", () => w.Module.kicadCollabSnapshotItems()], + ["snapshot", () => w.Module.kicadCollabSnapshot()], + ["applyItems", () => w.Module.kicadCollabApplyItems(wireDelta)], + ["apply", () => w.Module.kicadCollabApply(scalarDelta)], + ] as const) { + try { + const out = fn(); + if (typeof out === "string" && name.startsWith("snapshot")) { + const added = (JSON.parse(out) as { added: unknown[] }).added.length; + if (added > maxBusySnapshotItems) maxBusySnapshotItems = added; + } + } catch (e) { + errors.push(`${name} during load: ${String(e)}`); + } + } + await new Promise((r) => setTimeout(r, 10)); + } + for (const b of burners) b.terminate(); + if (burnUrl) URL.revokeObjectURL(burnUrl); + if (parkMs > 0) w.Module.kicadTestSetOpenPark!(0); + return { + busySamples, + iterations, + errors, + maxBusySnapshotItems, + settled: !w.Module.kicadOpenFileBusy(), + loadMs: Math.round(performance.now() - t0), + }; + }, opts); +} + +/** Post-settle asserts shared by both tests: guard dropped applies + released. */ +async function assertSettledContract(page: Page, stats: FuzzStats): Promise { + expect(stats.settled, "kicadOpenFileBusy cleared after the load").toBe(true); + expect(stats.errors, "no traps while hammering entries mid-load").toEqual([]); + // Guard held: no mid-load snapshot ever saw the model. + expect(stats.maxBusySnapshotItems, "mid-load snapshots returned the empty delta").toBe(0); + await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(/fuzz/i); + + // Guard dropped the mid-load applies: the probe segment never moved. + await expect + .poll( + () => + page.evaluate((id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), SEG_TARGET), + { timeout: 10000, intervals: [200] }, + ) + .toBe(PROBE_HOME); + + // Guard released: the snapshot now walks the real, fully-loaded board… + const itemCount = await page.evaluate( + () => JSON.parse((window.Module as unknown as Mod).kicadCollabSnapshotItems()).added.length, + ); + expect(itemCount, "post-load snapshot sees the board").toBeGreaterThan(12000); + + // …and a real apply lands. + await page.evaluate( + (id) => + (window.Module as unknown as Mod).kicadCollabApply( + JSON.stringify({ + added: [], + changed: [ + { + id, + type: "PCB_TRACK", + sx: 12_000_000, + sy: 34_000_000, + ex: 17_000_000, + ey: 34_000_000, + width: 200000, + }, + ], + removed: [], + }), + ), + SEG_TARGET, + ); + await expect + .poll( + () => + page.evaluate((id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), SEG_TARGET), + { timeout: 10000, intervals: [200] }, + ) + .toBe("12000000,34000000"); +} + +test.describe("collab entries during a parked board load (open_gate)", () => { + test("deterministic park window: gate engages, entries no-op, gate releases", async ({ + page, + testLogger, + }) => { + test.setTimeout(180000); + await bootHarness(page); + + const hooks = await page.evaluate(() => { + const m = window.Module as unknown as Partial; + return { + busy: typeof m.kicadOpenFileBusy === "function", + park: typeof m.kicadTestSetOpenPark === "function", + }; + }); + expect(hooks.busy, "kicadOpenFileBusy export present (open_gate.h)").toBe(true); + expect(hooks.park, "kicadTestSetOpenPark export present (open_gate.h)").toBe(true); + + const stats = await openAndHammer(page, { + content: bigBoard(), + probeUuid: SEG_TARGET, + parkMs: 1500, // entry + post-load parks — a guaranteed hammer window + starve: false, + }); + console.log( + `[TEST] gate: ${stats.iterations} iterations over ${stats.loadMs}ms, ` + + `${stats.busySamples} busy samples, maxBusySnap=${stats.maxBusySnapshotItems}, ` + + `${stats.errors.length} errors`, + ); + + // The armed parks make the window unconditional on any machine. + expect(stats.busySamples, "the busy window was observed").toBeGreaterThan(0); + await assertSettledContract(page, stats); + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + }); + + // Scheduler-dependent stress hunt — NOT a CI gate: window engagement is not + // guaranteed on a fast idle machine (see header). Loop it manually: + // PCBJAM_FUZZ_STRESS=1 npx playwright test --project=kicad-firefox kicad/collab-load-fuzz.spec.ts + test("stress: hammer through natural thread-wait parks under CPU starvation", async ({ + page, + testLogger, + }) => { + test.skip(!process.env.PCBJAM_FUZZ_STRESS, "manual stress hunt (PCBJAM_FUZZ_STRESS=1)"); + test.setTimeout(300000); + await bootHarness(page); + + const stats = await openAndHammer(page, { + content: bigBoard(), + probeUuid: SEG_TARGET, + parkMs: 0, // natural parks only + starve: true, + }); + console.log( + `[TEST] stress: ${stats.iterations} iterations over ${stats.loadMs}ms, ` + + `${stats.busySamples} busy samples, maxBusySnap=${stats.maxBusySnapshotItems}, ` + + `${stats.errors.length} errors`, + ); + + // No busySamples assert: with no natural park the window legitimately + // never opens. Everything that DID interleave must have been safe. + await assertSettledContract(page, stats); + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + }); +}); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 0f65b3c..67b616b 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -59,6 +59,7 @@ #include #include #include "collab_common.h" +#include "open_gate.h" #include "collab_presence_core.h" #include "collab_presence_style.h" #include "pcbjam_theme.h" @@ -82,6 +83,12 @@ using json = nlohmann::json; #ifndef KICAD_MERGED_EMBIND bool kicadOpenFile( std::string path ) { + // Held across every Asyncify park of the load; see open_gate.h. + pcbjam_open::BusyGuard busy; + + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + KIWAY_PLAYER* frame = wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; @@ -91,8 +98,28 @@ bool kicadOpenFile( std::string path ) if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) blocking->Close( true ); - return frame->OpenProjectFiles( + bool ok = frame->OpenProjectFiles( std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); + + // Test-only post-load park (open_gate.h): model fully loaded, gate still + // closed — the deterministic window the collab-load-fuzz spec hammers. + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + + return ok; +} + +// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the +// collab/presence attach until the open chain has truly completed. +bool kicadOpenFileBusy() +{ + return pcbjam_open::busy(); +} + +// Test-only (collab-load-fuzz): arm the deterministic open parks. +void kicadTestSetOpenPark( int aMs ) +{ + pcbjam_open::testParkMs() = aMs; } // Read-only viewer lock (read-only-viewer): flips the process-global @@ -1119,6 +1146,13 @@ void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScree // exact context real UI edits run in. So defer the whole mutation there. void schCollabApply( std::string aJson ) { + // Open-in-flight guard (open_gate.h): never touch the model while a + // kicadOpenFile Asyncify chain is parked mid-load — commits/virtuals on a + // half-built schematic mis-dispatch ("indirect call signature mismatch"). + // Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts. + if( pcbjam_open::busy() ) + return; + json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( delta.is_discarded() ) @@ -1142,6 +1176,10 @@ void schCollabApply( std::string aJson ) // registers the change listener on first call. std::string schCollabSnapshot() { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply + return json{ { "added", json::array() }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); + ensureBridge(); json added = snapshotItems( schFrame() ); @@ -1158,6 +1196,9 @@ std::string schCollabSnapshot() // (LoadContent + SCH_COMMIT must run where native edits run). void schCollabApplyItems( std::string aJson ) { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply + return; + json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( wire.is_discarded() ) @@ -1177,6 +1218,10 @@ void schCollabApplyItems( std::string aJson ) // Registers the listener + rebaselines exactly like kicadCollabSnapshot. std::string schCollabSnapshotItems() { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply + return json{ { "added", json::array() }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); + SCH_EDIT_FRAME* fr = schFrame(); json added = json::array(); @@ -2016,6 +2061,8 @@ EMSCRIPTEN_BINDINGS(eeschema) { // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFileBusy", &kicadOpenFileBusy); + function("kicadTestSetOpenPark", &kicadTestSetOpenPark); function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index f9abec0..312a7f8 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -40,6 +40,7 @@ #include #include "pcbjam_libs_reload.h" +#include "open_gate.h" using namespace emscripten; @@ -135,6 +136,12 @@ bool schCollabTestClearSelection(); // standalone bundles compile from their own binding TU. static bool kicadOpenFile( std::string path ) { + // Held across every Asyncify park of the load; see open_gate.h. + pcbjam_open::BusyGuard busy; + + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + KIWAY_PLAYER* frame = wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; @@ -144,8 +151,28 @@ static bool kicadOpenFile( std::string path ) if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) blocking->Close( true ); - return frame->OpenProjectFiles( + bool ok = frame->OpenProjectFiles( std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); + + // Test-only post-load park (open_gate.h): model fully loaded, gate still + // closed — the deterministic window the collab-load-fuzz spec hammers. + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + + return ok; +} + +// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the +// collab/presence attach until the open chain has truly completed. +static bool kicadOpenFileBusy() +{ + return pcbjam_open::busy(); +} + +// Test-only (collab-load-fuzz): arm the deterministic open parks. +static void kicadTestSetOpenPark( int aMs ) +{ + pcbjam_open::testParkMs() = aMs; } @@ -500,6 +527,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFileBusy", &kicadOpenFileBusy); + function("kicadTestSetOpenPark", &kicadTestSetOpenPark); // Canvas-only mobile mode (features/mobile). function("kicadSetChrome", &kicadSetChrome); diff --git a/wasm/bindings/open_gate.h b/wasm/bindings/open_gate.h new file mode 100644 index 0000000..24f6444 --- /dev/null +++ b/wasm/bindings/open_gate.h @@ -0,0 +1,56 @@ +/* + * Truthful "kicadOpenFile in flight" signal for the web shell. + * + * kicadOpenFile runs OpenProjectFiles under Asyncify: the embind call unwinds + * back to JS long before the load finishes, and the chain stays parked (and + * resumes, and parks again) across the whole multi-second load. Any bare + * embind entry that walks the model while that chain is parked mid-mutation + * (collab snapshot, presence bind) can virtual-dispatch through a half-built + * item and trap ("indirect call signature mismatch" — same class as the wx + * dispatch interlock and the drift-trio #10b fiber-busy probe, but through a + * JS entry neither of those covers). + * + * The guard is RAII on the open's C++ stack frame: an Asyncify unwind does not + * run destructors and a rewind resumes past the constructor, so the count is + * held for the park's entire lifetime and drops exactly when OpenProjectFiles + * truly returns (the same primitive as wxWasmDispatchGuard). A trap escaping + * the open leaves the count stuck — the JS poll times out and degrades. + */ +#pragma once + +namespace pcbjam_open +{ + +inline int& busyCount() +{ + static int s_count = 0; + return s_count; +} + +struct BusyGuard +{ + BusyGuard() { ++busyCount(); } + ~BusyGuard() { --busyCount(); } +}; + +/** JS-pollable: is a kicadOpenFile chain still in flight (possibly parked)? */ +inline bool busy() +{ + return busyCount() > 0; +} + +/** + * Test-only deterministic park (tests/kicad/collab-load-fuzz.spec.ts): with a + * nonzero value, kicadOpenFile Asyncify-parks for this many ms on entry and + * again after OpenProjectFiles returns — busy guard held, model fully loaded. + * Natural in-load parks (thread-pool futex waits) are scheduler-dependent and + * never happen on a fast idle machine, so the guard would be untestable in CI + * without this window. 0 (the default) is a no-op in production. + */ +inline int& testParkMs() +{ + static int s_ms = 0; + return s_ms; +} + +} // namespace pcbjam_open diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 6d48879..81601d9 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -52,6 +52,7 @@ #include #include "collab_common.h" #include "collab_presence_core.h" +#include "open_gate.h" #include "collab_presence_style.h" #include "pcbjam_theme.h" #include "pcbjam_libs_reload.h" @@ -84,6 +85,12 @@ using json = nlohmann::json; #ifndef KICAD_MERGED_EMBIND bool kicadOpenFile( std::string path ) { + // Held across every Asyncify park of the load; see open_gate.h. + pcbjam_open::BusyGuard busy; + + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + KIWAY_PLAYER* frame = wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; @@ -93,8 +100,28 @@ bool kicadOpenFile( std::string path ) if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) blocking->Close( true ); - return frame->OpenProjectFiles( + bool ok = frame->OpenProjectFiles( std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); + + // Test-only post-load park (open_gate.h): model fully loaded, gate still + // closed — the deterministic window the collab-load-fuzz spec hammers. + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + + return ok; +} + +// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the +// collab/presence attach until the open chain has truly completed. +bool kicadOpenFileBusy() +{ + return pcbjam_open::busy(); +} + +// Test-only (collab-load-fuzz): arm the deterministic open parks. +void kicadTestSetOpenPark( int aMs ) +{ + pcbjam_open::testParkMs() = aMs; } // Read-only viewer lock (read-only-viewer): flips the process-global @@ -1382,6 +1409,13 @@ void schedulePresenceSelCheck() // mis-dispatch and trap inside KiCad core, on it they dispatch correctly (eeschema 0007). void pcbCollabApply( std::string aJson ) { + // Open-in-flight guard (open_gate.h): never touch the model while a + // kicadOpenFile Asyncify chain is parked mid-load — commits/virtuals on a + // half-built board mis-dispatch ("indirect call signature mismatch"). + // Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts. + if( pcbjam_open::busy() ) + return; + json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( delta.is_discarded() ) @@ -1400,6 +1434,9 @@ void pcbCollabApply( std::string aJson ) // (the blob parse + commit must run where native edits run — see above). void pcbCollabApplyItems( std::string aJson ) { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see pcbCollabApply + return; + json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( wire.is_discarded() ) @@ -1418,6 +1455,10 @@ void pcbCollabApplyItems( std::string aJson ) // change listener on first call. std::string pcbCollabSnapshot() { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see pcbCollabApply + return json{ { "added", json::array() }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); + BOARD* board = ensureBridge(); json added = json::array(); @@ -1441,6 +1482,10 @@ std::string pcbCollabSnapshot() // listener + rebaselines exactly like kicadCollabSnapshot. std::string pcbCollabSnapshotItems() { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see pcbCollabApply + return json{ { "added", json::array() }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); + BOARD* board = ensureBridge(); json added = json::array(); @@ -2339,6 +2384,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFileBusy", &kicadOpenFileBusy); + function("kicadTestSetOpenPark", &kicadTestSetOpenPark); function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); diff --git a/wasm/bindings/pl_editor_embind.cpp b/wasm/bindings/pl_editor_embind.cpp index 4874916..ec6f8f6 100644 --- a/wasm/bindings/pl_editor_embind.cpp +++ b/wasm/bindings/pl_editor_embind.cpp @@ -17,6 +17,7 @@ #include #include #include +#include "open_gate.h" #include #include #include @@ -37,6 +38,12 @@ using json = nlohmann::json; // File→Open. bool kicadOpenFile( std::string path ) { + // Held across every Asyncify park of the load; see open_gate.h. + pcbjam_open::BusyGuard busy; + + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + KIWAY_PLAYER* frame = wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; @@ -46,8 +53,28 @@ bool kicadOpenFile( std::string path ) if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) blocking->Close( true ); - return frame->OpenProjectFiles( + bool ok = frame->OpenProjectFiles( std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); + + // Test-only post-load park (open_gate.h): model fully loaded, gate still + // closed — the deterministic window the collab-load-fuzz spec hammers. + if( pcbjam_open::testParkMs() > 0 ) + emscripten_sleep( pcbjam_open::testParkMs() ); + + return ok; +} + +// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the +// collab attach until the open chain has truly completed. +bool kicadOpenFileBusy() +{ + return pcbjam_open::busy(); +} + +// Test-only (collab-load-fuzz): arm the deterministic open parks. +void kicadTestSetOpenPark( int aMs ) +{ + pcbjam_open::testParkMs() = aMs; } // Read-only viewer lock (read-only-viewer): flips the process-global @@ -285,6 +312,11 @@ void addBlob( DS_DATA_MODEL& aModel, const json& j ) // resulting model mutations are not re-emitted as local changes. void kicadCollabApply( std::string aJson ) { + // Open-in-flight guard (open_gate.h): never touch the model while a + // kicadOpenFile Asyncify chain is parked mid-load; see kicadOpenFileBusy. + if( pcbjam_open::busy() ) + return; + json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( delta.is_discarded() ) @@ -431,6 +463,10 @@ extern "C" void kicadCollabOnSave( const char* aPath ) // join and to (re)baseline the differ. Idempotent. std::string kicadCollabSnapshot() { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see kicadCollabApply + return json{ { "added", json::array() }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); + std::map cur = snapshotMap(); json added = json::array(); @@ -455,6 +491,10 @@ std::string kicadCollabSnapshot() // differ exactly like kicadCollabSnapshot, so a v2 consumer gets no echo either. std::string kicadCollabSnapshotItems() { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see kicadCollabApply + return json{ { "added", json::array() }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); + DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance(); json added = json::array(); @@ -474,6 +514,9 @@ std::string kicadCollabSnapshotItems() // drop any pre-existing item that shares an appended uuid (replace-by-uuid). void kicadCollabApplyItems( std::string aJson ) { + if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see kicadCollabApply + return; + json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( wire.is_discarded() ) @@ -573,6 +616,8 @@ std::string kicadCollabTestAddText( std::string aText, double aX, double aY ) EMSCRIPTEN_BINDINGS(pl_editor) { // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFileBusy", &kicadOpenFileBusy); + function("kicadTestSetOpenPark", &kicadTestSetOpenPark); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet); diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 63cb697..54ec629 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -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. diff --git a/web/standalone/src/wasm/kicad-runner.ts b/web/standalone/src/wasm/kicad-runner.ts index 93d8871..31ccc22 100644 --- a/web/standalone/src/wasm/kicad-runner.ts +++ b/web/standalone/src/wasm/kicad-runner.ts @@ -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 { +): 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; } diff --git a/web/standalone/src/wasm/open-flow.test.ts b/web/standalone/src/wasm/open-flow.test.ts new file mode 100644 index 0000000..243c33a --- /dev/null +++ b/web/standalone/src/wasm/open-flow.test.ts @@ -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 { + 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"); + }); +}); diff --git a/web/standalone/src/wasm/open-flow.ts b/web/standalone/src/wasm/open-flow.ts index c38e06c..18bb43e 100644 --- a/web/standalone/src/wasm/open-flow.ts +++ b/web/standalone/src/wasm/open-flow.ts @@ -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 { + 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