Skip to content

HIL: boot every board against every config it can be flashed with - #379

Draft
mullinmax wants to merge 4 commits into
mainfrom
claude/wpc-hil-config-validation-rc62dn
Draft

HIL: boot every board against every config it can be flashed with#379
mullinmax wants to merge 4 commits into
mainfrom
claude/wpc-hil-config-validation-rc62dn

Conversation

@mullinmax

Copy link
Copy Markdown
Contributor

Description

Implements DESIGN.md's G3 — every available config can be parsed and boot, as dev/hil/config_matrix.py plus .github/workflows/hil-config-matrix.yml.

Per board: build and flash that target once, so the config bundle under test is the one this checkout produces, then loop over every config in src/<target>/config/:

  1. Write gamename into the FRAM configuration record over the REPL, and read it back
  2. machine.reset(), wait for the ready marker on the console
  3. Assert:
    • no CONF00 / CONF01, and no HDWR01 either
    • /api/game/active_config is the config we set
    • /api/game/name matches GameInfo.GameName from the source JSON in this repo
    • /api/leaders and /api/adjustments/status return 200 — the definition is usable, not merely loadable

Each board's bundle is also compared against the source directory once before the loop (same config names, same game names), which localises a packing bug to one boot instead of one boot per affected config. Every board is put back on its generic config when its matrix finishes, including after a failure.

Why the game name is the load-bearing assertion. A config that fails to apply raises nothing an outside observer can see: GameDefsLoad.go falls back to safe_defaults and the board serves the generic definition for its hardware, healthy in every other respect. /api/game/active_config does not catch it either — that route reads the gamename field back out of FRAM, not what actually loaded. The game name is what separates "loaded my config" from "silently fell back".

HDWR01 is fatal here even though flash_and_check.py only warns about it: it sends main.py down the safe_mode path where the config is never read at all, so every downstream assertion would pass or fail for reasons unrelated to the config under test.

Refactor

Shared plumbing moves out of flash_and_check.py into dev/hil/bench.py verbatim — inventory, resolve, build, flash, reset, wait-for-boot, USB request — so both harnesses agree on how a board is brought up. bench.py asserts nothing about firmware behaviour; assertions stay in the harness that imports it. flash_and_check.py keeps its own checks and behaves exactly as before.

Related Issues

None filed. Two pre-existing defects the harness found are described below and are not fixed here.

Motivation and Context

Requested: configure each board with every configuration it can be flashed as, and confirm it boots as that game rather than falling back to the generic config for that hardware. WPC is the interesting case at 63 configs.

Findings — pre-existing, not fixed in this PR

1. Two WPC configs can never be selected on a real board. configuration.gamename is a 16-byte fixed-width field (SPI_DataStore.py) and struct.pack truncates silently. HarleyDavidson_L3 and GilliganIsland_L9 are 17 characters, so the web UI offers them, /api/settings/... accepts the write, the name is truncated on the way into FRAM, and the next boot matches nothing and comes up on safe defaults with CONF01. Fixing it means shortening the two filenames or widening the field — the latter is a MapVersion change, since the 96-byte record is fully used. Happy to do either in a follow-up; it needs a call on whether renaming a config filename is acceptable.

Meanwhile: the harness reports it before spending a boot cycle, and test_no_new_config_name_exceeds_the_gamename_field fails an ordinary PR that adds a new over-long name. The two that exist are listed in KNOWN_UNREACHABLE_CONFIGS.

2. /api/adjustments/status returns 500 for the 14 configs with no Adjustments section. GameDefsLoad assigns the parsed config straight to SharedState.gdata without merging safe_defaults into it, so the key is absent and Adjustments._get_range_from_gamedef raises KeyError, which route_wrapper turns into a 500. Warned about rather than failed, so a firmware gap the harness cannot fix does not bury the signal it exists for.

Limits, recorded in DESIGN.md

  • Configs sharing a GameName (the four AddamsFam_*) are not distinguished from each other — no route exposes anything else from gdata. A pass there means "parsed and loaded without faulting", not "this exact ROM revision's definition is in memory".
  • The design's free-memory floor is not implemented: no route reports heap, and reading gc.mem_free() over the REPL means interrupting the firmware whose boot is being measured. It needs a small firmware-side route first.

Testing

  • dev/tests/test_hil_config_matrix.py — 27 tests, all passing. Covers config discovery from source JSON, selection/ordering (--configs, --limit, --changed-since), the FRAM field-width rule against every shipped config, and each assertion with the board faked out: the silent-fallback case, a board running a different config, the EM active_config special case, a name that cannot be stored, every fault class, the adjustments warn-vs-fail split, and the job summary.
  • Whole matrix simulated end to end against a fake board — clean run, the two over-long WPC names, and an injected silent fallback all produce the expected outcomes.
  • flake8 (repo settings) clean on every file touched; new files are black/isort clean. bench.py is left byte-identical to the code it came from so the move is reviewable as a move, which means it inherits flash_and_check.py's existing black drift.
  • Not yet run on the bench — needs a maintainer to dispatch it. Runtime is ~15–25s per config per board, so a full WPC leg is roughly 16–26 min. Suggested first run: --target wpc --limit 5.

