Skip to content

findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work - #40

Open
matejcsok-pcb wants to merge 6 commits into
stagingfrom
findings-group-e
Open

matejcsok-pcb wants to merge 6 commits into
stagingfrom
findings-group-e

Conversation

@matejcsok-pcb

Copy link
Copy Markdown

A code review of the group-E fixes (E-1..E-9, landed 2026-08-19) found 13 further defects. Ten were introduced by those fixes, two pre-existed and were only relocated, one is deferred. Sibling PRs: wxWidgets#5, kicad-source-mirror#9, pcbjam-private.

Services / transport

E-10 — Run died for the whole session after a worker death. retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning mirror stayed latched true: ENABLE(!simRunning) kept Run disabled and the promised fresh-worker restart was unreachable. Retirement now dispatches a synthetic controlled-exit straight to the installed handler — never through dispatchEvt, since a fabricated frame must not touch the credit ledger.

Driving that repro end to end 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 — one throw leaked one credit unit. dispatchEvt acked only after handler(evt) returned, and the sharedspice client deliberately rethrows non-trap errors — so each throw leaked a frame of the 64-frame window until the stream died with a misattributed "transport exceeded". The ack moves to a finally in both copies; the throw still propagates, because the trap machinery needs to see it.

E-20 — the terminal stop discarded the diagnostics. 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 very lines explaining the failure. The fatal notice now carries them as pendingEvents; both hosts deliver them in order, unacked (the fatal frame sits outside the credit protocol).

E-21 — the prefetch timeout was all-or-nothing and silent. The 30 s 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 isn't 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 — a message substring could brick a healthy instance. _terminalizeNativeTrap classified by text, so any plain JS error quoting Aborted( or out of bounds latched the one-way terminal gate permanently. 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, so the message arm bought nothing. Module.onAbort now latches the gate; it was previously ignored entirely.

