findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work

A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.

Services / transport
  E-10  retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
        mirror stayed latched true after a mid-run worker death: Run stayed
        disabled and the promised fresh-worker restart was unreachable for the
        whole session. Retirement now dispatches a synthetic controlled-exit
        straight to the installed handler (never through dispatchEvt — a
        fabricated frame must not touch the credit ledger). Driving the repro
        exposed two further defects, both fixed here: a replacement worker
        trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
        that uninitialized engine before KiCad's validate() re-init (the native
        flow assumes a crashed engine survives in-process — true for the dll,
        false for a dead worker). Reads now answer their empty shapes pre-init,
        writes lazy-init, and init is idempotent per worker engine.
  E-19  dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
        client deliberately rethrows non-trap errors — so each throw leaked one
        unit of the 64-frame credit window until the stream died with a
        misattributed "transport exceeded". The ack moves to a finally in both
        service copies; the throw still propagates (the trap machinery needs it).
  E-20  the oversize-line path promises to transfer the accepted prefix, but
        with the window full that flush only DEFERS, and stopEventStream wiped
        the deferred queue — losing the diagnostics that explain the failure.
        The terminal notice now carries them as pendingEvents; both hosts
        deliver them in order, unacked (the fatal frame is outside the credit
        protocol).
  E-21  the 30s prefetch deadline discarded every model already collected and
        reported nothing. A caller-owned progress sink ships the partials and
        the omission reaches the export report. (Awaiting the aborted collection
        was rejected: an in-flight source fetch is not abortable — E-4's
        original disease.) Plus a serving-candidate memo, so a .wrl ref served
        by its .step fallback stops re-probing the miss on every export.

