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>
94 lines
4.8 KiB
TypeScript
94 lines
4.8 KiB
TypeScript
/**
|
|
* Stub/production parity tripwire (findings group E guardrail). The e2e
|
|
* harness drives hand-maintained MIRRORS of the worker services
|
|
* (tests/kicad/utils/{ngspice,occ}-service.ts) while production ships
|
|
* web/standalone/src/wasm/{ngspice,occ}-service.ts — a fix landed in one copy
|
|
* and not the other silently invalidates what the browser specs claim to
|
|
* prove. Until the copies collapse into one shared lifecycle module (the
|
|
* deferred refactor), this tool pins the load-bearing invariants both sides
|
|
* must share, parsing ACTUAL VALUES — never comment text.
|
|
* Run: npm run findings-e:parity
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import {
|
|
ngspiceWorkerConstants,
|
|
parseConstants,
|
|
readRepoFile,
|
|
} from "./lib/worker-constants.js";
|
|
|
|
const prodNgspice = readRepoFile("web/standalone/src/wasm/ngspice-service.ts");
|
|
const prodOcc = readRepoFile("web/standalone/src/wasm/occ-service.ts");
|
|
const stubNgspice = readRepoFile("tests/kicad/utils/ngspice-service.ts");
|
|
const stubOcc = readRepoFile("tests/kicad/utils/occ-service.ts");
|
|
const bootTs = readRepoFile("web/standalone/src/wasm/boot.ts");
|
|
const workerJs = readRepoFile("web/standalone/src/wasm/ngspice-worker.js");
|
|
|
|
// --- credit window: worker ≡ production host ≡ harness stub -----------------
|
|
// The host queue caps must EQUAL the worker's credit window or the protocol
|
|
// retires healthy workers ("event-frame queue exceeded credit").
|
|
const worker = ngspiceWorkerConstants();
|
|
const hostCaps = ["MAX_QUEUED_EVENT_FRAMES", "MAX_QUEUED_EVENT_BYTES"] as const;
|
|
const prodCaps = parseConstants(prodNgspice, hostCaps, "production ngspice-service.ts");
|
|
const stubCaps = parseConstants(stubNgspice, hostCaps, "stub ngspice-service.ts");
|
|
assert.equal(prodCaps.MAX_QUEUED_EVENT_FRAMES, worker.MAX_EVENT_UNACKED_FRAMES,
|
|
"credit window FRAMES: production host must equal the worker");
|
|
assert.equal(prodCaps.MAX_QUEUED_EVENT_BYTES, worker.MAX_EVENT_UNACKED_UTF8_BYTES,
|
|
"credit window BYTES: production host must equal the worker");
|
|
assert.deepEqual(stubCaps, prodCaps,
|
|
"credit window: the harness stub must equal the production host");
|
|
|
|
// --- E-19: the frame ack survives a throwing handler (both copies) ----------
|
|
for (const [label, src] of [
|
|
["production ngspice-service.ts", prodNgspice],
|
|
["stub ngspice-service.ts", stubNgspice],
|
|
] as const) {
|
|
const start = src.indexOf("const dispatchEvt");
|
|
const end = src.indexOf("deliverTerminalEvents", start);
|
|
assert.ok(start >= 0 && end > start, `${label}: dispatchEvt body not found`);
|
|
const body = src.slice(start, end);
|
|
assert.ok(/finally\s*\{[\s\S]{0,80}?ackEvent\(/.test(body),
|
|
`E-19 REGRESSION (${label}): dispatchEvt must ack the owned frame in a `
|
|
+ "finally — a throwing handler leaked one credit unit per throw");
|
|
}
|
|
|
|
// --- E-20: the terminal notice's pendingEvents are consumed (both copies) ---
|
|
for (const [label, src] of [
|
|
["production ngspice-service.ts", prodNgspice],
|
|
["stub ngspice-service.ts", stubNgspice],
|
|
] as const) {
|
|
assert.ok(src.includes("deliverTerminalEvents(data.pendingEvents)"),
|
|
`E-20 (${label}): the fatal branch must deliver the worker's accepted-`
|
|
+ "prefix pendingEvents before retiring");
|
|
}
|
|
assert.ok(workerJs.includes("postMessage({ fatal: reason, pendingEvents })"),
|
|
"E-20 (ngspice-worker.js): the terminal notice must carry the deferred frames");
|
|
|
|
// --- E-10: retirement synthesizes the controlled exit (both copies) ---------
|
|
assert.ok(/kind: "exit", status: 1, immediate: true, quit: false/.test(prodNgspice),
|
|
"E-10 (production): retireWorker must synthesize the controlled exit");
|
|
assert.ok(/kind: 'exit', status: 1, immediate: true, quit: false/.test(stubNgspice),
|
|
"E-10 (stub): retireWorker must synthesize the controlled exit");
|
|
|
|
// --- E-10 recovery: the worker guards pre-init engine access ----------------
|
|
assert.ok(workerJs.includes("let engineReady") && workerJs.includes("ensureEngine("),
|
|
"E-10 (ngspice-worker.js): pre-init reads must answer empty shapes and "
|
|
+ "writes must lazy-init the fresh engine");
|
|
|
|
// --- E-22 / E-1: a boot deadline exists in all four lifecycle copies --------
|
|
for (const [label, src] of [
|
|
["production ngspice-service.ts", prodNgspice],
|
|
["production occ-service.ts", prodOcc],
|
|
["stub ngspice-service.ts", stubNgspice],
|
|
["stub occ-service.ts", stubOcc],
|
|
] as const) {
|
|
assert.ok(src.includes("bootTimer") && src.includes("boot timed out after"),
|
|
`E-22 REGRESSION (${label}): the boot deadline is gone — a wedged worker `
|
|
+ "boot hangs every request with zero evidence");
|
|
}
|
|
|
|
// --- E-14: boot wires Module.onAbort to the scheduler's terminal latch ------
|
|
assert.ok(/onAbort[\s\S]{0,600}?terminalize\?\.\(\s*"emscripten abort"/.test(bootTs),
|
|
"E-14 (boot.ts): Module.onAbort must latch __wxScheduler.terminalize — the "
|
|
+ "authoritative abort notification");
|
|
|
|
console.log("service-stub-parity: all green");
|