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

@ -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).

View file

@ -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<void> {
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<Mod> }).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<FuzzStats> {
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<void> {
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<Mod>;
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);
});
});

View file

@ -59,6 +59,7 @@
#include <tool/coroutine.h>
#include <pcbjam_remote_lock.h>
#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<KIWAY_PLAYER*>( 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<wxString>( 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);

View file

@ -40,6 +40,7 @@
#include <project.h>
#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<KIWAY_PLAYER*>( 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<wxString>( 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);

56
wasm/bindings/open_gate.h Normal file
View file

@ -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

View file

@ -52,6 +52,7 @@
#include <nlohmann/json.hpp>
#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<KIWAY_PLAYER*>( 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<wxString>( 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);

View file

@ -17,6 +17,7 @@
#include <wx/string.h>
#include <wx/window.h>
#include <nlohmann/json.hpp>
#include "open_gate.h"
#include <eda_draw_frame.h>
#include <kiid.h>
#include <pcbjam_read_only.h>
@ -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<KIWAY_PLAYER*>( 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<wxString>( 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<std::string, json> 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);

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