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:
parent
0f21a33216
commit
c421d724b0
48 changed files with 2071 additions and 694 deletions
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
57
web/standalone/src/wasm/test-utils/fake-worker.ts
Normal file
57
web/standalone/src/wasm/test-utils/fake-worker.ts
Normal 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]!;
|
||||
}
|
||||
Loading…
Reference in a new issue