E-15 (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 in dump() 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 — a corrupted worker answer killed the editor. get_vec trusted res.length over the transferred arrays. Observed death shape: a 4 GiB std::vector threw an unhandled std::length_error that exited the main loop — nastier than the predicted OOB trap. Now clamped, with buffers freed on every failure path.

Guardrails

Two review recommendations were deferred by maintainer decision — making the e2e suite drive production service code instead of the stub mirrors, and collapsing the four copies of the worker-lifecycle machinery. These stand in:

  • service-stub-parity.ts pins what all four copies must share: credit-window equality parsed from source, the finally-ack, boot deadlines, terminal-notice consumption. Transport numbers are single-sourced from the worker.
  • E-18 — the source contract asserted comment-string counts, so rewording failed CI while moving a guard outside its #ifdef passed. It now parses #ifdef regions and asserts on code.
  • 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 60 s boot watchdog, deleted in a66e109, restored with a wedgeNextBoot() repro hook.

Deferred

E-17 — a stale RUNNING cross-stamping the next run's generation under E-6's transport deferral. Analysis recorded; a real fix needs run identity on the bg frames. Explicitly not "not-a-bug".

Verification

Every behavioral fix has red-then-green evidence, reds captured first. Vitest 404/404; reducer 9/9; source contract and parity tool green with false-green audits in both directions; clean kicad suite (both engines) 348 passed / 1 pre-existing marginal dark-mode rendering-ratio failure (0.2004 vs 0.2; nothing here touches rendering).

Live-collab gate passed on the real dev stack: a real UI tool dispatch moved a board item exactly 5 mm, a dialog parked the opener on an E-15-guarded nested wait and resolved cleanly on Cancel, and both clients stayed non-terminal with clean rings and zero parked waits across all nine kinds.

Note for CI: wx-chromium, jspi-firefox and coroutine-firefox cannot run locally in this worktree — the standalone wx test apps aren't built here — so CI is their first real coverage.

🤖 Generated with Claude Code

matejcsok-ee and others added 6 commits August 19, 2026 15:48
…rror + abortable model prefetch

Adapted from codex/asyncify-execution-owner-core 3753320 (scheduler-free on
that branch already; one comment line re-worded for the JSPI line):

- E-1: generation-slotted WorkerSlot with a 2-min boot watchdog (armed before
  resolveWasmBase, so a hung delivery lookup expires too) and a 30-min
  per-request response watchdog; retireWorker() is the single idempotent
  funnel (fail that generation's pendings, terminate, revoke the worker Blob
  URL, clear timers/listeners).
- E-2: worker.onerror is wired for the worker's whole life and settles every
  in-flight STEP/export request; a synchronous postMessage throw settles its
  request without leaking the pending id; late frames from a retired
  generation are inert.
- E-3: worker.onmessageerror retires the generation like error does.
- E-4: collectBoardModelFiles is a pure source/IDB/network path (no editor
  MEMFS round-trip) taking an AbortSignal checked at every loop head;
  prefetchBoardModels races it against a 30 s deadline — timeout is non-fatal
  (export proceeds without models) and late results are inert.

Tests: occ-service.test.ts (7, ported) — boot/response watchdog expiry,
crash-settles-all, bootError retry, decode-fault retirement (invokes the real
onmessageerror transition, per J-4), hung-prefetch export; models-bridge.test.ts
+3 — abort inertness, zero FS access on the collect path. e2e harness twin
updated to the same generation shape (adds __occServiceTestHooks/failDecode).
False-green audited: 19 cases fail with the fixes reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed event transport

Adapted from codex/asyncify-execution-owner-core 3753320. Service side mirrors
the occ-service shape (E-1 watchdogs, E-2 fail-all + boot-death fix — onerror
now rejects the in-flight boot waiter instead of stranding it, E-3
onmessageerror terminal, Blob URL revoked, per-generation evtQueue cleared on
retirement).

E-6 transport bounds (worker hunks re-applied inside the emscripten-6
em-pthread else-branch — the codex file predates that split, so this is a
re-application, not a cherry-pick):
- batch cut at 512 lines / 1 MiB exact JSON-UTF-8 bytes, measured before a
  line is retained; a single line > 1 MiB flushes the accepted prefix then
  stops the event stream terminally (never retained);
- posting gated by a 64-frame / 8 MiB unacked credit window; each frame
  carries { eventSequence, eventBytes } and is released only by an exact
  { sequence, bytes } ack; any mismatched ack is terminal;
- the service mirrors the same 64-frame / 8 MiB bound on its pre-handler
  queue, acks after handing a frame to __ngspiceOnEvent, and retires the
  generation on invalid credit; { fatal } frames retire the worker.

Tests: ngspice-service.test.ts (11, ported) — watchdogs, crash/bootError/
decode-fault settlement + recovery, out-of-order ids, sync postMessage throw,
stale-generation event drops, fatal-frame retirement. tests/tools/
ngspice-worker-batch-unit.ts (node:vm over the production worker source;
`npm run ngspice:worker-batch`) — bounded ordered chunks, byte-pressure
flush, 100k-chunk credit storm, over-limit line, exact ack lease. e2e harness
twin updated to speak the ack protocol (adds __ngspiceServiceTestHooks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion admission gate

E-8 (re-implemented for JSPI — the codex gate is entangled with the dropped
execution owner; under JSPI a fresh non-suspending JS→wasm entry while
another activation is suspended is structurally safe on its own stack, so
the admission boundary for worker completions is liveness + trap state, not
execution ownership):
- jspi-scheduler.js grows `terminal` (trapped instance; distinct from `dead`),
  canTouchNative(), _terminalizeNativeTrap() (WebAssembly.RuntimeError +
  cross-realm string classification), and runWaitCompletion(site, token,
  prepare, inertResult): prepare runs immediately and owns ALL native work;
  stale tokens and dead/terminal instances drop loudly without resolving
  (resolving would resume the parked frame inside the damaged module); a
  trap latches terminal; a plain JS bug resolves inertResult so the wait
  fails instead of stranding. beginWait refuses (token 0) when dead/terminal.
- all four delayed completion sites route their native work through the
  gate: 'OCC export completion' (exporter_step_stub), 'OCC model completion'
  (oce_plugin_stub — the MEMFS cache write moves inside the gate too),
  'ngspice request completion' and 'ngspice vector completion'
  (sharedspice_client — every HEAP32/HEAPF64/malloc write inside prepare,
  inertResult 1 = transport error). Every wxWasmBeginWait caller in the
  stubs bails on token <= 0.
- deliberately NOT ported from codex: ownerModule, enqueueNativeCompletion,
  executionBarrier, the byte-credit native-entry FIFO — completions are
  one-shot per wait token and stream volume is bounded at the E-6 transport
  credit window. Cross-refs logged for group M (M-2/M-6/M-8).

E-5 (re-implemented; codex shape kept, owner APIs replaced with the E-8
gate): js_ngspice_install_events binds the handler to the EXACT installing
module (handler.__pcbjamNgspiceOwnerModule stamp; presence is not identity),
re-installation replaces a foreign module's handler, a superseded handler
disarms itself, native entry goes through installingModule._malloc/
._pcbjam_ngspice_event (never lexical Module), each dispatch checks
canTouchNative() (loud drop on a dead/terminal module), and a trap on the
per-line entry latches the terminal gate.

Tests: scheduler-shim.test.ts +7 (gate happy/stale/dead/terminal/cross-realm/
js-bug/beginWait-refusal). e2e specs updated from the codex line: occ-export
decode-fault recovery (real onmessageerror transition via failDecode, J-4),
ngspice-probe direct-service coverage, eeschema-sim rewritten onto the E-7
applied-generation receipt (codex's executionBarrier await replaced with a
pendingWaits('ngspice') drain poll — the JSPI-line equivalent).

Also bumps the kicad submodule to the E-7/E-9 commit (dd5751038f7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rted shapes

E-5: the module-identity bridge called installingModule._malloc, but this
build exposes _malloc only as a bare glue-closure export (Module._malloc is
absent) — every char/stat event entry threw TypeError, which also starved
the E-6 credit window (thrown dispatches never acked) and wedged the queued
bg-finished frame behind them. The bridge now uses the bare closure exports
(identity is still exact: the EM_JS body IS the installing module's closure;
the __ngspiceOnEvent self-disarm covers supersession).

E-6 (codex reference design corrected — its validation matrix never ran):
a FULL credit window was terminal (stopEventStream at 64 in-flight frames).
Under live e2e that killed a real simulation: bg-thread emissions proxy one
per task, so each line ships as its own frame and a normal transient outruns
a busy main thread. A full window now DEFERS into a bounded FIFO (512 events
/ 4 MiB) drained in order as acks free credit; only true overload or an
invalid ack is terminal. Retention stays bounded (8 MiB in flight + 4 MiB
deferred + 1 MiB open batch). And the service/harness mirror queue now acks
at ENQUEUE — placing a frame in the bounded pre-handler queue is taking
ownership; without that, a stream starting before the C++ handler installs
(the ngspice-probe page) starves the worker window forever.

Test updates: worker-batch reducer — new "a full credit window defers and
drains in order, never terminal" case pinning the regression; the storm case
now proves the deferred caps are the terminal edge. board-ready.ts gains the
owner-free openBoardProgrammatically (codex helper the ported occ-export
spec needs; the barrier-based waitForUiBoardReady was NOT taken).
occ-export.spec: domId is optional on this line's registry (coordinate
fallback is the supported path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fdef bump

Coverage audit found three fixes without a test that could catch their
reversion:

- E-6 ack-on-enqueue: new unit case "acks a pre-handler queued frame at
  enqueue so the transport window never starves" (ngspice-service.test.ts;
  false-green audited — fails with the ack removed, exactly one ack per
  frame, none repeated on drain).
- E-5 / E-8 wiring / E-9 / E-7 shape: new source-contract tripwire
  tests/tools/findings-e-source-contract.ts (npm run findings-e:contract) —
  the codex-thread contract style for C++/EM_JS code that cannot be
  behaviorally unit-tested without a wasm build. Asserts: the module
  identity stamp + self-disarm and ABSENCE of the install-once presence
  guard (E-5); all four completion sites use runWaitCompletion, no stub
  resolves a wait directly, token<=0 bails, the shim exports the gate
  surface (E-8); the run-generation mechanism present with its behavioral
  drops confined to the wasm build (E-7); the identity-checked destructor
  unregistration (E-9).

Also bumps kicad to the E-7 ifdef-confinement commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
….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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants