pcbjam/tests/tools/findings-e-source-contract.ts

207 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Source-contract tripwire for the findings group E fixes that live in C++
* (EM_JS bridges and KiCad simulator code) and therefore cannot be
* behaviorally unit-tested without a full wasm build. Same style as the codex
* thread's contract tools: read the sources, assert the load-bearing tokens
* are present (and the reverted shapes absent), fail loudly with the finding
* ID. Run: npm run findings-e:contract
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, "../..");
const read = (rel: string) => readFileSync(path.join(repo, rel), "utf8");
const sharedspice = read("wasm/stubs/sharedspice_client.cpp");
const exporterStub = read("wasm/stubs/exporter_step_stub.cpp");
const oceStub = read("wasm/stubs/oce_plugin_stub.cpp");
const simFrame = read("kicad/eeschema/sim/simulator_frame.cpp");
const ngspiceCpp = read("kicad/eeschema/sim/ngspice.cpp");
const shim = read("scripts/common/shims/jspi-scheduler.js");
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-27 12:27:12 +02:00
// --- structural #ifdef scanner ----------------------------------------------
// The wasm-only-confinement guarantees are asserted on CODE STRUCTURE (is this
// statement lexically inside an `__EMSCRIPTEN__`-conditioned region?), never on
// comment text: a comment-string contract fails on rewording with no behavior
// change and passes when the guard moves outside the ifdef but the comment
// stays — the exact regression it exists to catch.
type Cond = "em" | "not-em" | "other";
function emscriptenLineMap(src: string): boolean[] {
const stack: Cond[] = [];
return src.split("\n").map((line) => {
const t = line.trim();
let m: RegExpMatchArray | null;
if ((m = t.match(/^#\s*ifdef\s+(\w+)/))) {
stack.push(m[1] === "__EMSCRIPTEN__" ? "em" : "other");
} else if ((m = t.match(/^#\s*ifndef\s+(\w+)/))) {
stack.push(m[1] === "__EMSCRIPTEN__" ? "not-em" : "other");
} else if ((m = t.match(/^#\s*if\b(.*)/))) {
const cond = m[1];
const negated = /!\s*defined\s*\(?\s*__EMSCRIPTEN__/.test(cond);
const positive = /defined\s*\(?\s*__EMSCRIPTEN__/.test(cond) && !negated;
stack.push(positive ? "em" : negated ? "not-em" : "other");
} else if (/^#\s*(else|elif)\b/.test(t)) {
const top = stack[stack.length - 1];
if (top === "em") stack[stack.length - 1] = "not-em";
else if (top === "not-em") stack[stack.length - 1] = "em";
} else if (/^#\s*endif\b/.test(t)) {
stack.pop();
}
return stack.includes("em");
});
}
/** Line indexes (0-based) of every occurrence of `needle` in `src`. */
function occurrenceLines(src: string, needle: string): number[] {
const out: number[] = [];
src.split("\n").forEach((line, i) => {
if (line.includes(needle)) out.push(i);
});
return out;
}
function assertOccurrences(
src: string,
map: boolean[],
needle: string,
expect: { total: number; insideEm: number },
label: string,
): void {
const lines = occurrenceLines(src, needle);
assert.equal(lines.length, expect.total,
`${label}: expected ${expect.total} occurrence(s) of "${needle}", found ${lines.length}`);
const inside = lines.filter((i) => map[i]).length;
assert.equal(inside, expect.insideEm,
`${label}: ${inside} of ${lines.length} occurrence(s) of "${needle}" are inside an `
+ `__EMSCRIPTEN__ region, expected ${expect.insideEm}`);
}
// --- E-5: ngspice event handler bound to exact module identity --------------
assert.ok(sharedspice.includes("const installingModule = Module"),
"E-5: js_ngspice_install_events must capture the installing module");
assert.ok((sharedspice.match(/__pcbjamNgspiceOwnerModule/g) ?? []).length >= 2,
"E-5: the handler must be stamped AND compared by owner module identity");
assert.ok(sharedspice.includes("globalThis.__ngspiceOnEvent !== handler"),
"E-5: a superseded handler must disarm itself");
assert.ok(!/if\(\s*globalThis\.__ngspiceOnEvent\s*\)/.test(sharedspice),
"E-5 REGRESSION: the install-once presence guard is back — presence is not identity");
assert.ok(sharedspice.includes("canTouchNative"),
"E-5/E-8: event dispatch must check the scheduler liveness gate");
// --- E-8: all four completion sites route native work through the gate ------
for (const [name, src, site] of [
["exporter_step_stub.cpp", exporterStub, "'OCC export completion'"],
["oce_plugin_stub.cpp", oceStub, "'OCC model completion'"],
["sharedspice_client.cpp", sharedspice, "'ngspice request completion'"],
["sharedspice_client.cpp", sharedspice, "'ngspice vector completion'"],
] as const) {
assert.ok(src.includes(`runWaitCompletion( ${site}`),
`E-8: ${name} must run its ${site} through runWaitCompletion`);
}
for (const [name, src] of [
["exporter_step_stub.cpp", exporterStub],
["oce_plugin_stub.cpp", oceStub],
["sharedspice_client.cpp", sharedspice],
] as const) {
assert.ok(!/__wxScheduler\.resolveWait\(/.test(src),
`E-8 REGRESSION: ${name} resolves a wait directly, bypassing the admission gate`);
assert.ok(/if\(\s*token\s*<=\s*0\s*\)/.test(src),
`E-8: ${name} must bail when wxWasmBeginWait refuses the token`);
}
for (const symbol of ["runWaitCompletion", "_terminalizeNativeTrap",
"canTouchNative", "beginWaitRefused"]) {
assert.ok(shim.includes(symbol),
`E-8: jspi-scheduler.js must provide ${symbol}`);
}
// --- E-7: per-session run generation, behavioral drops wasm-only ------------
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-27 12:27:12 +02:00
const simMap = emscriptenLineMap(simFrame);
// The acceptance guards (onSimStarted entry, onSimFinished entry, and the
// post-wxYield re-check) are the three `generation != m_simRunGeneration`
// comparisons — every one must sit inside an __EMSCRIPTEN__ region.
assertOccurrences(simFrame, simMap, "generation != m_simRunGeneration",
{ total: 3, insideEm: 3 }, "E-7 acceptance guards");
// The unowned-event drop.
assertOccurrences(simFrame, simMap, "delete event;",
{ total: 1, insideEm: 1 }, "E-7 unowned-event drop");
// The bookkeeping stays UNGUARDED by design (inert on native — every reader
// is guarded): the generation allocator and the event stamping.
assertOccurrences(simFrame, simMap, "= allocateSimRunGeneration()",
{ total: 1, insideEm: 0 }, "E-7 bookkeeping (allocator call)");
assertOccurrences(simFrame, simMap, "SetExtraLong",
{ total: 1, insideEm: 0 }, "E-7 bookkeeping (event stamping)");
assert.ok(simFrame.includes("s_nextSimRunGeneration")
&& simFrame.includes("m_lastAppliedSimRunGeneration"),
"E-7: simulator_frame.cpp must carry the run-generation mechanism");
// The final-refresh receipt lives at the right altitude: one ifdef'd call in
// kicad, the JS hook knowledge in the stub layer.
assertOccurrences(simFrame, simMap, "pcbjam_sim_run_applied( generation )",
{ total: 1, insideEm: 1 }, "E-7 receipt call");
assert.ok(!simFrame.includes("__pcbjamNgspiceFinalRefreshApplied"),
"E-7 REGRESSION: the harness hook name is back inside kicad source — it belongs "
+ "to wasm/stubs/sharedspice_client.cpp");
assert.ok(sharedspice.includes("__pcbjamNgspiceFinalRefreshApplied"),
"E-7: sharedspice_client.cpp must implement the final-refresh receipt hook");
// --- E-12: crash-exit IDLE before RUNNING consumes the pending token --------
// `generation = m_pendingRunGeneration.exchange` appears twice: the RUNNING
// consumption (unguarded bookkeeping) and the IDLE crash-exit fallback
// (behavioral — wasm-only).
assertOccurrences(simFrame, simMap, "generation = m_pendingRunGeneration.exchange",
{ total: 2, insideEm: 1 }, "E-12 IDLE pending fallback");
// --- E-13: a failed launch withdraws its token and resets the busy state ----
assertOccurrences(simFrame, simMap, "m_reporter->SetRunGeneration( 0 )",
{ total: 1, insideEm: 1 }, "E-13 failed-launch reset");
// --- E-11: get_vec clamps v_length to the transferred arrays + frees on fail -
assert.ok(sharedspice.includes("length = Math.min( length, nComp >> 1 )"),
"E-11: the vector prepare must clamp v_length to the transferred arrays");
assert.ok(/std::free\( vname \);\s*\n\s*std::free\( real \);\s*\n\s*std::free\( comp \);/
.test(sharedspice),
"E-11: pcbjam_ngGet_Vec_Info must free the prepare's buffers on every failure path");
// --- E-16: the event handler gates on its INSTALLING module's scheduler -----
assert.ok(sharedspice.includes("const installingScheduler = globalThis.__wxScheduler"),
"E-16: js_ngspice_install_events must capture the installing scheduler");
// --- E-15: every wxWasmBeginWait caller bails on a refused token -------------
// (The three worker stubs are asserted in the E-8 block above.)
for (const [rel, expectedBegins] of [
["wxwidgets/src/wasm/fontenum.cpp", 1],
["wxwidgets/src/wasm/clipbrd.cpp", 4],
["wxwidgets/src/wasm/dialog.cpp", 1],
["wxwidgets/src/wasm/evtloop.cpp", 1],
["kicad/3d-viewer/3d_cache/pcbjam_model_fetch.cpp", 1],
["kicad/pcbnew/pcb_io/pcbjam_fp/pcb_io_pcbjam_fp.cpp", 1],
["kicad/eeschema/sch_io/pcbjam_lib/sch_io_pcbjam_lib.cpp", 1],
] as const) {
const src = read(rel);
const begins = (src.match(/=\s*wxWasmBeginWait\s*\(/g) ?? []).length;
const guards = (src.match(/if\s*\(\s*(?:token|waitToken)\s*<=\s*0\s*\)/g) ?? []).length;
assert.equal(begins, expectedBegins,
`E-15: ${rel} should mint ${expectedBegins} wait token(s), found ${begins}`);
assert.ok(guards >= begins,
`E-15: ${rel} has ${begins} wxWasmBeginWait call(s) but only ${guards} `
+ "token<=0 guard(s) — a refused token must never start its request");
}
for (const symbol of ["terminalize", "resolveRefused"]) {
assert.ok(shim.includes(symbol),
`E-14/E-15: jspi-scheduler.js must provide ${symbol}`);
}
// --- E-9: destructor unregisters the sharedspice callbacks ------------------
assert.ok(/#ifdef __EMSCRIPTEN__[\s\S]{0,400}pcbjam_ngspice_reset_callbacks\( this \)/
.test(ngspiceCpp),
"E-9: ~NGSPICE must call pcbjam_ngspice_reset_callbacks(this) under __EMSCRIPTEN__");
assert.ok(/pcbjam_ngspice_reset_callbacks\( void\* aUser \)[\s\S]{0,200}s_user != aUser/
.test(sharedspice),
"E-9: the reset must be identity-checked so a stale destructor cannot clear a successor");
console.log("findings-e-source-contract: all green");