fix(load): open-settle gate — kicadOpenFileBusy probe + collab entry guards for the parked-open embind trap (indirect call signature mismatch) + deterministic collab-load-fuzz e2e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
parent
bd1fbdce85
commit
a26ef4ebeb
11 changed files with 964 additions and 17 deletions
116
docs/features/async/14-open-settle-gate.md
Normal file
116
docs/features/async/14-open-settle-gate.md
Normal 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).
|
||||
388
tests/kicad/collab-load-fuzz.spec.ts
Normal file
388
tests/kicad/collab-load-fuzz.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
56
wasm/bindings/open_gate.h
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1514,7 +1514,7 @@ export function WasmTool({
|
|||
const docResult = await docSessionReady;
|
||||
if ("error" in docResult) throw docResult.error;
|
||||
const { session, targetBytes } = docResult;
|
||||
await driveProjectIntoTool(win, {
|
||||
const openResult = await driveProjectIntoTool(win, {
|
||||
tool,
|
||||
slug,
|
||||
files,
|
||||
|
|
@ -1556,6 +1556,12 @@ export function WasmTool({
|
|||
);
|
||||
}
|
||||
}
|
||||
// Everything below drives BARE embind entries that walk the loaded
|
||||
// model (collab snapshot/adopt, presence bind, drift). Deferred until
|
||||
// the open chain settled (openResult) — calling them while the
|
||||
// kicadOpenFile Asyncify chain is still parked mid-load walks a
|
||||
// half-built model and traps ("indirect call signature mismatch").
|
||||
const attachCollabAndPresence = async () => {
|
||||
// Drift detection: while a sheet is collaboratively edited, periodically (every N
|
||||
// edits + at session end) compare the WASM serialization to the Y.Doc and report
|
||||
// divergence. Gated on a real collab session; re-targeted per active sheet below.
|
||||
|
|
@ -1662,6 +1668,23 @@ export function WasmTool({
|
|||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
if (openResult === "failed") {
|
||||
// The load never settled (or a legacy-wasm open timed out): entering
|
||||
// the wasm now would race the parked open chain. Boot on without
|
||||
// collab/presence — the board stays viewable, saves still route.
|
||||
append("[collab] file open never settled — collab/presence disabled for this session");
|
||||
} else {
|
||||
try {
|
||||
await attachCollabAndPresence();
|
||||
} catch (err) {
|
||||
// Version-skew refusal must still surface as the boot error.
|
||||
if ((err as { name?: string } | undefined)?.name === "SexprVersionError") throw err;
|
||||
// Degrade, don't die: a residual wasm trap here (reentrancy during
|
||||
// some other parked chain) used to fail the whole boot.
|
||||
append(`[collab] attach failed — continuing without collab: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
// Lib editors: the enumerate gate holds their whole-set hydrate until
|
||||
// the presync settles — wait for it here too, so the boot overlay (with
|
||||
// its ticking lib line) stays up instead of revealing an empty tree.
|
||||
|
|
|
|||
|
|
@ -141,11 +141,17 @@ function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void {
|
|||
* Drive a project into an already-booting tool runtime (booted into `win` by
|
||||
* bootKicadTool — the top-level window). Waits for the Emscripten FS, syncs the
|
||||
* project tree into MEMFS, then auto-opens the target file.
|
||||
*
|
||||
* Returns the open outcome: "failed" means the load never settled — the caller
|
||||
* must NOT drive further bare embind entries that walk the model (collab
|
||||
* snapshot, presence bind); they'd race the still-parked open chain and can
|
||||
* trap ("indirect call signature mismatch"). "none" = no open was attempted
|
||||
* (fileless tool / no target).
|
||||
*/
|
||||
export async function driveProjectIntoTool(
|
||||
win: ToolWindow,
|
||||
opts: DriveOptions,
|
||||
): Promise<void> {
|
||||
): Promise<"programmatic" | "ui" | "failed" | "none"> {
|
||||
const { log, onStatus } = opts;
|
||||
|
||||
onStatus("Waiting for runtime…");
|
||||
|
|
@ -159,11 +165,13 @@ export async function driveProjectIntoTool(
|
|||
await syncProjectToMemfs(win, opts);
|
||||
synthesizeProjectFile(win, opts);
|
||||
|
||||
let result: "programmatic" | "ui" | "failed" | "none" = "none";
|
||||
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
|
||||
onStatus("Opening file…");
|
||||
const abs = memfsFilePath(opts.slug, opts.targetPath);
|
||||
const result = await openFileInTool(win, abs, { log });
|
||||
result = await openFileInTool(win, abs, { log });
|
||||
log(`[open] result: ${result}`);
|
||||
}
|
||||
onStatus("");
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
117
web/standalone/src/wasm/open-flow.test.ts
Normal file
117
web/standalone/src/wasm/open-flow.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { openFileInTool } from "./open-flow";
|
||||
|
||||
/**
|
||||
* The programmatic-open settle gate (open_gate.h / kicadOpenFileBusy): the
|
||||
* shell must not report the open finished — and so must not go on to drive
|
||||
* bare embind entries (collab snapshot, presence bind) — while the
|
||||
* kicadOpenFile Asyncify chain is still parked mid-load. Regression tests for
|
||||
* the prod "indirect call signature mismatch" trap at board load.
|
||||
*/
|
||||
|
||||
function makeElement(partial: Partial<WxElementInfo>): WxElementInfo {
|
||||
return {
|
||||
id: "e1",
|
||||
typeName: "wxFrame",
|
||||
name: "MainFrame",
|
||||
label: "",
|
||||
visible: true,
|
||||
enabled: true,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWin(opts: {
|
||||
busy?: () => boolean;
|
||||
elements?: () => WxElementInfo[];
|
||||
title?: () => string;
|
||||
}) {
|
||||
const opened: string[] = [];
|
||||
const elements =
|
||||
opts.elements ?? (() => [makeElement({ typeName: "PCB_EDIT_FRAME" })]);
|
||||
const win = {
|
||||
document: {
|
||||
get title() {
|
||||
return opts.title ? opts.title() : "PCB Editor";
|
||||
},
|
||||
},
|
||||
Module: {
|
||||
kicadOpenFile: (p: string) => {
|
||||
opened.push(p);
|
||||
return false; // asyncify placeholder return — callers must ignore it
|
||||
},
|
||||
...(opts.busy ? { kicadOpenFileBusy: opts.busy } : {}),
|
||||
},
|
||||
wxElementRegistry: {
|
||||
findAll: (filter?: { visible?: boolean }) =>
|
||||
elements().filter((e) => (filter?.visible === undefined ? true : e.visible)),
|
||||
findByLabel: () => [],
|
||||
findRenderedByLabel: () => [],
|
||||
},
|
||||
};
|
||||
return { win: win as unknown as ToolWindow, opened };
|
||||
}
|
||||
|
||||
const log = () => {};
|
||||
|
||||
describe("openFileInTool settle gate", () => {
|
||||
it("waits for kicadOpenFileBusy to clear before reporting success", async () => {
|
||||
let busy = true;
|
||||
setTimeout(() => (busy = false), 350);
|
||||
const { win, opened } = makeWin({ busy: () => busy });
|
||||
const result = await openFileInTool(win, "/p/board.kicad_pcb", { log });
|
||||
expect(result).toBe("programmatic");
|
||||
expect(opened).toEqual(["/p/board.kicad_pcb"]);
|
||||
expect(busy).toBe(false); // returned only after the chain settled
|
||||
});
|
||||
|
||||
it("returns failed when the open chain never settles", async () => {
|
||||
const { win } = makeWin({ busy: () => true });
|
||||
const result = await openFileInTool(win, "/p/board.kicad_pcb", {
|
||||
log,
|
||||
settleTimeoutMs: 300,
|
||||
});
|
||||
expect(result).toBe("failed");
|
||||
});
|
||||
|
||||
it("proceeds when a modal input dialog is up (must stay answerable)", async () => {
|
||||
const elements = [makeElement({ typeName: "PCB_EDIT_FRAME" })];
|
||||
setTimeout(
|
||||
() => elements.push(makeElement({ id: "d1", typeName: "wxRichMessageDialog" })),
|
||||
250,
|
||||
);
|
||||
const { win } = makeWin({ busy: () => true, elements: () => elements });
|
||||
const result = await openFileInTool(win, "/p/board.kicad_pcb", {
|
||||
log,
|
||||
settleTimeoutMs: 5000,
|
||||
});
|
||||
expect(result).toBe("programmatic");
|
||||
});
|
||||
|
||||
it("does NOT treat the load's own progress dialog as an input dialog", async () => {
|
||||
const elements = [
|
||||
makeElement({ typeName: "PCB_EDIT_FRAME" }),
|
||||
makeElement({ id: "d1", typeName: "wxGenericProgressDialog" }),
|
||||
];
|
||||
const { win } = makeWin({ busy: () => true, elements: () => elements });
|
||||
const result = await openFileInTool(win, "/p/board.kicad_pcb", {
|
||||
log,
|
||||
settleTimeoutMs: 300,
|
||||
});
|
||||
expect(result).toBe("failed"); // progress dialog must not open the gate
|
||||
});
|
||||
|
||||
it("falls back to the legacy title heuristic on wasm without the probe", async () => {
|
||||
let title = "untitled [Unsaved] — Schematic Editor";
|
||||
setTimeout(() => (title = "board — Schematic Editor"), 250);
|
||||
const { win } = makeWin({ title: () => title });
|
||||
const result = await openFileInTool(win, "/p/main.kicad_sch", { log });
|
||||
expect(result).toBe("programmatic");
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,8 @@
|
|||
export interface OpenFlowOptions {
|
||||
log: (msg: string) => void;
|
||||
timeoutMs?: number;
|
||||
/** Override the load-settle budget (kicadOpenFileBusy poll) — tests only. */
|
||||
settleTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
|
@ -111,6 +113,79 @@ function schematicLoaded(win: ToolWindow): boolean {
|
|||
return title.length > 0 && !/untitled/i.test(title);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a load may stay in flight before we give up waiting and boot on
|
||||
* without collab (slow Firefox + big board loads run minutes, and the poll is
|
||||
* free — see waitForOpenSettled).
|
||||
*/
|
||||
const OPEN_SETTLE_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* A modal dialog other than the load's own progress dialog is up — the open
|
||||
* chain is parked waiting for USER input (file-version confirm, remap…). We
|
||||
* must not keep the shell blocked (the boot overlay would sit on top of the
|
||||
* dialog, unanswerable), so the settle wait treats this as "proceed".
|
||||
*/
|
||||
function inputDialogVisible(win: ToolWindow): boolean {
|
||||
return visible(win, {}).some(
|
||||
(e) => /Dialog/.test(e.typeName) && !/Progress/i.test(e.typeName),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the kicadOpenFile Asyncify chain has TRULY completed.
|
||||
*
|
||||
* kicadOpenFile suspends and unwinds back to JS long before the load finishes;
|
||||
* for the whole load the chain stays parked mid-mutation of the board/schematic.
|
||||
* Any bare embind entry that walks the model during such a park (collab
|
||||
* snapshot, presence bind) can virtual-dispatch through a half-built item and
|
||||
* trap with "indirect call signature mismatch" — the same reentrancy class the
|
||||
* wx dispatch interlock guards, but through a JS entry it cannot see. The old
|
||||
* readiness signal (the "untitled" title heuristic below) passes IMMEDIATELY
|
||||
* for any real project (the pre-open title is just "PCB Editor"), so it never
|
||||
* actually gated anything.
|
||||
*
|
||||
* The truthful signal is the wasm's kicadOpenFileBusy probe (open_gate.h): an
|
||||
* RAII counter on the open's C++ stack, held across every park, dropped when
|
||||
* OpenProjectFiles really returns. Feature-detected — wasm builds predating it
|
||||
* fall back to the legacy title poll. Returns false when the load never
|
||||
* settled (caller reports "failed"; the shell then skips the wasm-entering
|
||||
* collab/presence attach instead of trapping).
|
||||
*/
|
||||
async function waitForOpenSettled(
|
||||
win: ToolWindow,
|
||||
log: (m: string) => void,
|
||||
legacyTimeoutMs: number,
|
||||
settleTimeoutMs = OPEN_SETTLE_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
const mod = win.Module as { kicadOpenFileBusy?: () => boolean } | undefined;
|
||||
const busyFn = mod?.kicadOpenFileBusy;
|
||||
if (typeof busyFn === "function") {
|
||||
const settled = await waitFor(
|
||||
() => !busyFn.call(mod) || inputDialogVisible(win),
|
||||
settleTimeoutMs,
|
||||
);
|
||||
if (!settled) {
|
||||
log("[open] load chain never settled (kicadOpenFileBusy stuck) — giving up");
|
||||
return false;
|
||||
}
|
||||
if (busyFn.call(mod)) {
|
||||
log("[open] modal dialog during load — proceeding so it stays answerable");
|
||||
} else {
|
||||
log("[open] load chain settled (kicadOpenFileBusy cleared)");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Legacy wasm without the probe: the old title heuristic.
|
||||
const loaded = await waitFor(() => schematicLoaded(win), legacyTimeoutMs);
|
||||
if (!loaded) {
|
||||
log("[open] kicadOpenFile did not load the schematic within timeout");
|
||||
return false;
|
||||
}
|
||||
log(`[open] schematic loaded: ${win.document.title}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function openFileInTool(
|
||||
win: ToolWindow,
|
||||
absPath: string,
|
||||
|
|
@ -141,18 +216,14 @@ export async function openFileInTool(
|
|||
|
||||
// Strategy 1: programmatic hook (preferred — deterministic, no UI automation).
|
||||
// Because the call is Asyncify-async we can't trust its return value; instead
|
||||
// we invoke it and poll the frame title until the schematic loads. We must NOT
|
||||
// fall back to UI automation while the hook is in flight — synthesizing input
|
||||
// would re-enter the suspended Asyncify call and corrupt it.
|
||||
// we invoke it and wait for the open chain to settle (kicadOpenFileBusy — see
|
||||
// waitForOpenSettled). We must NOT fall back to UI automation while the hook
|
||||
// is in flight — synthesizing input would re-enter the suspended Asyncify
|
||||
// call and corrupt it.
|
||||
if (hasProgrammaticHook(win)) {
|
||||
invokeProgrammaticOpen(win, absPath, log);
|
||||
const loaded = await waitFor(() => schematicLoaded(win), timeoutMs);
|
||||
if (loaded) {
|
||||
log(`[open] schematic loaded: ${win.document.title}`);
|
||||
return "programmatic";
|
||||
}
|
||||
log("[open] kicadOpenFile did not load the schematic within timeout");
|
||||
return "failed";
|
||||
const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs);
|
||||
return settled ? "programmatic" : "failed";
|
||||
}
|
||||
|
||||
// Strategy 2: UI automation fallback (EXPERIMENTAL, fragile). Only when the
|
||||
|
|
|
|||
Loading…
Reference in a new issue