Types of Changes

  • Bug fix (non-breaking change to resolve an issue)
  • New feature (non-breaking change to add functionality)
  • Breaking change (alters existing functionality)
  • Documentation update required

Checklist

  • My code follows the project's style guidelines.
  • I have updated documentation as needed.
  • I have read the CONTRIBUTING.md document.
  • I have added or updated tests.
  • All new and existing tests pass.

Additional Notes

The workflow is workflow_dispatch only, plus a push trigger on this branch so it can be validated before merging (same pattern as the existing HIL workflows — drop the push trigger once it is on main). It shares the hil-bench concurrency group, so it queues behind the other HIL jobs rather than interleaving with them. Dispatch inputs go through the environment rather than ${{ }} interpolation into the shell.


Generated by Claude Code

Implements DESIGN.md's G3. Per board: build and flash that target once, then
loop over every config in src/<target>/config/ - write gamename into the FRAM
configuration record over the REPL, reset, wait for the ready marker, and ask
the board what it loaded.

The game name is the assertion that earns its keep. A config that fails to
apply does not fault or crash from outside: GameDefsLoad.go falls back to
safe_defaults and the board serves the generic definition for its hardware,
healthy in every other respect. /api/game/active_config does not catch that
either - it reads the gamename field back out of FRAM, not what actually
loaded. Comparing /api/game/name against GameInfo.GameName in the source JSON
is what separates "loaded my config" from "silently fell back", and it
cross-checks the on-board bundle against the repo while it is there.

HDWR01 is fatal here even though flash_and_check.py only warns about it: it
sends main.py down the safe_mode path where the config is never read, so every
downstream assertion would pass or fail for reasons unrelated to the config
under test.

Shared plumbing moves to bench.py verbatim - inventory, resolve, build, flash,
reset, wait-for-boot, USB request - so both harnesses agree on how a board is
brought up and neither has to be edited to add a check. flash_and_check.py
keeps its own assertions and behaves exactly as before.

Two findings from writing this, both pre-existing and neither fixed here:

- HarleyDavidson_L3 and GilliganIsland_L9 are 17 characters and the FRAM
  gamename field is 16, so struct.pack truncates them and the two games can
  never be selected on a real board - the UI offers them, the write is
  accepted, and the next boot comes up on safe defaults with CONF01. The
  harness catches this before spending a boot cycle, and a unit test catches
  any new offender in ordinary CI.
- /api/adjustments/status 500s for the 14 configs that declare no Adjustments
  section, because gdata is not merged with safe_defaults. Warned about rather
  than failed, so a firmware gap the harness cannot fix does not bury the
  signal it exists for.

Hardware-free parts are unit tested: config discovery, selection and ordering,
and each assertion with the board faked out - including the silent-fallback
case the whole harness is built around.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013c2GwCiEu9rRxznR5vfkuU
@github-actions

Copy link
Copy Markdown
Contributor

Developer build links:
Sys11

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/sys11-update.json

Sys11 (Tiny)

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/sys11-tiny-update.json

WPC

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/wpc-update.json

EM

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/em-update.json

WhiteStar

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/whitestar-update.json

DataEast

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/data-east-update.json

Classic

https://raw.githubusercontent.com/warped-pinball/vector/pr-update-artifacts/pr-artifacts/pr-379/classic-update.json

claude added 3 commits August 26, 2026 04:31
…n a dead board

First bench run: sys11 passed all 39 configs at ~21s each, which is the harness
working end to end. WPC wedged - silent console, no reply to Ctrl-C - and then
the run spent 63 minutes writing the same 60s timeout 63 times before dying in
teardown, taking the summary with it and never touching data_east.

Three separate faults, fixed separately.

1. The wedge. The Pico has one CDC endpoint, so using mpremote to write the
   gamename meant closing our connection, letting a second process open the
   port, and reopening after - once per config. sys11 (MicroPython 1.24.1)
   survived that 39 times; WPC (1.26.0-preview) did not survive it once. A
   running board printing into a CDC endpoint nothing is draining is the
   difference between them.

   The REPL is now driven directly over the connection the harness already
   holds (bench.Repl), so nothing else ever opens the port and the port is
   closed only while the board is mid-reset. run_matrix became a pipeline -
   boot, assert, set the next config, reboot - carrying one connection per
   boot instead of churning three.

   Repl owns its read buffer, which is the point of it being a class: raw REPL
   is a sequence of markers and a read that syncs on one almost always pulls in
   bytes belonging to the next. The first version dropped them and
   desynchronised; there is a test for it now.

2. 63 timeouts. Two consecutive setup failures abandon the board, after one
   cheap recovery attempt that drains its console and sends Ctrl-C. Boot
   timeout for the matrix drops to 90s - a healthy boot answers in 12-16s, so
   150s only bought a wedged board more time to waste. A dead board now costs
   about three minutes instead of an hour, and the boards after it still run.

