jspi: migration phases 0-7 — build knob, scheduler shim, test successor suite

Toolchain: emsdk 6.0.6 (versions.sh; cache-hash keys on it). Build knob
PCBJAM_ASYNC_BACKEND=jspi|asyncify: build-kicad-target.sh links editors with
-sJSPI + -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt + --pre-js
jspi-scheduler.js (no DYNCALLS, no post-link asyncify pipeline); wx build
stamps the backend and forces clean on flip or unknown provenance;
docker/build.sh passes the knob, seeds the emscripten ports cache from the
volume every launch, jspi postprocess = patch-env-shim only.

scripts/common/shims/jspi-scheduler.js: the JSPI successor scheduler —
token-wait registry, resume turnstile (one armed resume between engine
re-entries, SP swaps only at microtask boundaries), green-region spill
stacks (16-aligned tops), S1 embind mutator FIFO lane + parker wraps, S6
shutdown, libctx integration hooks (suspend/end/quarantine + g_current
arm/clear), SuspendError attributor, lost-wake + stuck-window watchdogs,
__wxWaitDump observability.

Embind: PARKER registrations get emscripten::async() under PCBJAM_JSPI
(wasm/bindings/pcbjam_async_policy.h). nanosleep yields route via the shim.

Tests: tests/asyncify -> tests/jspi successor suite (jspi-stack red/green
shadow-stack battery, jspi-coroutine MiniCoro harness, suspend-races
semantic scenarios + __wxWaitDump books coherence); projects jspi-firefox/
jspi-chrome (asyncify-webkit retired — no JSPI in WebKit); unconditional
Firefox JSPI pref; guard-beacons -> wait-beacons (+wxScheduler/libctxJspi
families); Makefile.wasm links test apps against JSPI with the shim as a
tracked link prerequisite.

