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>
207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
/**
|
|
* 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");
|
|
|
|
// --- 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 ------------
|
|
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");
|