pcbjam/tests/tools/service-stub-parity.ts
Istvan Matejcsok c421d724b0 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>
2026-08-31 18:19:16 +02:00

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");