3. Teardown taking down the run. restore_default caught CheckFailure but not
   TimeoutExpired, so it raised through main and lost results already in hand.
   Teardown is best-effort now, and a board that fails outright is one board's
   failure rather than the run's.

Also: stty raw -echo before dumping a console in both HIL workflows. A tty
reverts to ECHO-on once every handle closes, so `cat /dev/ttyACM*` was echoing
each board's output back into it - the logs show all three boards parsing their
own log lines as USB API requests.

Simulated end to end against a fake board for the healthy, unstorable-name and
wedged cases; the wedge that cost 63 minutes now costs two boots.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013c2GwCiEu9rRxznR5vfkuU
Run 2 died in inventory, before any of the config-matrix fixes could run:
`mpremote connect /dev/ttyACM1` timed out at 30s. The WPC board is still
deadlocked from run 1, so the bench is down until something revives it.

TrenchCoat confirms the mechanism in its own source (src/ray.py,
send_command): "if nothing ever drains the board's output, the USB CDC buffers
fill up, MicroPython blocks writing to stdout, and the board deadlocks
mid-script". Its enter_bootloader_mode() goes through machine.bootloader() on
the REPL, which is no use once a board is in that state - hence this.

dev/hil/recover.py escalates cheapest-first and stops the moment the board
answers:

  1. drain      read whatever is queued and send Ctrl-C. If it is blocked on a
                full CDC buffer, reading is the remedy. Zero bytes drained is
                itself a diagnosis: it is stuck on something else.
  2. usb reset  USBDEVFS_RESET on the device node, to re-enumerate and reset
                TinyUSB's endpoint state.
  3. power      uhubctl on the board's hub port. The board is USB bus powered
                (Trench-Coat-Install-Guide.md), so this is a genuine cold boot.
  4. reflash    1200 baud touch into the ROM bootloader, then a MicroPython
                UF2 onto the RPI-RP2 drive. The touch is a CDC line-coding
                change handled in USB interrupt context, so a blocked Python VM
                does not stop it - which is exactly why it can work when the
                REPL cannot.

Two guards on step 4, because it is the one that can make things worse. It
refuses to touch a board into BOOTSEL unless something here can actually mount
the drive afterwards - a wedged board is at least still a serial device, while
a BOOTSEL board that cannot be flashed needs a physical replug. And the UF2 is
downloaded and checksummed *before* the touch, so a bad fetch cannot strand the
board either. UF2s come from warped-pinball/trench-coat pinned by commit, with
sha256 per file, rather than being vendored.

A dead board cannot say which system it is, so the target is deduced by
elimination: ask the boards that do answer, and whatever VECTOR_HIL_BOARD_MAP
still expects is the one on the floor. With one board down that is exact; with
two it refuses to guess, because flashing the wrong system's UF2 is worse than
leaving a board dead.

The run opens with a capability report - whether the USB node is writable,
whether uhubctl is installed, whether anything can mount an RPI-RP2 drive - so
even a failed recovery says which one-time bit of runner setup would make the
bench self-healing. Expect steps 2 and 3 to be unavailable today: the runner
user is in dialout, which covers serial but not raw USB.

22 tests cover the ladder, the elimination logic, both step-4 guards and the
sysfs path parsing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013c2GwCiEu9rRxznR5vfkuU
The recovery run hung. Fourteen minutes into a step that should take one, with
no output, until the job timeout killed it - and the cause is our side of the
same failure the board has.

`serial.Serial(timeout=...)` sets the READ timeout only. Every connection the
harness opened had no `write_timeout`, so a write to a board that has stopped
draining its USB OUT endpoint blocks forever. That is exactly the state a
wedged board is in, and exactly the board the recovery tool has to write to:
it drained the console fine, then blocked on the Ctrl-C it sends afterwards.

`flush()` is worse. It is termios tcdrain, it waits for the kernel's output
buffer to reach the device, and it takes no timeout at all - so it hangs on the
same board even where the write did not. Every call site is gone: handing bytes
to the kernel is enough, and every exchange here is already synchronised by a
read with a deadline.

Both are now impossible to reintroduce by accident. open_serial() is the only
way a port gets opened and always sets both timeouts; serial_write() turns a
deaf board into a CheckFailure naming the cause; a test asserts bench.py
contains no .flush() at all.

This was not only the recovery tool's problem - config_matrix's nudge() would
have hung the matrix the same way, on the same board.

Added bench.time_limit as a backstop: a SIGALRM ceiling around each recovery
step (180s) and each config in the matrix (240s). Every call in here is meant
to be bounded, but a board in a bad enough state can block a syscall no library
timeout covers, and one board must never hang a bench job again. SIGALRM
interrupts the syscall, so it catches what the individual timeouts miss.

Also removed the config matrix's push trigger for now. It shares the hil-bench
concurrency group with the recovery workflow, and GitHub keeps only one pending
run per group, so a push touching both would race and silently cancel one.
While the bench needs recovering, recovery gets the queue; the workflow says
how to put it back.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013c2GwCiEu9rRxznR5vfkuU
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