Scheduler
  E-14  _terminalizeNativeTrap classified by message substring, so any plain JS
        error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
        healthy instance. Now structural only: instanceof RuntimeError plus a
        duck-typed name check (verified in this build's glue that abort() throws
        a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
        now latches the gate — the authoritative notification, previously
        ignored.
  E-15  the shim half: _pumpResume gates on terminal (catching wakes already
        queued at latch time) and resolveWait refuses on terminal WITHOUT
        consuming the entry, so a frame stays visibly parked rather than
        resuming inside a trapped module.
  E-16  the E-5 handler read the realm-global scheduler at dispatch instead of
        its installing module's; also frees the per-line buffer on the non-trap
        rethrow path.
  E-11  get_vec trusted the worker's res.length over the transferred arrays.
        Observed death shape: a 4 GiB std::vector threw an unhandled
        std::length_error that exited the editor's main loop. Now clamped, with
        the buffers freed on every failure path.

Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
  E-18  the source contract asserted comment-string counts — rewording failed
        CI while moving a guard outside its #ifdef passed. It now parses the
        #ifdef regions and asserts on code.
        service-stub-parity.ts pins what the four lifecycle copies must share:
        credit-window equality parsed from source, the finally-ack, boot
        deadlines, terminal-notice consumption. The transport numbers are now
        single-sourced from the worker.
        CI actually runs the gates: the web/standalone vitest suites (which had
        NEVER run in CI), the reducer, the source contract and the parity tool —
        with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
  E-22  the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
        the ngspice-stub shape with a wedgeNextBoot() repro hook.

Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.

Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.

Bumps kicad and wxwidgets to their findings-group-e tips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-08-27 12:27:12 +02:00
commit c421d724b0
48 changed files with 2071 additions and 694 deletions

View file

@ -679,6 +679,13 @@ async function doBoot(opts: BootOptions): Promise<void> {
onAbort: (what: unknown) => {
const msg = what === undefined ? "" : String(what);
log(`[boot] abort: ${msg}`);
// Authoritative trap notification: latch the scheduler's terminal gate
// so no parked frame resumes into the aborted instance (E-8/E-14).
// Safe w.r.t. recovery — oom-watch recovers via a full page reload,
// never an in-realm module replacement.
(globalThis as {
__wxScheduler?: { terminalize?: (site: string, e?: unknown) => void };
}).__wxScheduler?.terminalize?.("emscripten abort", msg);
onAbort?.(msg);
},
monitorRunDependencies: () => {},

View file

@ -7,6 +7,7 @@ import {
runDeferredModelPrescan,
normalizeModelRef,
scanModelRefs,
type BoardModelFile,
} from "./models-bridge";
import type { Model3dSource } from "./models-source";
@ -172,6 +173,104 @@ describe("collectBoardModelFiles", () => {
expect(source.getModelBody).toHaveBeenCalledTimes(1);
});
it("feeds the caller's progress sink as models are accepted", async () => {
// E-21: the prefetch caller reads the sink synchronously at its timeout —
// the accepted models must be there the moment they are accepted, and the
// scan total as soon as it is known.
(globalThis as unknown as { window: unknown }).window ??= globalThis;
let resolveSecond!: (body: Uint8Array | null) => void;
const source: Model3dSource = {
getModelBody: vi.fn((ref: string) => {
if (ref.startsWith("SinkA")) {
return Promise.resolve(new TextEncoder().encode(`body:${ref}`));
}
return new Promise<Uint8Array | null>((resolve) => {
resolveSecond = resolve;
});
}),
hasModel: async () => true,
};
installModel3dHandler(source, () => {});
const progress = { totalRefs: 0, models: [] as BoardModelFile[] };
const collection = collectBoardModelFiles(
'(model "SinkA.3dshapes/A.step")\n(model "SinkB.3dshapes/B.step")',
1,
undefined,
progress,
);
await vi.waitFor(() => expect(progress.models).toHaveLength(1));
expect(progress.totalRefs, "scan total known up front").toBe(2);
expect(progress.models[0]!.path).toBe("SinkA.3dshapes/A.step");
resolveSecond(new TextEncoder().encode("body:SinkB.3dshapes/B.step"));
const models = await collection;
expect(models, "the sink IS the result array").toBe(progress.models);
expect(models).toHaveLength(2);
});
it("gains no sink entries after abort (in-flight result stays inert)", async () => {
// E-4 barrier, restated through the sink: an aborted collection may not
// retain a body that resolves after the abort — not in its result, and
// not in the caller's progress sink either.
(globalThis as unknown as { window: unknown }).window ??= globalThis;
let resolveFirst!: (body: Uint8Array | null) => void;
const source: Model3dSource = {
getModelBody: vi.fn(
() => new Promise<Uint8Array | null>((resolve) => {
resolveFirst = resolve;
}),
),
hasModel: async () => true,
};
installModel3dHandler(source, () => {});
const controller = new AbortController();
const retired = new Error("exact OCC prefetch retired");
const progress = { totalRefs: 0, models: [] as BoardModelFile[] };
const collection = collectBoardModelFiles(
'(model "SinkAbortA.3dshapes/A.step")\n'
+ '(model "SinkAbortB.3dshapes/B.step")',
1,
controller.signal,
progress,
);
await vi.waitFor(() => expect(source.getModelBody).toHaveBeenCalledTimes(1));
controller.abort(retired);
resolveFirst(new Uint8Array([1, 2, 3]));
await expect(collection).rejects.toBe(retired);
expect(progress.totalRefs).toBe(2);
expect(progress.models, "no post-abort retention through the sink").toEqual([]);
});
it("remembers the serving fallback candidate across collects (no re-probes)", async () => {
// E-21 memo: a .wrl ref served by its .step fallback re-probed the missing
// .wrl on every export (IDB + network round-trips). The serving candidate
// is remembered per ref — positive results only, no body caching.
(globalThis as unknown as { window: unknown }).window ??= globalThis;
const getModelBody = vi.fn(async (ref: string) =>
ref.endsWith(".step") ? new TextEncoder().encode(`body:${ref}`) : null);
const source: Model3dSource = { getModelBody, hasModel: async () => true };
installModel3dHandler(source, () => {});
const board = '(model "${KICAD10_3DMODEL_DIR}/MemoLib.3dshapes/M1.wrl")';
await collectBoardModelFiles(board);
expect(getModelBody.mock.calls.map((call) => call[0]),
"first collect probes the .wrl miss then the .step hit").toEqual([
"MemoLib.3dshapes/M1.wrl",
"MemoLib.3dshapes/M1.step",
]);
getModelBody.mockClear();
const models = await collectBoardModelFiles(board);
expect(getModelBody.mock.calls.map((call) => call[0]),
"second collect goes straight to the remembered candidate").toEqual([
"MemoLib.3dshapes/M1.step",
]);
expect(models[0]!.path).toBe("MemoLib.3dshapes/M1.step");
});
it("never touches the editor MEMFS (pure source/IDB/network path)", async () => {
// repro for E-4: routing bodies through the editor heap added a stale
// native-completion tail after a prefetch timeout — the collect path must

View file

@ -91,6 +91,15 @@ const materialized = new Map<string, string>();
/** In-flight ensures, coalesced per ref (prescan and the C++ fallback race). */
const ensuring = new Map<string, Promise<string | null>>();
/**
* Which fallback candidate served each ref on the pure-source collect path
* (positive results only). Kills the repeated failed-candidate probes — a
* `.wrl` ref served by its `.step` fallback re-probed the missing `.wrl` on
* every export — without caching bodies (the source's IDB layer does that) and
* without touching the editor MEMFS. Cleared on source replacement.
*/
const servingCandidate = new Map<string, string>();
/** Wire the model source used by the provider dispatch + prescan. */
export function installModel3dHandler(
source: Model3dSource,
@ -98,6 +107,7 @@ export function installModel3dHandler(
): void {
installedSource = source;
installedLog = log;
servingCandidate.clear();
}
/** Fetch one model body and write it under MODELS_3D_ROOT. Resolves to the
@ -194,6 +204,18 @@ export interface BoardModelFile {
bytes: Uint8Array;
}
/**
* Caller-owned progress sink for collectBoardModelFiles: `models` receives
* each accepted body the moment it is accepted, `totalRefs` is set as soon as
* the board scan completes. A caller that abandons the collection (prefetch
* timeout) reads the partial set synchronously — awaiting the collector after
* abort would be unbounded (an in-flight source fetch is not abortable).
*/
export interface CollectProgress {
totalRefs: number;
models: BoardModelFile[];
}
/**
* Fetch every lib model a board references for an occ_service export. This is
* deliberately a pure source/IDB/network path (E-4): the OCC worker has a
@ -209,8 +231,10 @@ export async function collectBoardModelFiles(
boardText: string,
concurrency = 6,
signal?: AbortSignal,
progress?: CollectProgress,
): Promise<BoardModelFile[]> {
if (!installedSource) return [];
const out: BoardModelFile[] = progress?.models ?? [];
if (!installedSource) return out;
const source = installedSource;
const isCurrent = () => source === installedSource;
const throwIfAborted = (): void => {
@ -219,9 +243,9 @@ export async function collectBoardModelFiles(
};
throwIfAborted();
const refs = scanModelRefs(boardText);
if (!refs.length) return [];
if (progress) progress.totalRefs = refs.length;
if (!refs.length) return out;
const out: BoardModelFile[] = [];
const seen = new Set<string>();
let idx = 0;
const worker = async (): Promise<void> => {
@ -229,7 +253,14 @@ export async function collectBoardModelFiles(
throwIfAborted();
if (!isCurrent() || idx >= refs.length) return;
const ref = refs[idx++]!;
for (const candidate of refCandidates(ref)) {
// The remembered serving candidate goes first (skips re-probing
// fallbacks that failed on an earlier export); the rest stay as backup
// in case it can no longer serve (IDB eviction).
const remembered = servingCandidate.get(ref);
const candidates = remembered
? [remembered, ...refCandidates(ref).filter((c) => c !== remembered)]
: refCandidates(ref);
for (const candidate of candidates) {
let body: Uint8Array | null = null;
try {
body = await source.getModelBody(candidate);
@ -241,6 +272,7 @@ export async function collectBoardModelFiles(
if (!isCurrent()) return;
if (!body) continue;
servingCandidate.set(ref, candidate);
if (!seen.has(candidate)) {
seen.add(candidate);
// The OCC worker receives this buffer as a transferable. Keep its

View file

@ -7,55 +7,18 @@ vi.mock("./wasm-assets", () => ({
import {
installNgspiceService,
type NgspiceEvent,
type NgspiceRequest,
type NgspiceResponse,
} from "./ngspice-service";
import { resolveWasmBase } from "./wasm-assets";
import { FakeWorker, waitForWorker } from "./test-utils/fake-worker";
const mockedResolveWasmBase = vi.mocked(resolveWasmBase);
const TEST_BOOT_TIMEOUT_MS = 1_000;
const TEST_RESPONSE_TIMEOUT_MS = 5_000;
type MessageListener = (event: MessageEvent) => void;
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: MessageListener | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
onmessageerror: ((event: MessageEvent) => void) | null = null;
readonly postMessage = vi.fn();
readonly terminate = vi.fn();
private readonly messageListeners = new Set<MessageListener>();
constructor() {
FakeWorker.instances.push(this);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
if (type === "message") this.messageListeners.add(listener as MessageListener);
}
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
if (type === "message") this.messageListeners.delete(listener as MessageListener);
}
emitMessage(data: unknown): void {
const event = { data } as MessageEvent;
this.onmessage?.(event);
for (const listener of [...this.messageListeners]) listener(event);
}
emitError(message: string): void {
this.onerror?.({ message } as ErrorEvent);
}
emitMessageError(): void {
this.onmessageerror?.({} as MessageEvent);
}
}
const commandRequest = (cmd = "run"): NgspiceRequest => ({ kind: "command", cmd });
const service = () => {
@ -64,11 +27,6 @@ const service = () => {
return installed;
};
async function waitForWorker(index: number): Promise<FakeWorker> {
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
return FakeWorker.instances[index]!;
}
async function readyRequest(
workerIndex: number,
requestBody: NgspiceRequest = commandRequest(),
@ -454,6 +412,142 @@ describe("ngspice service worker lifetime", () => {
await expect(first.request).resolves.toEqual({ ret: 0 });
});
it("acks a frame whose handler throws — one throw must not leak credit", async () => {
// E-19: the host takes transport ownership at onmessage; the sharedspice
// client deliberately rethrows non-trap errors, and each throw that
// escaped before the ack leaked one unit of the worker's 64-frame credit
// window until the stream died with a misattributed overload.
const first = await readyRequest(0);
globalThis.__ngspiceOnEvent = () => {
throw new Error("plot apply bug");
};
expect(() => first.worker.emitMessage({
evt: { kind: "char", lines: ["boom"] },
eventSequence: 1,
eventBytes: 32,
}), "the handler throw keeps propagating (trap machinery must see it)")
.toThrow("plot apply bug");
const acks = () => first.worker.postMessage.mock.calls
.map((call) => (call[0] as { eventAck?: { sequence: number; bytes: number } }).eventAck)
.filter(Boolean);
expect(acks(), "the frame is acked despite the throwing handler")
.toEqual([{ sequence: 1, bytes: 32 }]);
// The stream stays live: a later frame delivers and acks normally.
const events: string[] = [];
globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind);
first.worker.emitMessage({
evt: { kind: "char", lines: ["after"] },
eventSequence: 2,
eventBytes: 33,
});
expect(events).toEqual(["after"]);
expect(acks()).toEqual([
{ sequence: 1, bytes: 32 },
{ sequence: 2, bytes: 33 },
]);
first.worker.emitMessage({ id: first.id, res: { ret: 0 } });
await expect(first.request).resolves.toEqual({ ret: 0 });
});
it("acks the live frame exactly once when the queued-frame drain throws", async () => {
// E-19, drain path: a throw while draining the pre-handler queue aborts
// delivery, but the live frame's credit was owned at onmessage — its ack
// must still go out, and the queued frame (acked at enqueue) not twice.
const first = await readyRequest(0);
first.worker.emitMessage({
evt: { kind: "char", lines: ["early"] },
eventSequence: 1,
eventBytes: 33,
});
globalThis.__ngspiceOnEvent = (event) => {
if (event.lines?.[0] === "early") throw new Error("drain bug");
};
expect(() => first.worker.emitMessage({
evt: { kind: "char", lines: ["live"] },
eventSequence: 2,
eventBytes: 32,
})).toThrow("drain bug");
const acks = first.worker.postMessage.mock.calls
.map((call) => (call[0] as { eventAck?: { sequence: number; bytes: number } }).eventAck)
.filter(Boolean);
expect(acks, "exactly one ack per owned frame, none doubled").toEqual([
{ sequence: 1, bytes: 33 },
{ sequence: 2, bytes: 32 },
]);
first.worker.emitMessage({ id: first.id, res: { ret: 0 } });
await expect(first.request).resolves.toEqual({ ret: 0 });
});
it("delivers the terminal notice's pending events, then retires, then exits", async () => {
// E-20: the worker's fatal frame carries the deferred batches it had
// already accepted (typically the diagnostics explaining the failure) —
// they must reach the handler, in order, without acks; the retirement's
// synthetic controlled-exit (E-10) follows them.
const first = await readyRequest(0);
const events: string[] = [];
globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind);
first.worker.emitMessage({
fatal: "ngspice event line exceeds 1048576 UTF-8 bytes",
pendingEvents: [
{ evt: { kind: "char", lines: ["tail diagnostics"] }, eventBytes: 42 },
{ evt: { kind: "bg", finished: true }, eventBytes: 30 },
],
});
await expect(first.request).resolves.toEqual({
error: "ngspice_service crashed: event stream failure: "
+ "ngspice event line exceeds 1048576 UTF-8 bytes",
});
expect(events, "accepted frames first, synthetic exit last").toEqual([
"tail diagnostics",
"bg",
"exit",
]);
const acks = first.worker.postMessage.mock.calls
.map((call) => (call[0] as { eventAck?: unknown }).eventAck)
.filter(Boolean);
expect(acks, "terminal delivery is outside the credit protocol").toEqual([]);
});
it("synthesizes one controlled exit per retirement so the run mirror unlatches", async () => {
// E-10: a retired worker emits no bg/exit frame of its own; without the
// synthetic exit the sharedspice s_bgRunning mirror stays latched true
// and the simulator's Run action is disabled for the session.
const first = await readyRequest(0);
const events: NgspiceEvent[] = [];
globalThis.__ngspiceOnEvent = (event) => events.push(event);
first.worker.emitError("wasm trap");
await expect(first.request).resolves.toEqual({
error: "ngspice_service crashed: wasm trap",
});
expect(events).toEqual([
{ kind: "exit", status: 1, immediate: true, quit: false },
]);
// Retirement is idempotent — a late second fault emits nothing more.
first.worker.emitError("late echo");
expect(events).toHaveLength(1);
// And a throwing handler must not break the retirement itself.
const second = await readyRequest(1);
globalThis.__ngspiceOnEvent = () => {
throw new Error("exit handler bug");
};
second.worker.emitError("second trap");
await expect(second.request).resolves.toEqual({
error: "ngspice_service crashed: second trap",
});
});
it("retires a worker whose bounded event stream reports a fatal line", async () => {
const first = await readyRequest(0, commandRequest("oversize output"));
first.worker.emitMessage({

View file

@ -169,15 +169,24 @@ export function installNgspiceService(
const frame = { generation: slot.generation, evt, sequence, bytes };
const handler = globalThis.__ngspiceOnEvent;
if (handler) {
while (evtQueue.length) {
const queued = evtQueue.shift()!;
evtQueueBytes -= queued.bytes;
if (queued.generation !== slot.generation) continue;
// Queued frames were acked at enqueue (ownership was taken then).
handler(queued.evt);
// The host took transport ownership of this frame the moment it arrived
// in onmessage, so the ack must survive a throwing handler (the
// sharedspice client deliberately rethrows non-trap errors) — otherwise
// each throw leaks one unit of the worker's credit window until the
// stream dies with a misattributed overload. The throw itself keeps
// propagating: the client's trap-latch machinery needs to see it.
try {
while (evtQueue.length) {
const queued = evtQueue.shift()!;
evtQueueBytes -= queued.bytes;
if (queued.generation !== slot.generation) continue;
// Queued frames were acked at enqueue (ownership was taken then).
handler(queued.evt);
}
handler(evt);
} finally {
ackEvent(slot, frame);
}
handler(evt);
ackEvent(slot, frame);
} else {
if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES
|| evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) {
@ -195,6 +204,31 @@ export function installNgspiceService(
}
};
// The worker's terminal notice carries the deferred frames it had already
// accepted (its accepted-prefix contract — typically the last diagnostics
// explaining why the run died): deliver them best-effort, in order, WITHOUT
// acking — the fatal frame lives outside the credit protocol and the stream
// is gone. Best-effort: a throwing handler must not block later frames or
// the retirement that follows.
const deliverTerminalEvents = (entries: unknown): void => {
if (!Array.isArray(entries) || entries.length === 0) return;
const handler = globalThis.__ngspiceOnEvent;
if (!handler) {
log(`[ngspice] dropping ${entries.length} undelivered event frame(s) `
+ "from a failed stream (no handler installed)");
return;
}
for (const entry of entries) {
const evt = (entry as { evt?: NgspiceEvent } | null)?.evt;
if (!evt) continue;
try {
handler(evt);
} catch (error) {
log(`[ngspice] terminal event delivery failed: ${String(error)}`);
}
}
};
const failPending = (generation: number, why: string): void => {
for (const [id, request] of pending) {
if (request.generation !== generation) continue;
@ -237,6 +271,24 @@ export function installNgspiceService(
const reject = slot.rejectBoot;
slot.rejectBoot = undefined;
reject?.(new Error(why));
// A retired worker emits no bg/exit frame of its own, so the sharedspice
// client's s_bgRunning mirror would stay latched true after a mid-run
// death — Run stays disabled and the promised fresh-worker restart is
// unreachable for the whole session. Synthesize the controlled-exit the
// crashed engine could not send. Dispatch straight to the installed
// handler, NOT through dispatchEvt: a fabricated frame must never touch
// the transport credit ledger. The client handler routes it through
// cbControlledExit (clears the mirror, reports, delivers SIM_IDLE) and
// self-drops on a dead/terminal instance.
const handler = globalThis.__ngspiceOnEvent;
if (handler) {
try {
handler({ kind: "exit", status: 1, immediate: true, quit: false });
} catch (error) {
log(`[ngspice] synthetic exit dispatch failed: ${String(error)}`);
}
}
};
const ensureWorker = (): Promise<WorkerSlot> => {
@ -284,6 +336,9 @@ export function installNgspiceService(
if (slot.failed || workerSlot !== slot) return;
const data = e.data ?? {};
if (data.fatal) {
// Deliver the accepted-but-undelivered frames the terminal
// notice carries before retiring the generation.
deliverTerminalEvents(data.pendingEvents);
failWorker(`event stream failure: ${String(data.fatal)}`);
return;
}

View file

@ -12,6 +12,7 @@ vi.mock("./wasm-assets", () => ({
import { installOccService, type OccResponse } from "./occ-service";
import { collectBoardModelFiles } from "./libs/models-bridge";
import { resolveWasmBase } from "./wasm-assets";
import { FakeWorker, waitForWorker } from "./test-utils/fake-worker";
const mockedCollectBoardModelFiles = vi.mocked(collectBoardModelFiles);
const mockedResolveWasmBase = vi.mocked(resolveWasmBase);
@ -20,45 +21,6 @@ const TEST_MODEL_PREFETCH_TIMEOUT_MS = 500;
const TEST_BOOT_TIMEOUT_MS = 1_000;
const TEST_RESPONSE_TIMEOUT_MS = 5_000;
type MessageListener = (event: MessageEvent) => void;
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: MessageListener | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
onmessageerror: ((event: MessageEvent) => void) | null = null;
readonly postMessage = vi.fn();
readonly terminate = vi.fn();
private readonly messageListeners = new Set<MessageListener>();
constructor() {
FakeWorker.instances.push(this);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
if (type === "message") this.messageListeners.add(listener as MessageListener);
}
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
if (type === "message") this.messageListeners.delete(listener as MessageListener);
}
emitMessage(data: unknown): void {
const event = { data } as MessageEvent;
this.onmessage?.(event);
for (const listener of [...this.messageListeners]) listener(event);
}
emitError(message: string): void {
this.onerror?.({ message } as ErrorEvent);
}
emitMessageError(): void {
this.onmessageerror?.({} as MessageEvent);
}
}
const loadRequest = () => ({
kind: "loadModel" as const,
bytes: new Uint8Array([1, 2, 3]),
@ -78,11 +40,6 @@ const service = () => {
return installed;
};
async function waitForWorker(index: number): Promise<FakeWorker> {
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
return FakeWorker.instances[index]!;
}
async function readyRequest(workerIndex: number): Promise<{
worker: FakeWorker;
request: Promise<OccResponse>;
@ -255,9 +212,55 @@ describe("OCC service worker lifetime", () => {
expect(worker.postMessage).toHaveBeenCalledTimes(1);
worker.emitMessage({ id, res: { ok: true, report: "exported" } });
// The timeout is no longer silent at the headline level: the export
// report carries the omission note. (The mock feeds no progress sink,
// hence the 0-of-0 counts here.)
await expect(request).resolves.toEqual({
ok: true,
report: "exported",
report: "exported\nmodel prefetch timed out after "
+ `${TEST_MODEL_PREFETCH_TIMEOUT_MS} ms — 0 of 0 model(s) omitted`,
fileName: undefined,
});
expect(vi.getTimerCount()).toBe(0);
});
it("ships the partial prefetch on timeout and reports the omission", async () => {
// E-21: a slow-but-alive prefetch used to be all-or-nothing — the 30s
// deadline discarded every model already collected and the export
// completed under a bare "Export complete." A timeout must ship the
// accepted partials and surface the omission in the report.
vi.useFakeTimers();
mockedCollectBoardModelFiles.mockImplementationOnce(
(_board, _concurrency, _signal, progress) => {
if (progress) {
progress.totalRefs = 3;
progress.models.push(
{ path: "PartialA.3dshapes/a.step", bytes: new Uint8Array([1]) },
{ path: "PartialB.3dshapes/b.step", bytes: new Uint8Array([2]) },
);
}
return new Promise(() => undefined); // hangs past the deadline
},
);
const request = service().request(exportRequest());
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(TEST_MODEL_PREFETCH_TIMEOUT_MS);
const worker = FakeWorker.instances[0]!;
expect(worker).toBeDefined();
worker.emitMessage({ ready: true });
await vi.advanceTimersByTimeAsync(0);
const [{ id, req: dispatched }] = worker.postMessage.mock.calls[0] as [
{ id: number; req: { models: Array<{ path: string }> } },
];
expect(dispatched.models.map((m) => m.path), "the accepted partials ship")
.toEqual(["PartialA.3dshapes/a.step", "PartialB.3dshapes/b.step"]);
worker.emitMessage({ id, res: { ok: true, report: "Export complete." } });
await expect(request).resolves.toEqual({
ok: true,
report: "Export complete.\nmodel prefetch timed out after "
+ `${TEST_MODEL_PREFETCH_TIMEOUT_MS} ms — 1 of 3 model(s) omitted`,
fileName: undefined,
});
expect(vi.getTimerCount()).toBe(0);

View file

@ -1,5 +1,5 @@
import { downloadBytes } from "@/lib/download";
import { collectBoardModelFiles, type BoardModelFile } from "./libs/models-bridge";
import { collectBoardModelFiles, type BoardModelFile, type CollectProgress } from "./libs/models-bridge";
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
import occWorkerSource from "./occ-worker.js?raw";
@ -291,7 +291,7 @@ export function installOccService(
const prefetchBoardModels = async (
board: Uint8Array,
): Promise<BoardModelFile[]> => {
): Promise<{ models: BoardModelFile[]; note?: string }> => {
type Outcome =
| { kind: "ready"; models: BoardModelFile[] }
| { kind: "failed"; error: unknown }
@ -300,12 +300,18 @@ export function installOccService(
// collectBoardModelFiles keeps its own bounded network parallelism and does
// no editor-native work. The controller owns this exact optional
// collection: a timeout stops it from selecting more models and makes its
// already-started source results inert.
// already-started source results inert. The progress sink receives every
// accepted model as it lands — on timeout the partial set still ships
// (an aborted collection can never be awaited: an in-flight source fetch
// is not abortable), and the omission is surfaced in the export report
// instead of silently exporting without models.
const controller = new AbortController();
const progress: CollectProgress = { totalRefs: 0, models: [] };
const collected: Promise<Outcome> = collectBoardModelFiles(
new TextDecoder().decode(board),
6,
controller.signal,
progress,
).then(
(models) => ({ kind: "ready", models }),
(error) => ({ kind: "failed", error }),
@ -327,25 +333,29 @@ export function installOccService(
const outcome = await Promise.race([collected, deadline]);
if (timer !== undefined) clearTimeout(timer);
if (outcome.kind === "ready") return outcome.models;
if (outcome.kind === "ready") return { models: outcome.models };
if (!controller.signal.aborted) {
controller.abort(
new DOMException("OCC model prefetch retired", "AbortError"),
);
}
if (outcome.kind === "failed") {
log(`[occ] model prefetch failed (exporting without models): ${outcome.error}`);
} else {
log(
`[occ] model prefetch timed out after ${modelPrefetchTimeoutMs} ms ` +
"(exporting without models)",
);
const note =
`model prefetch failed — exported without models: ${String(outcome.error)}`;
log(`[occ] ${note}`);
return { models: [], note };
}
return [];
const models = [...progress.models];
const note =
`model prefetch timed out after ${modelPrefetchTimeoutMs} ms — ` +
`${progress.totalRefs - models.length} of ${progress.totalRefs} model(s) omitted`;
log(`[occ] ${note}`);
return { models, note };
};
const request = async (req: OccRequest): Promise<OccResponse> => {
let prepared: OccRequest;
let prefetchNote: string | undefined;
if (req.kind === "export") {
// Capture the caller-owned request fields before the first await and
// build a private dispatch object. A late optional prefetch can then
@ -356,8 +366,10 @@ export function installOccService(
// Ship the board's lib model bodies with the request: the worker's
// EXPORTER_STEP resolves them from its own MEMFS (delivery gap doc:
// docs/features/3d-models/0007). Best-effort — an export without
// models still succeeds, each miss reported by the exporter.
const models = await prefetchBoardModels(board);
// models still succeeds, each miss reported by the exporter — but a
// curtailed prefetch is surfaced in the export report (E-21).
const { models, note } = await prefetchBoardModels(board);
prefetchNote = note;
if (models.length)
log(`[occ] shipping ${models.length} board model(s) with the export`);
prepared = { kind: "export", board, jobJson, fileName, models };
@ -387,7 +399,12 @@ export function installOccService(
downloadBytes(name, res.bytes);
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
}
return { ok: res.ok, report: res.report, fileName: res.fileName };
// A curtailed prefetch reaches the user through the export report
// dialog, not only the console.
const report = prefetchNote
? (res.report ? `${res.report}\n${prefetchNote}` : prefetchNote)
: res.report;
return { ok: res.ok, report, fileName: res.fileName };
}
return res;

View file

@ -61,6 +61,9 @@ function loadShim(opts: { busy: () => boolean }) {
g.Module = {
kicadOpenFileBusy: opts.busy,
kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`,
// Headless stack ops so parked waits and the resume pump run under vitest.
stackSave: () => 0,
stackRestore: () => {},
};
// eslint-disable-next-line no-eval
(0, eval)(readFileSync(SHIM_PATH, "utf8"));
@ -312,13 +315,18 @@ describe("E-8: runWaitCompletion admission gate for worker completions", () => {
}
});
it("classifies cross-realm trap strings as terminal too", () => {
it("classifies a realm-crossed trap by its RuntimeError name", () => {
// An error object relayed across a realm loses its instanceof identity
// but keeps its name. (The old message-substring sniff is gone — see the
// false-positive gate below.)
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const token = S.beginWait("occ");
const crossed = new Error("unreachable");
crossed.name = "RuntimeError";
S.runWaitCompletion("cross-realm trap", token, () => {
throw new Error("RuntimeError: unreachable");
throw crossed;
});
expect(S.terminal).toBe(true);
expect(S.waitEarlyResolved(token)).toBe(0);
@ -327,6 +335,89 @@ describe("E-8: runWaitCompletion admission gate for worker completions", () => {
}
});
it("a plain error QUOTING trap text does not terminalize (E-14)", async () => {
// The old classifier matched message substrings ('Aborted(', 'index out
// of bounds', …) — any plain JS error whose text merely QUOTED such
// wording permanently bricked a healthy instance. Structural signals
// only: RuntimeError instance or name.
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const token = S.beginWait("ngspice");
expect(
S.runWaitCompletion("relay bug", token, () => {
throw new Error(
"copy failed: memory access out of bounds in worker payload (Aborted(…))",
);
}, 1),
).toBe(false);
expect(S.terminal, "a message-only match must not brick the instance").toBe(false);
expect(S.canTouchNative()).toBe(true);
await expect(S.waitPromise(token), "the wait fails with inertResult").resolves.toBe(1);
} finally {
err.mockRestore();
}
});
it("bare resolveWait after a terminal latch does not resume the parked waiter (E-15)", async () => {
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const token = S.beginWait("3d");
let settled = false;
void S.waitPromise(token).then(() => {
settled = true;
});
const trapToken = S.beginWait("occ");
S.runWaitCompletion("trap", trapToken, () => {
throw new WebAssembly.RuntimeError("unreachable");
});
expect(S.terminal).toBe(true);
// The ten bare finishers (fontenum/clipboard/3d/fp-lib/…) all route
// through resolveWait — on a terminal instance it must refuse WITHOUT
// consuming the entry (the frame stays visibly parked in dump()).
expect(S.resolveWait(token, 7), "bare resolve refused on terminal").toBe(false);
expect(S.pendingWaits("3d"), "the entry is not consumed").toBe(1);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled, "the parked frame must not resume into the trapped module")
.toBe(false);
} finally {
err.mockRestore();
warn.mockRestore();
}
});
it("a wake already queued when terminal latches is never delivered (E-15)", async () => {
const S = loadShim({ busy: () => false });
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const token = S.beginWait("sleep");
let settled = false;
void S.waitPromise(token).then(() => {
settled = true;
});
// Resolve on a HEALTHY instance — the wake is now queued behind a
// microtask — then latch terminal before the pump can run it.
expect(S.resolveWait(token, 1)).toBe(true);
const trapToken = S.beginWait("occ");
S.runWaitCompletion("trap", trapToken, () => {
throw new WebAssembly.RuntimeError("unreachable");
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled, "the queued wake must not re-enter the trapped module")
.toBe(false);
} finally {
err.mockRestore();
warn.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(() => {});

View file

@ -0,0 +1,57 @@
import { expect, vi } from "vitest";
/**
* Shared Worker test double for the service-lifetime suites (one copy —
* previously duplicated verbatim in occ-service.test.ts and
* ngspice-service.test.ts, where a fix to its event semantics applied to one
* file left the other suite validating different fake-worker behavior).
*
* Honors J-4: no synthetic `dispatchEvent` — emit* invoke the exact functions
* the service assigned to the handler attributes.
*
* (Not named *.test.ts: the vitest include glob must not collect it.)
*/
export type MessageListener = (event: MessageEvent) => void;
export class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: MessageListener | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
onmessageerror: ((event: MessageEvent) => void) | null = null;
readonly postMessage = vi.fn();
readonly terminate = vi.fn();
private readonly messageListeners = new Set<MessageListener>();
constructor() {
FakeWorker.instances.push(this);
}
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
if (type === "message") this.messageListeners.add(listener as MessageListener);
}
removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
if (type === "message") this.messageListeners.delete(listener as MessageListener);
}
emitMessage(data: unknown): void {
const event = { data } as MessageEvent;
this.onmessage?.(event);
for (const listener of [...this.messageListeners]) listener(event);
}
emitError(message: string): void {
this.onerror?.({ message } as ErrorEvent);
}
emitMessageError(): void {
this.onmessageerror?.({} as MessageEvent);
}
}
/** Await the service's Nth Worker construction. */
export async function waitForWorker(index: number): Promise<FakeWorker> {
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
return FakeWorker.instances[index]!;
}