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:
parent
33e23e0a65
commit
3f09a46ff5
48 changed files with 2640 additions and 546 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue