Date: 2026-07-28 Branch: claude/hil-test-split (based on claude/hil-pool-check, which adds pool_check.py)
test/hil/hil_test.py is 2370 lines mixing five concerns: board-lock protocol, per-controller scheduling permits, flash/reset backends, the actual per-example tests, and orchestration/report/CLI. The lock protocol additionally exists in three copies (hil_test.py, board_lock.py, .claude/skills/hil/pool_check.py), which has already produced drift (pool_check‘s copy lacks hil_test’s fail-open and error guards). Splitting the infrastructure out makes hil_test.py test-focused and gives external tools (pool_check) one canonical import for locks, permits, and flashing.
Goal: behavior-preserving code motion. hil_test.py's CLI, arguments, output, report format, and runtime behavior stay byte-identical. One deliberate user-visible change: the operator lock CLI moves from board_lock.py to hil_lock.py (same subcommands, same behavior); board_lock.py is deleted.
Non-goals (explicit follow-ups, not this change):
flash_permit controller budgeting (enabled by this split).test/hil/)| File | ~Lines | Role |
|---|---|---|
hil_test.py | 1600 | tests + orchestration + report + CLI (unchanged interface) |
hil_lock.py (new) | 420 | board-lock protocol + controller permits + operator CLI |
hil_flash.py (new) | 250 | run_cmd + flash/reset backends + find_firmware |
board_lock.py | deleted | superseded by hil_lock.py |
Import graph: hil_test → {hil_lock, hil_flash}; the helpers import nothing local (no cycles). pool_check.py imports all three.
Docstring states the scope: board locks + controller flash/battery permits; the CLI manages board locks only (permits are in-process semaphores with no CLI meaning).
Flock core (protocol defined once; moved from board_lock.py/hil_test.py):
BOARD_LOCK_DIR = '/tmp/tinyusb-hil-locks', lock_path(board)CI_REASON = 'hil_test.py' — the release-protected holder tag (release refuses to kill it)flock_nb(board) -> fh — os.open(O_RDWR|O_CREAT, 0o666) without O_TRUNC (a losing racer must not wipe the winner's record), fdopen('r+'), LOCK_EX|LOCK_NB; raises OSError when heldwrite_record(fh, reason) — truncate+seek+json.dump({pid, reason, since})+flushclear_record(fh) — truncate(0), swallow OSError (records stay truthful on release)read_record(board) -> dict | None — today's board_lock.read_infoacquire_board_lock(board, reason=CI_REASON) -> fh | None — today's hil_test.acquire_board_lock with a reason parameter: HIL_NO_BOARD_LOCK=1 bypass, fail-open with warning on lock-dir OSError, RuntimeError carrying holder info on conflictController permits (moved verbatim from hil_test.py):
FLASH_PARALLEL, USBTEST_PARALLEL, CONTROLLER_SLOTS (env-overridable as today)controller_of(uid), controller_slot(pci), controller_permit, flash_permit(uid), usbtest_permit(uid)usbtest_sems, flash_sems, controller_map, controller_meta, controller_hints) set by a new init_scheduling(sems, fsems, cmap, cmeta, hints) hook that hil_test.init_worker calls from the Pool initializer. controller_permit's PROFILE logging calls back through a module-level log = print-style hook that hil_test points at log_line during init_scheduling (keeps helpers free of hil_test imports). The PROFILE env flag (HIL_PROFILE=1) is read independently in hil_lock at import, same derivation as today.Operator CLI (moved verbatim from board_lock.py): hold/release/status subcommands with the daemon-holder machinery (double-fork, setsid, stdio detach, success pipe, SIGTERM bow-out), release policy (probe the flock; protect CI_REASON holders; SIGTERM other recorded pids), is_locked pid-liveness, --all/--config roster handling. The hold/release/status internals switch to the flock-core helpers above; observable behavior unchanged.
Moved verbatim from hil_test.py:
CMD_TIMEOUT (env-overridable), run_cmd(cmd, cwd, timeout), cmd_stdout_text(out)OPENCOD_ADI_PATH, TINYUSB_ROOTflash_jlink/reset_jlink, flash_stlink/reset_stlink, flash_stflash/reset_stflash, flash_openocd/reset_openocd, flash_openocd_wch/reset_openocd_wch, flash_openocd_adi/reset_openocd_adi, flash_wlink_rs/reset_wlink_rs, flash_esptool/reset_esptool, flash_uniflash/reset_uniflash, flash_lm4flash/reset_lm4flashfind_firmware(variant, example)get_serial_dev(id, vendor_str, product_str, ifnum) — moves here (not hil_test) because flash_esptool calls it; keeping it test-side would create a helper→hil_test import cycle. Tests call hil_flash.get_serial_dev.build_dir = 'cmake-build' and verbose = False, set by callers exactly as the hil_test globals are today (hil_test.main sets them from argparse; pool_check sets them directly). run_cmd's verbose echo reads hil_flash.verbose.Dispatch in callers stays string-based: getattr(hil_flash, f'flash_{flasher["name"].lower()}').
Config TypedDicts (Board, FlasherCfg, …), device-node lookup except get_serial_dev (get_disk_dev, get_hid_dev, get_alsa_capture_dev, open_serial_dev, serial_write_all, read_disk_file, open_mtp_dev, get_printer_dev/open_printer_dev), enum-timeout globals + wait_until, log_line/print-lock, compact_output, all test_* functions, test lists, test_example, build_board, test_board, report rendering/accumulation, main. Call sites use explicit module-qualified names (hil_lock.flash_permit(...), hil_flash.run_cmd(...)) so provenance is greppable; no from … import *-style mirroring.
init_worker keeps its signature (Pool initargs unchanged) and forwards the scheduling state to hil_lock.init_scheduling(...).
.claude/skills/hil/pool_check.py — drop its private lock_board/unlock_board in favor of hil_lock.flock_nb + write_record(fh, 'pool_check') (+ clear_record on release; deliberately NOT acquire_board_lock, whose HIL_NO_BOARD_LOCK bypass and fail-open behavior pool_check must not inherit); import flashers/find_firmware/get_serial_dev/cmd_stdout_text/TINYUSB_ROOT/build_dir from hil_flash; BOARD_LOCK_DIR references move to hil_lock. pool_check then imports only hil_lock + hil_flash (no hil_test), so its pymtp stub shim is deleted — that shim existed solely because importing hil_test pulls in libmtp.test/hil/hil_ci.sh — the scp list is currently hil_test.py, pymtp.py, $CONFIG; add hil_lock.py and hil_flash.py (hil_test cannot even import without them). board_lock.py was never in the list.board_lock.py → hil_lock.py (live docs only): .claude/skills/hil/SKILL.md, .claude/agents/hil-operator.md, .claude/agents/target-debugger.md, .claude/skills/etm-trace/SKILL.md, .claude/skills/usb-kernel-recover/SKILL.md, .claude/skills/target-debug/SKILL.md. Historical docs/superpowers/{plans,specs} stay as records.hil_test.py CLI only).python3 -m py_compile on all three modules + pool_check.hil_lock.py hold/status/release interplay: hold, conflicting hold, status listing, release, protection of a CI_REASON record, stale-record cleanup.pool_check.py --scan-only, then a single flash board (e.g. -b stm32f407disco).hil_test.py -b stm32f407disco -B examples tinyusb.json on the rig; compare the report row and log shape against a pre-refactor run.pre-commit run on all touched files.Lands on top of claude/hil-pool-check. After merge, fix the pool_check review findings as a separate change on the new module boundaries, and update agent-memory references to board_lock.py.