fix(ci+esp32): unblock backend e2e + bump frontend node heap

Two CI failures landed after PR #196 (esp32-gpio-matrix-cb-callback)
merged. Both are independent and fixed here together.

1) **Backend E2E: ESP32 hangs at bootloader handoff.**
   PR #196 added picsimlab_gpio_matrix_cb which fires on QEMU's
   iothread. The handler did `_emit({...})` for every routing
   change — and the ESP-IDF bootloader writes to gpio_out_sel
   *hundreds* of times during early boot (each peripheral init
   configures its matrix slot). Each emit acquires _stdout_lock
   and writes to the worker→manager pipe. If the manager drains
   even briefly slow, the pipe fills, write blocks, and the
   iothread stalls — symptom: ESP32 reports `entry 0x400805e4`
   then no Arduino setup() output for 75 s.

   Fix: the iothread callback now ONLY mutates the SignalRouter
   snapshot. It never emits. The 10 Hz poll thread
   (_refresh_signal_routing) stays as the sole emitter, so the
   wire-format event stream is unchanged. Benefit of having the
   callback over poll-only is reduced worst-case routing-emit
   latency (next poll tick vs up to 100 ms) and a warmer
   snapshot dict for cheaper poll diffs.

2) **Frontend Tests: Node OOM at end of suite.**
   117 test files run in one forks-pool worker. Several lazy-load
   the ngspice emscripten module (~30 MB), the MixedModeScheduler
   singleton, and other heavy modules whose dispose hooks aren't
   reached because singletons leak across files. Cumulative heap
   pressure exceeds Node's 4 GB default; the worker hits "Ineffective
   mark-compacts near heap limit" AFTER all 1881 tests pass and
   the OOM kill is reported by vitest as "Worker exited unexpectedly
   / Timeout terminating forks worker". This is not a real test
   failure — every individual test passes.

   Quick fix: pass NODE_OPTIONS=--max-old-space-size=8192 to the
   `npm test` step. Long-term, the singletons should add dispose
   hooks that test fixtures call in afterAll(), or the suite
   should shard into multiple `vitest run --shard` invocations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-19 16:04:50 +02:00
parent be55c97cce
commit cfde1eb27c
2 changed files with 34 additions and 23 deletions

View File

@ -67,6 +67,16 @@ jobs:
- name: Run tests
run: cd frontend && npm test
env:
# Vitest 4 forks pool keeps state alive across the 117 test
# files in one worker process, and several of those tests
# load the ngspice emscripten module (~30 MB each), the
# MixedModeScheduler singleton, and other lazy modules.
# Cumulative heap pressure exceeds Node's 4 GB default by
# the end of the suite and the OOM kills the worker AFTER
# all 1881 tests pass. Bump to 8 GB until the test files
# are sharded or singletons get proper dispose hooks.
NODE_OPTIONS: --max-old-space-size=8192
# Production build smoke — catches Vite/Rollup-only failures that
# vitest doesn't see (chunk wiring, dynamic imports, manualChunks

View File

@ -877,36 +877,37 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
def _on_gpio_matrix(gpio: int, signal_id: int) -> None:
"""Synchronous GPIO Matrix routing event from libqemu 1.1.0+.
Replaces the 100 ms poll path in _refresh_signal_routing().
signal_id 0x100 = SIG_GPIO_OUT_IDX = "matrix routing cleared,
pin reverts to plain GPIO". Below 0x100 is the routed signal
source. We filter to the LEDC HS/LS range matching what the
poll path already handled future peripherals just need to
extend the range here, not add their own poll thread.
Critical: this fires on QEMU's iothread, hundreds of times during
early boot (bootloader + IDF init configure every GPIO Matrix
slot). It MUST NOT do anything that can block the iothread
most importantly NOT _emit() over the stdout pipe, because if
the manager's reader is even briefly stalled, the pipe fills,
write() blocks, the iothread freezes, and the entire guest
stops (symptom: ESP32 boot stops at "entry 0x400805e4" with no
Arduino setup() output).
So this callback ONLY mutates the in-memory SignalRouter
snapshot. The 10 Hz poll thread (_refresh_signal_routing) is
the sole emitter of gpio_routing / gpio_routing_clear events.
The callback's only benefit over the poll alone is reducing
the worst-case routing-to-emit latency from ~100 ms to one
poll tick, AND keeping the snapshot dict warm so the next
poll's diff is cheaper.
"""
if _stopped.is_set():
return
try:
sid_lo = signal_id & 0xFF
prev = _signal_router.signal_for_gpio(gpio)
if signal_id == 0x100 or signal_id == 0:
# Matrix entry reset → clear routing. Only emit if we
# actually had a previous entry.
if prev is not None:
if signal_id == 0x100:
_signal_router.clear_routing(gpio)
_emit({'type': 'gpio_routing_clear', 'gpio': gpio})
return
# Only emit for signals the frontend SignalRouter cares
# about (LEDC for now). Other writes are dropped — future
# peripherals (RMT-out, MCPWM) extend the range here.
if SIG_LEDC_HS_CH0_OUT_IDX <= sid_lo <= SIG_LEDC_LS_CH_LAST:
if prev != sid_lo:
elif SIG_LEDC_HS_CH0_OUT_IDX <= sid_lo <= SIG_LEDC_LS_CH_LAST:
_signal_router.update_routing(gpio, sid_lo)
_emit({'type': 'gpio_routing',
'gpio': gpio, 'signal_id': sid_lo})
# Other signal_id values fall outside what the frontend
# SignalRouter currently cares about; future peripherals
# extend the range above.
except Exception:
# Iothread callback — never raise, just drop.
# Iothread callback — never raise, never block.
pass
# ── Per-slave I2C event counter (for logging) ─────────────────────────────