findings(E-5,E-8,E-9): module-identity ngspice events + runWaitCompletion admission gate

E-8 (re-implemented for JSPI — the codex gate is entangled with the dropped
execution owner; under JSPI a fresh non-suspending JS→wasm entry while
another activation is suspended is structurally safe on its own stack, so
the admission boundary for worker completions is liveness + trap state, not
execution ownership):
- jspi-scheduler.js grows `terminal` (trapped instance; distinct from `dead`),
  canTouchNative(), _terminalizeNativeTrap() (WebAssembly.RuntimeError +
  cross-realm string classification), and runWaitCompletion(site, token,
  prepare, inertResult): prepare runs immediately and owns ALL native work;
  stale tokens and dead/terminal instances drop loudly without resolving
  (resolving would resume the parked frame inside the damaged module); a
  trap latches terminal; a plain JS bug resolves inertResult so the wait
  fails instead of stranding. beginWait refuses (token 0) when dead/terminal.
- all four delayed completion sites route their native work through the
  gate: 'OCC export completion' (exporter_step_stub), 'OCC model completion'
  (oce_plugin_stub — the MEMFS cache write moves inside the gate too),
  'ngspice request completion' and 'ngspice vector completion'
  (sharedspice_client — every HEAP32/HEAPF64/malloc write inside prepare,
  inertResult 1 = transport error). Every wxWasmBeginWait caller in the
  stubs bails on token <= 0.
- deliberately NOT ported from codex: ownerModule, enqueueNativeCompletion,
  executionBarrier, the byte-credit native-entry FIFO — completions are
  one-shot per wait token and stream volume is bounded at the E-6 transport
  credit window. Cross-refs logged for group M (M-2/M-6/M-8).

E-5 (re-implemented; codex shape kept, owner APIs replaced with the E-8
gate): js_ngspice_install_events binds the handler to the EXACT installing
module (handler.__pcbjamNgspiceOwnerModule stamp; presence is not identity),
re-installation replaces a foreign module's handler, a superseded handler
disarms itself, native entry goes through installingModule._malloc/
._pcbjam_ngspice_event (never lexical Module), each dispatch checks
canTouchNative() (loud drop on a dead/terminal module), and a trap on the
per-line entry latches the terminal gate.

Tests: scheduler-shim.test.ts +7 (gate happy/stale/dead/terminal/cross-realm/
js-bug/beginWait-refusal). e2e specs updated from the codex line: occ-export
decode-fault recovery (real onmessageerror transition via failDecode, J-4),
ngspice-probe direct-service coverage, eeschema-sim rewritten onto the E-7
applied-generation receipt (codex's executionBarrier await replaced with a
pendingWaits('ngspice') drain poll — the JSPI-line equivalent).

Also bumps the kicad submodule to the E-7/E-9 commit (dd5751038f7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-08-19 16:03:16 +02:00
commit c14e76651c
9 changed files with 763 additions and 149 deletions

View file

@ -24,6 +24,14 @@ type SchedulerShape = {
mutatorQueue: unknown[];
mutatorsDelivered: number;
dead: boolean;
terminal: boolean;
canTouchNative(): boolean;
runWaitCompletion(
site: string,
token: number,
prepare: () => number,
inertResult?: number,
): boolean;
shutdown(reason: string): void;
enqueueAfter(fn: number, arg: number, ms: number): void;
_openBusy(): boolean;
@ -229,3 +237,121 @@ describe("N5: scheduler shim under flood", () => {
await expect(S.waitPromise(99999), "unknown token resolves 0").resolves.toBe(0);
});
});
describe("E-8: runWaitCompletion admission gate for worker completions", () => {
it("runs prepare immediately and resolves the wait with its result", async () => {
const S = loadShim({ busy: () => false });
const token = S.beginWait("occ");
let ran = false;
expect(
S.runWaitCompletion("test completion", token, () => {
ran = true;
return 42;
}),
).toBe(true);
expect(ran, "prepare runs immediately, never queued").toBe(true);
await expect(S.waitPromise(token)).resolves.toBe(42);
});
it("drops a completion for a stale or already-resolved token, loudly", () => {
const S = loadShim({ busy: () => false });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const prepare = vi.fn(() => 1);
expect(S.runWaitCompletion("late frame", 99999, prepare)).toBe(false);
const token = S.beginWait("occ");
S.resolveWait(token, 7);
expect(S.runWaitCompletion("late frame", token, prepare)).toBe(false);
expect(prepare, "stale completions never touch native").not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(2);
} finally {
warn.mockRestore();
}
});
it("a dead instance admits no native work and does not resolve the wait", () => {
const S = loadShim({ busy: () => false });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const token = S.beginWait("ngspice");
S.shutdown("test");
expect(S.canTouchNative()).toBe(false);
const prepare = vi.fn(() => 1);
expect(S.runWaitCompletion("post-shutdown", token, prepare)).toBe(false);
expect(prepare).not.toHaveBeenCalled();
expect(S.waitEarlyResolved(token), "wait deliberately not resolved").toBe(0);
} finally {
warn.mockRestore();
}
});
it("a trap in prepare latches terminal and never resolves the wait", () => {
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const token = S.beginWait("occ");
expect(
S.runWaitCompletion("trapping completion", token, () => {
throw new WebAssembly.RuntimeError("memory access out of bounds");
}),
).toBe(false);
expect(S.terminal).toBe(true);
expect(S.canTouchNative()).toBe(false);
// Resolving would resume the parked frame INSIDE the trapped module.
expect(S.waitEarlyResolved(token)).toBe(0);
// Every later completion is inert…
const prepare = vi.fn(() => 1);
const token2Before = S.beginWait("occ");
expect(token2Before, "beginWait refuses on a terminal instance").toBe(0);
expect(S.runWaitCompletion("after trap", token, prepare)).toBe(false);
expect(prepare).not.toHaveBeenCalled();
} finally {
err.mockRestore();
warn.mockRestore();
}
});
it("classifies cross-realm trap strings as terminal too", () => {
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const token = S.beginWait("occ");
S.runWaitCompletion("cross-realm trap", token, () => {
throw new Error("RuntimeError: unreachable");
});
expect(S.terminal).toBe(true);
expect(S.waitEarlyResolved(token)).toBe(0);
} finally {
err.mockRestore();
}
});
it("a plain JS bug fails the wait with inertResult instead of stranding it", async () => {
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const token = S.beginWait("ngspice");
expect(
S.runWaitCompletion(
"buggy completion",
token,
() => {
throw new TypeError("res.lines is not iterable");
},
1,
),
).toBe(false);
expect(S.terminal, "a JS bug is not a trap").toBe(false);
await expect(S.waitPromise(token), "wait fails instead of stranding").resolves.toBe(1);
} finally {
err.mockRestore();
}
});
it("beginWait refuses (token 0) on a dead instance", () => {
const S = loadShim({ busy: () => false });
S.shutdown("test");
expect(S.beginWait("occ")).toBe(0);
});
});