Web: WasmTool setRo await + __wxWaitDump forensics, open-flow contained
promise, scheduler-shim.test.ts retargeted (8 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDeBaKKhQztd8KiVtHuyXr
This commit is contained in:
Viktor Vaczi 2026-08-13 07:06:24 +02:00
commit 3f09a46ff5
48 changed files with 2640 additions and 546 deletions

View file

@ -1250,12 +1250,19 @@ export function WasmTool({
const promote = (kind: string, msg: string) => {
append(`[fatal] ${kind}: ${msg}`);
append(dumpTrace());
// The asyncify flight recorder (handlesleep.js shim): event ring +
// machine state at death — the targeting data for the fiber trap.
const rec = (
window as Window & { __wxAsyncifyDump?: () => string }
).__wxAsyncifyDump?.();
if (rec) append(rec);
// The scheduler flight recorder: event ring + wait/activation state at
// death — the targeting data for suspension-machinery traps. Canonical
// name is __wxWaitDump (jspi-scheduler); __wxAsyncifyDump is the legacy
// shim's name, kept as a fallback one release. The jspi dump is an
// object, the legacy one a string — normalize.
const dumper = (
window as Window & {
__wxWaitDump?: () => unknown;
__wxAsyncifyDump?: () => unknown;
}
);
const rec = (dumper.__wxWaitDump ?? dumper.__wxAsyncifyDump)?.();
if (rec) append(typeof rec === "string" ? rec : JSON.stringify(rec));
setFatal(msg);
setShowLog(true);
// Arm the React-independent floor too: it stays invisible while our
@ -1763,15 +1770,27 @@ export function WasmTool({
// they proceed (saves are already MEMFS-only above).
if (readOnly) {
const setRo = (
win.Module as { kicadSetReadOnly?: (v: boolean) => boolean } | undefined
win.Module as
| { kicadSetReadOnly?: (v: boolean) => boolean | Promise<boolean> }
| undefined
)?.kicadSetReadOnly;
if (typeof setRo === "function") {
const t0 = Date.now();
while (setRo(true) !== true) {
if (Date.now() - t0 > 30_000) {
throw new Error("read-only lock did not apply");
}
await new Promise((r) => setTimeout(r, 150));
// The scheduler's mutator lane returns the boolean synchronously
// when the wasm side is idle, and a Promise for the SAME call when
// it queued behind a live open — await covers both. (The old
// poll-until-literal-true loop could spin forever under JSPI: a
// queued call re-enqueues on every retry and never compares true.)
const applied = await Promise.race([
Promise.resolve(setRo(true)),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("read-only lock did not apply")),
30_000,
),
),
]);
if (applied !== true) {
throw new Error("read-only lock did not apply");
}
append("[readonly] wasm frame locked (kicadSetReadOnly)");
} else if (tool !== "gerbview" && tool !== "calculator") {

View file

@ -21,9 +21,10 @@ export interface OpenFlowOptions {
* Replace the programmatic invocation (default: `Module.kicadOpenFile(path)`)
* while keeping the readiness handling around it the frame wait, the
* settle gate, the no-UI-automation-while-parked rule. GerbView uses this to
* open a whole fabrication set through `kicadOpenFiles`.
* open a whole fabrication set through `kicadOpenFiles`. May return the
* open call's Promise (JSPI embind async) the flow contains its rejection.
*/
open?: () => void;
open?: () => unknown;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@ -98,20 +99,22 @@ function hasProgrammaticHook(win: ToolWindow): boolean {
}
/**
* Invoke the programmatic hook. NOTE: kicadOpenFile runs OpenProjectFiles under
* Asyncify, so the call SUSPENDS and unwinds back to JS before the load finishes
* its synchronous return is a falsy placeholder, not the real bool. So we fire
* it and ignore the return; the caller polls for the loaded schematic instead.
* Invoke the programmatic hook. kicadOpenFile suspends mid-load either way:
* under JSPI it is an embind async() export and returns a real Promise for the
* whole load chain; legacy asyncify builds return a falsy placeholder. The
* caller contains the Promise's rejection and gates readiness on the settle
* probe, which is truthful for both shapes.
*/
function invokeProgrammaticOpen(
win: ToolWindow,
absPath: string,
log: (m: string) => void,
): void {
): unknown {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod = win.Module as any;
mod.kicadOpenFile(absPath);
log(`[open] invoked Module.kicadOpenFile(${absPath}) (async; polling for load)`);
const ret = mod.kicadOpenFile(absPath);
log(`[open] invoked Module.kicadOpenFile(${absPath}) (async; awaiting settle)`);
return ret;
}
/** Heuristic: the editor frame title drops "untitled" once a real file is open. */
@ -222,14 +225,19 @@ 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 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.
// The open call suspends mid-load; readiness comes from the settle probe
// (kicadOpenFileBusy — see waitForOpenSettled), NOT the call's return: under
// JSPI the returned Promise deliberately stays pending while the load is
// parked on a user dialog (file-version confirm, remap…), exactly the case
// the probe's input-dialog escape handles. We must NOT fall back to UI
// automation while the hook is in flight — synthesizing input would re-enter
// the suspended load and corrupt it.
if (opts.open || hasProgrammaticHook(win)) {
if (opts.open) opts.open();
else invokeProgrammaticOpen(win, absPath, log);
const ret = opts.open ? opts.open() : invokeProgrammaticOpen(win, absPath, log);
// JSPI: contain the load Promise's rejection — a failed open clears the
// busy gate (RAII) and reports through the settle path like it always
// has; it must not ALSO surface as an unhandled rejection.
Promise.resolve(ret).catch((e) => log(`[open] open chain rejected: ${e}`));
const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs);
return settled ? "programmatic" : "failed";
}

View file

@ -1,12 +1,18 @@
/**
* N5 flood/fairness unit gates for the scheduler shim
* (docs/features/async/17 §3d N5; the shim source is scripts/common/shims/
* asyncify-scheduler.js, loaded here against a fake runtime surface).
* jspi-scheduler.js the JSPI-era successor of asyncify-scheduler.js
* loaded here against a fake runtime surface).
*
* Doc 06 §starvation: FIFO by default; a stimulus flood must neither reorder
* deliveries nor starve them, and the time-boxed pump must not monopolize the
* thread in one burst. These are unit gates the e2e batteries cover the
* same machinery under the real runtime.
*
* Retired with the asyncify shim (states unrepresentable under JSPI):
* deferred wakes (readyWakes/_scheduleWakeDrain), currData single-writer
* tripwire, state() machine string. The S4 wait-registry gates below are the
* JSPI-era additions.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { readFileSync } from "node:fs";
@ -15,24 +21,27 @@ import path from "node:path";
const SHIM_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../scripts/common/shims/asyncify-scheduler.js",
"../../../../scripts/common/shims/jspi-scheduler.js",
);
type SchedulerShape = {
backend: string;
mailbox: unknown[];
mutatorQueue: unknown[];
mutatorsDelivered: number;
readyWakes: { deliver: (r: unknown) => void; result: unknown }[];
deferredWakes: number;
drainedWakes: number;
strayWrites: number;
dead: boolean;
shutdown(reason: string): void;
enqueueAfter(fn: number, arg: number, ms: number): void;
_openBusy(): boolean;
_armMutatorPump(): void;
_scheduleWakeDrain(): void;
state(): string;
beginWait(kind: string): number;
waitPromise(token: number): Promise<number>;
waitEarlyResolved(token: number): number;
takeWaitResult(token: number): number;
resolveWait(token: number, result: number): boolean;
resolveTopWait(kind: string, result: number): boolean;
pendingWaits(kind: string): number;
earlyWaitResolves: number;
};
declare global {
@ -47,16 +56,6 @@ function loadShim(opts: { busy: () => boolean }) {
delete (globalThis as Record<string, unknown>).__wxSchedulerInstalled;
delete (globalThis as Record<string, unknown>).__wxScheduler;
const g = globalThis as Record<string, unknown>;
g.Asyncify = {
state: 0,
exportCallStack: [],
currData: null,
handleSleep: function (startAsync: (wake: (r: unknown) => void) => void) {
startAsync(() => undefined);
},
allocateData: () => 0,
maybeStopUnwind: () => undefined,
};
g.Module = {
kicadOpenFileBusy: opts.busy,
kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`,
@ -64,17 +63,24 @@ function loadShim(opts: { busy: () => boolean }) {
// eslint-disable-next-line no-eval
(0, eval)(readFileSync(SHIM_PATH, "utf8"));
const S = (globalThis as Record<string, unknown>).__wxScheduler as SchedulerShape;
// Run the Module init hook (fake runtime: pretend init fired).
// Run the Module init hook (fake runtime: pretend init fired) — installs
// the export/parker/mutator wraps; absent names are skipped.
const M = g.Module as { onRuntimeInitialized?: () => void };
M.onRuntimeInitialized?.();
return S;
}
describe("N5: scheduler shim under flood", () => {
describe("N5: scheduler shim under flood (jspi backend)", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("identifies as the jspi backend", () => {
const S = loadShim({ busy: () => false });
expect(S.backend).toBe("jspi");
expect(globalThis.__wxSchedulerInstalled).toBe(true);
});
it("500-call mutator flood delivers strictly FIFO with zero drops", async () => {
vi.useFakeTimers();
try {
@ -140,7 +146,18 @@ describe("N5: scheduler shim under flood", () => {
}
});
it("S6 shutdown: queued mutators reject, messages and wakes drop, pumps stop", async () => {
it("mutators bypass the queue when idle and not open-busy", () => {
const S = loadShim({ busy: () => false });
const M = (globalThis as Record<string, unknown>).Module as {
kicadCollabApplyItems: (x: number) => unknown;
};
// Sync fast path: the wrapped call returns the real value, unqueued.
expect(M.kicadCollabApplyItems(7)).toBe("applied:7");
expect(S.mutatorQueue.length).toBe(0);
expect(S.mutatorsDelivered).toBe(1);
});
it("S6 shutdown: queued mutators reject, messages drop, pumps stop", async () => {
vi.useFakeTimers();
try {
let busy = true;
@ -152,7 +169,6 @@ describe("N5: scheduler shim under flood", () => {
const exP = expect(p).rejects.toThrow("shutdown");
S.enqueueAfter(1234, 0, 5);
await vi.advanceTimersByTimeAsync(6); // message lands in the mailbox
S.readyWakes.push({ deliver: () => undefined, result: 0 });
expect(S.mailbox.length).toBe(1);
expect(S.mutatorQueue.length).toBe(1);
@ -160,9 +176,7 @@ describe("N5: scheduler shim under flood", () => {
await exP;
expect(S.mailbox.length).toBe(0);
expect(S.mutatorQueue.length).toBe(0);
expect(S.readyWakes.length).toBe(0);
expect(S.dead).toBe(true);
expect(S.state()).toContain("DEAD");
// Post-shutdown enqueues are dropped, and idempotent shutdown is safe.
S.enqueueAfter(1234, 0, 1);
@ -177,22 +191,47 @@ describe("N5: scheduler shim under flood", () => {
}
});
it("deferred wakes drain strictly FIFO", async () => {
vi.useFakeTimers();
try {
const S = loadShim({ busy: () => false });
const order: number[] = [];
// Queue 50 deferred wakes directly (the runtime path queues these when
// a wake arrives mid-transition); the drain must preserve order.
for (let i = 0; i < 50; i++) {
S.readyWakes.push({ deliver: (r) => order.push(r as number), result: i });
}
S._scheduleWakeDrain();
await vi.advanceTimersByTimeAsync(50);
expect(order).toEqual(Array.from({ length: 50 }, (_, i) => i));
expect(S.readyWakes.length).toBe(0);
} finally {
vi.useRealTimers();
}
// --- S4 wait registry (JSPI-era unit gates) ------------------------------
it("early resolve is consumed by the late waiter (no lost wake)", async () => {
const S = loadShim({ busy: () => false });
const token = S.beginWait("modal");
// Resolve BEFORE anyone awaits — the EndModal-during-Show() race.
expect(S.resolveWait(token, 42)).toBe(true);
expect(S.waitEarlyResolved(token)).toBe(1);
expect(S.earlyWaitResolves).toBe(1);
// The late waiter still gets the result, immediately.
await expect(S.waitPromise(token)).resolves.toBe(42);
// Consumed: a second take returns nothing.
expect(S.takeWaitResult(token)).toBe(0);
});
it("resolveTopWait pops per-kind LIFO — innermost modal first", () => {
const S = loadShim({ busy: () => false });
const outer = S.beginWait("modal");
const inner = S.beginWait("modal");
const nested = S.beginWait("nested"); // different kind: untouched
expect(S.pendingWaits("modal")).toBe(2);
expect(S.resolveTopWait("modal", 7)).toBe(true);
expect(S.waitEarlyResolved(inner), "inner resolved first").toBe(1);
expect(S.waitEarlyResolved(outer)).toBe(0);
expect(S.pendingWaits("modal")).toBe(1);
expect(S.pendingWaits("nested")).toBe(1);
expect(S.resolveTopWait("modal", 8)).toBe(true);
expect(S.waitEarlyResolved(outer)).toBe(1);
expect(S.resolveTopWait("modal", 9), "empty stack refuses").toBe(false);
void nested;
});
it("double resolve is refused; unknown token is a defined no-op", async () => {
const S = loadShim({ busy: () => false });
const token = S.beginWait("sleep");
expect(S.resolveWait(token, 1)).toBe(true);
expect(S.resolveWait(token, 2), "second resolve refused").toBe(false);
await expect(S.waitPromise(token)).resolves.toBe(1);
expect(S.resolveWait(99999, 0)).toBe(false);
await expect(S.waitPromise(99999), "unknown token resolves 0").resolves.toBe(0);
});
});