summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.circleci/config.yml2
-rw-r--r--.claude/agents/driver-reviewer.md4
-rw-r--r--.claude/agents/hil-operator.md14
-rw-r--r--.claude/agents/port-dev.md2
-rw-r--r--.claude/agents/pr-monitor.md2
-rw-r--r--.claude/agents/target-debugger.md4
-rw-r--r--.claude/skills/etm-trace/SKILL.md4
-rw-r--r--.claude/skills/etm-trace/boards.md2
-rw-r--r--.claude/skills/hil-pool-check/SKILL.md17
-rw-r--r--.claude/skills/hil/SKILL.md68
-rw-r--r--.claude/skills/pre-pr/SKILL.md2
-rw-r--r--.claude/skills/read-doc/SKILL.md60
-rwxr-xr-x.claude/skills/read-doc/search.py112
-rw-r--r--.claude/skills/target-debug/SKILL.md6
-rw-r--r--.claude/skills/usb-kernel-recover/SKILL.md274
-rwxr-xr-x.claude/skills/usb-kernel-recover/scripts/usb_recover.sh122
-rw-r--r--.claude/skills/usbtest/SKILL.md32
-rw-r--r--.claude/workflows/driver-review.js2
-rw-r--r--.claude/workflows/hil-validate.js4
-rw-r--r--.claude/workflows/pr-babysit.js2
-rwxr-xr-x.github/scripts/ci_set_matrix.py (renamed from .github/workflows/ci_set_matrix.py)0
-rw-r--r--.github/scripts/hil_ci_set_matrix.py (renamed from test/hil/hil_ci_set_matrix.py)8
-rw-r--r--.github/workflows/build.yml71
-rw-r--r--.github/workflows/pre-commit.yml1
-rw-r--r--.pre-commit-config.yaml21
-rw-r--r--CLAUDE.md14
-rw-r--r--docs/superpowers/followup/pr3803-flasher-recover.md280
-rw-r--r--docs/superpowers/followup/pr3803-hil-blindness-reporting.md185
-rw-r--r--docs/superpowers/followup/pr3803-hil-iar-rerun-spec.md118
-rw-r--r--docs/superpowers/followup/pr3803-pci-rebind-stranding.md157
-rw-r--r--docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md175
-rw-r--r--docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md22
-rw-r--r--docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md236
-rw-r--r--hw/bsp/lpc43/family.c24
-rw-r--r--hw/bsp/mcx/family.cmake2
-rw-r--r--hw/bsp/stm32l4/boards/stm32l412nucleo/board.h48
-rw-r--r--src/class/mtp/mtp_device.c18
-rw-r--r--src/portable/chipidea/ci_hs/ci_hs_imxrt.h3
-rw-r--r--src/portable/chipidea/ci_hs/ci_hs_lpc18_43.h5
-rw-r--r--src/portable/chipidea/ci_hs/ci_hs_type.h9
-rw-r--r--src/portable/chipidea/ci_hs/dcd_ci_hs.c4
-rw-r--r--src/portable/chipidea/ci_hs/hcd_ci_hs.c4
-rw-r--r--src/portable/synopsys/dwc2/dcd_dwc2.c7
-rw-r--r--test/hil/helper/__init__.py4
-rw-r--r--test/hil/helper/hil_health.py380
-rwxr-xr-xtest/hil/helper/hil_lock.py (renamed from test/hil/hil_lock.py)188
-rw-r--r--test/hil/helper/hil_pool_check.py (renamed from test/hil/hil_pool_check.py)290
-rwxr-xr-xtest/hil/helper/hil_select.py (renamed from test/hil/hil_select.py)12
-rw-r--r--test/hil/helper/hil_util.py585
-rw-r--r--test/hil/hfp.json5
-rw-r--r--test/hil/hil_ci.sh85
-rw-r--r--test/hil/hil_examples.py37
-rwxr-xr-xtest/hil/hil_flash.py363
-rwxr-xr-xtest/hil/hil_test.py1615
-rw-r--r--test/hil/mtp_test.py246
-rw-r--r--test/hil/pymtp.py10
-rw-r--r--test/hil/requirements.txt3
-rw-r--r--test/hil/test/stubs/pymtp.py125
-rw-r--r--test/hil/test/test_hil_bounded.py1701
-rw-r--r--test/hil/test/test_hil_health.py636
-rw-r--r--test/hil/test/test_hil_select.py (renamed from test/hil/test_hil_select.py)128
-rw-r--r--test/hil/test/test_hil_util.py230
-rw-r--r--test/hil/tinyusb.json15
-rwxr-xr-xtest/hil/usbtest.py643
-rw-r--r--tools/metrics_compare_base.py4
65 files changed, 8061 insertions, 1391 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 66799910d..48fa87899 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -15,7 +15,7 @@ jobs:
- run:
name: Set matrix
command: |
- MATRIX_JSON=$(python .github/workflows/ci_set_matrix.py)
+ MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)
echo "MATRIX_JSON=$MATRIX_JSON"
BUILDSYSTEM_LIST=(
diff --git a/.claude/agents/driver-reviewer.md b/.claude/agents/driver-reviewer.md
index f45eca03e..9ce8b621f 100644
--- a/.claude/agents/driver-reviewer.md
+++ b/.claude/agents/driver-reviewer.md
@@ -1,7 +1,7 @@
---
name: driver-reviewer
description: Review one TinyUSB driver directory or one diff against one review dimension (correctness, ISR safety, datasheet/errata conformance, style) with coverage-first structured findings; or adversarially verify a single finding / fix. Read-only.
-tools: Bash, Read, Grep, Glob
+tools: Bash, Read, Grep, Glob, Skill
model: opus
---
@@ -9,7 +9,7 @@ You review exactly the scope given in your prompt (one driver directory, or one
## Datasheets & errata
-For register-use review, find the MCU/USB-IP reference manual in `$HOME/Documents/calibre-library` — and ALSO search the library for the part's errata / silicon-bug sheets (search terms: "errata" plus the MCU or USB-IP name). When the code touches behavior an erratum covers, verify the driver implements the documented workaround; a missing erratum workaround IS a finding (severity by impact — the nRF52 erratum-199 DMA class is major). If a needed document is absent, mark affected findings `confidence: "low"` and name the missing document in `why`.
+For register-use review, find the MCU/USB-IP reference manual with the `read-doc` skill — `python3 .claude/skills/read-doc/search.py <keywords>`, never `find`/`grep` over the library tree — and ALSO search for the part's errata / silicon-bug sheets (search terms: "errata" plus the MCU or USB-IP name). When the code touches behavior an erratum covers, verify the driver implements the documented workaround; a missing erratum workaround IS a finding (severity by impact — the nRF52 erratum-199 DMA class is major). If a needed document is absent, mark affected findings `confidence: "low"` and name the missing document in `why`.
## Reporting discipline
diff --git a/.claude/agents/hil-operator.md b/.claude/agents/hil-operator.md
index 6f04f6dcc..ebc9251cc 100644
--- a/.claude/agents/hil-operator.md
+++ b/.claude/agents/hil-operator.md
@@ -18,19 +18,23 @@ The GitHub Actions runner keeps running during your work. Per-board flock locks
- `python3 test/hil/hil_test.py ...` runs: do NOT pre-hold those boards — `hil_test.py` self-locks each board for its flash+test and would fail fast with `board locked` against your own hold.
- ANY other hardware action (JLinkExe/openocd/GDB, manual flash, usbtest.py, serial poking): hold first, release when done — release is mandatory cleanup (a crashed holder auto-releases via kernel flock, but do not rely on it):
```bash
- python3 test/hil/hil_lock.py hold <board...> --reason "<task>"
+ python3 test/hil/helper/hil_lock.py hold <board...> --reason "<task>"
# ... hardware work ...
- python3 test/hil/hil_lock.py release <board...>
+ python3 test/hil/helper/hil_lock.py release <board...>
```
-- Rig-wide operations (uhubctl power cycling, pci-rebind — they renumber buses): `python3 test/hil/hil_lock.py hold --all --reason "<why>"` first.
+- Rig-wide operations (uhubctl power cycling, controller resets — they renumber buses): `python3 test/hil/helper/hil_lock.py hold --all --reason "<why>"` first.
- If a lock is already held by someone else: report holder/reason (`hil_lock.py status`) — never force, never kill the holder. If the holder's reason is `hil_test.py`, that is a concurrent CI job mid-test on the board: waiting a few minutes and retrying once is appropriate when your task allows; otherwise return the holder info so the orchestrator can ask the user.
- You cannot ask the user anything. Bypassing a lock (`HIL_NO_BOARD_LOCK=1`, or proceeding with manual hardware work despite a held lock) is allowed ONLY when your prompt explicitly states the user authorized forcing.
## Hard rules
-- HIL runs take 2–5 min per board: use Bash timeouts >= 20 min (1200000 ms) and NEVER cancel early.
+- HIL runs take 2-5 min per board, but a stuck fleet runs to `HIL_POOL_TIMEOUT` — 60 min
+ unless the env pins it; the run logs its guard in the startup line. That far exceeds the
+ Bash tool's 10 min foreground cap: run it in the background and wait
+ for the completion notification. A foreground timeout kills the run before hil_test.py
+ can write its report. NEVER cancel early.
- One hardware action at a time. You are never run concurrently with another hil-operator.
-- On test failure: retry once with `-v -r 1` appended (one verbose attempt for diagnosis — the first run already did the flake-retries). If a board/fixture stops enumerating or tools hang in D state, consult usb-kernel-recover and capture `dmesg | tail -50` into `detail`; set `wedged` true.
+- On test failure: retry once with `-v -r 1` appended (one verbose attempt for diagnosis; a usbtest battery that produced per-case verdicts is NOT auto-retried, so its result already stands). If a board/fixture stops enumerating, or a tool of YOURS hangs in D state, consult usb-kernel-recover and capture `dmesg | tail -50` into `detail`; set `wedged` true. A `> **Rig note.**` banner reporting someone else's D-state process is not that — see the hil skill's banner list.
## Output contract
diff --git a/.claude/agents/port-dev.md b/.claude/agents/port-dev.md
index 76bafb39b..77a28bafa 100644
--- a/.claude/agents/port-dev.md
+++ b/.claude/agents/port-dev.md
@@ -16,7 +16,7 @@ You implement exactly one specified change in one assigned scope (a directory un
## Datasheets
-When changing dcd/hcd register logic, cross-check the MCU reference manual / datasheet / programming guide in `$HOME/Documents/calibre-library` (search by MCU or USB-IP name). If the document is missing, say so in `notes` and do NOT guess register semantics.
+When changing dcd/hcd register logic, cross-check the MCU reference manual / datasheet / programming guide with the `read-doc` skill — `python3 .claude/skills/read-doc/search.py <MCU or USB-IP name>`, never `find`/`grep` over the library tree. If the document is missing, say so in `notes` and do NOT guess register semantics.
## Finish checklist (in order)
diff --git a/.claude/agents/pr-monitor.md b/.claude/agents/pr-monitor.md
index 7dba91fea..77777b0fb 100644
--- a/.claude/agents/pr-monitor.md
+++ b/.claude/agents/pr-monitor.md
@@ -9,7 +9,7 @@ You triage exactly one PR (number given in your prompt) using `gh`. You never mo
## CI triage
-1. `gh pr checks <N>`. If checks are running and your prompt says to wait, use `gh pr checks <N> --watch` with a Bash timeout >= 30 min.
+1. `gh pr checks <N>`. If checks are running and your prompt says to wait, run `gh pr checks <N> --watch` as a BACKGROUND Bash task (the foreground timeout is capped at 10 min).
2. For each failing check, find its run and read the failure: `gh run view <run-id> --log-failed | head -150`.
3. Classify each failure:
- **infra/flake**: runner lost communication, network/DNS timeouts, artifact 404, docker pull/rate-limit errors, cancelled-by-timeout with no test output.
diff --git a/.claude/agents/target-debugger.md b/.claude/agents/target-debugger.md
index 655b5f512..1b6931307 100644
--- a/.claude/agents/target-debugger.md
+++ b/.claude/agents/target-debugger.md
@@ -46,8 +46,8 @@ the next technique you would try.
## Lock discipline
-- Hold the board lock for the WHOLE session (`hil_lock.py hold <board>
- --reason "target debug: <bug>"`). Multi-hour holds are fine; never stop the
+- Hold the board lock for the WHOLE session (`python3 test/hil/helper/hil_lock.py
+ hold <board> --reason "target debug: <bug>"`). Multi-hour holds are fine; never stop the
actions-runner. Locks held by others: report holder/reason, never force
unless your prompt states the user authorized it.
- `hil_test.py` self-locks: release your hold before any `hil_test.py` run,
diff --git a/.claude/skills/etm-trace/SKILL.md b/.claude/skills/etm-trace/SKILL.md
index 9e2505736..43cf6a2a5 100644
--- a/.claude/skills/etm-trace/SKILL.md
+++ b/.claude/skills/etm-trace/SKILL.md
@@ -43,7 +43,7 @@ this skill for exact counts, coverage, or instruction-by-instruction history.
capture script uses automation port **19201**, never an interactive Ozone's
19200.
- Hold the board lock (see the `hil` skill):
- `python3 test/hil/hil_lock.py hold <board> --reason "etm capture"`.
+ `python3 test/hil/helper/hil_lock.py hold <board> --reason "etm capture"`.
- Committed `hw/bsp/**/ozone/*.jdebug` are the maintainer's interactive
projects — automation never opens them (Ozone rewrites project files); the
script generates a throwaway project.
@@ -147,7 +147,7 @@ request `itrace.csv`, `profile_lines.csv`, `profile_insts.csv`, `samples.csv`,
Bring-up ladder — each step gates the next:
-1. **Docs before hardware** (calibre library first, then vendor site): board
+1. **Docs before hardware** (`read-doc` skill first, then vendor site): board
manual, schematics, MCU reference manual. Establish the trace clock
source and max — chip side and probe side (J-Trace PRO Cortex-M tops out
at a 150 MHz trace clock) — the pins carrying TRACE_CLK/D0-D3 (read the board's
diff --git a/.claude/skills/etm-trace/boards.md b/.claude/skills/etm-trace/boards.md
index 4a5f297ae..044d4e0ee 100644
--- a/.claude/skills/etm-trace/boards.md
+++ b/.claude/skills/etm-trace/boards.md
@@ -52,7 +52,7 @@ Board caveats (beyond the table):
`AfterTargetConnect` hook; un-attachable after a killed session →
power-cycle.
- **metro_m7_1011** (RT1011): a custom Adafruit rev with a hand-added 2x10
- ETM header (KiCad schematic in the calibre library). No SEGGER RT1011
+ ETM header (KiCad schematic via the `read-doc` skill). No SEGGER RT1011
example exists — the committed .jdebug (tuned +50 ps) is the known-good
reference. BOARD_BootClockRUN sets the 132 MHz trace root but leaves it
gated; `trace_etm_init` ungates it. The first Ozone run after a fresh
diff --git a/.claude/skills/hil-pool-check/SKILL.md b/.claude/skills/hil-pool-check/SKILL.md
index 49d252f62..6a8f66087 100644
--- a/.claude/skills/hil-pool-check/SKILL.md
+++ b/.claude/skills/hil-pool-check/SKILL.md
@@ -5,7 +5,7 @@ description: Use when asked for a pool check or board/probe health scan on a Tin
# HIL Pool Check (board/probe health)
-Health-scan the HIL board pool with `test/hil/hil_pool_check.py`: per board it checks the flash
+Health-scan the HIL board pool with `test/hil/helper/hil_pool_check.py`: per board it checks the flash
probe is on the USB bus, flashes a light example (`device/dfu_runtime`; host-only boards get
`host/device_info`, verified by serial output), waits for the board's uid to re-enumerate,
applies safe per-device recovery (probe authorized-toggle, board reset), re-parks with
@@ -21,19 +21,19 @@ pool check holds fails it as "board locked" — prefer running between CI runs.
A request for a "pool check" means the DEFAULT full check below. Use `--scan-only` only when the
user explicitly asks for a quick look, or when you have VERIFIED a CI sweep is mid-run right now
-(`python3 test/hil/hil_lock.py status` shows `hil_test.py` holders) — "CI might be running" is not
+(`python3 test/hil/helper/hil_lock.py status` shows `hil_test.py` holders) — "CI might be running" is not
that predicate: the full check is already lock-safe (CI-held boards report 🔒 locked and are never
touched), so an unconfirmed suspicion is no reason to downgrade. In either scan case say which
mode ran and why; never silently substitute the scan for the full check.
```bash
-python3 test/hil/hil_pool_check.py # full check: ~10 s + ~1-2 s/board with firmware built;
+python3 test/hil/helper/hil_pool_check.py # full check: ~10 s + ~1-2 s/board with firmware built;
# first run on an unbuilt tree takes minutes (it builds)
-python3 test/hil/hil_pool_check.py --scan-only # USB presence only, <1 s, no locks/flashing/building
-python3 test/hil/hil_pool_check.py -b BOARD [-b …] # subset; may name boards-skip (parked) entries
+python3 test/hil/helper/hil_pool_check.py --scan-only # USB presence only, <1 s, no locks/flashing/building
+python3 test/hil/helper/hil_pool_check.py -b BOARD [-b …] # subset; may name boards-skip (parked) entries
# from a dev PC, against the ci rig (bash -lc: flashers like STM32_Programmer_CLI live in ~/bin):
-ssh ci.lan 'bash -lc "cd ~/code/tinyusb && python3 test/hil/hil_pool_check.py"'
+ssh ci.lan 'bash -lc "cd ~/code/tinyusb && python3 test/hil/helper/hil_pool_check.py"'
```
## Notes
@@ -45,8 +45,9 @@ ESP-IDF env (`get-idf`) for espressif — which also needs `esptool` on PATH (pi
`~/.local/bin/esptool`; a non-login shell may lack it — run via `bash -lc`). An explicit `-B` is
searched exclusively for *existing* firmware; builds still land in `cmake-build/` and are noted
`built <example>`. Espressif boards park too when the IDF env is present. A first run on an
-unbuilt tree builds for many minutes: use a command timeout ≥ 30 min and NEVER cancel early — a
-killed run leaves detached cmake/ninja children still writing to `cmake-build/`.
+unbuilt tree builds for many minutes: the Bash tool caps a foreground timeout at 10 min, so run
+it in the BACKGROUND and NEVER cancel early — a killed run leaves detached cmake/ninja children
+still writing to `cmake-build/` with the board locks held under a protected reason.
Statuses: `ok` (flashed and verified; in `--scan-only` it only means the probe is present),
`flash-failed` (firmware delivery failed: probe missing, build failed, flasher error, silent
diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md
index f273be120..093f345b2 100644
--- a/.claude/skills/hil/SKILL.md
+++ b/.claude/skills/hil/SKILL.md
@@ -1,6 +1,6 @@
---
name: hil
-description: Use when running TinyUSB Hardware-in-the-Loop (HIL) tests on physical boards, debugging HIL failures, or copying firmware to the ci.lan test rig. Covers per-host config selection (infra rigs ci/tusb use tinyusb.json/hfp.json, any dev PC uses local.json), local and remote execution, the board-lock protocol, and debugging tips. For board/probe health scans ("pool check") use the hil-pool-check skill.
+description: Use when running TinyUSB Hardware-in-the-Loop (HIL) tests on physical boards, when a HIL run fails, hangs, reports a board locked, or produces a report you need to interpret, or when copying firmware to a test rig (ci.lan, hifiphile/tusb, or a dev PC). For board/probe health scans ("pool check") use the hil-pool-check skill instead.
---
# Hardware-in-the-Loop (HIL) Testing
@@ -26,28 +26,28 @@ The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL
- For hardware work outside `hil_test.py` (JLink/GDB, manual flashing, `usbtest.py`, serial poking), hold the lock first:
```bash
-python3 test/hil/hil_lock.py hold BOARD [BOARD...] --reason "why"
+python3 test/hil/helper/hil_lock.py hold BOARD [BOARD...] --reason "why"
# ... hardware work ...
-python3 test/hil/hil_lock.py release BOARD [BOARD...]
+python3 test/hil/helper/hil_lock.py release BOARD [BOARD...]
```
- Never pre-hold boards you are about to run `hil_test.py` on — it self-locks and would treat your own hold as a conflict.
-- Rig-wide operations (uhubctl power cycling, pci-rebind — bus renumbering) affect every board: `hil_lock.py hold --all --reason "..."` first.
+- Rig-wide operations (uhubctl power cycling, controller resets — bus renumbering) affect every board: `hil_lock.py hold --all --reason "..."` first.
- `hil_lock.py status` lists holders. Locks auto-release when the holder process dies (kernel flock); `/tmp` clears on reboot.
- Forcing past a lock: `HIL_NO_BOARD_LOCK=1 python3 test/hil/hil_test.py ...` bypasses the guard without killing the holder. Only with the user's explicit go-ahead — they accept the risk of colliding with whatever holds the board.
## Pool check (board/probe health)
-Board/probe health scanning (`test/hil/hil_pool_check.py`) has its own skill: **hil-pool-check**.
+Board/probe health scanning (`test/hil/helper/hil_pool_check.py`) has its own skill: **hil-pool-check**.
Use it before a HIL campaign, after rig maintenance/reboot, or when boards fail to flash.
## PR-scoped selection
-`test/hil/hil_select.py` maps a diff to affected boards/tests (used by CI on PRs; fail-open
+`test/hil/helper/hil_select.py` maps a diff to affected boards/tests (used by CI on PRs; fail-open
to the full matrix). Manual use:
```bash
-SEL=$(python3 test/hil/hil_select.py --base master test/hil/tinyusb.json)
+SEL=$(python3 test/hil/helper/hil_select.py --base master test/hil/tinyusb.json)
FULL=$(printf '%s' "$SEL" | python3 -c "import json,sys; print(json.load(sys.stdin)['full'])")
ARGS=$(printf '%s' "$SEL" | python3 -c "import json,sys; print(json.load(sys.stdin)['args']['tinyusb.json'])")
if [ "$FULL" = "True" ] || [ -n "$ARGS" ]; then
@@ -60,7 +60,20 @@ fi
Read `full`, never `args` alone: `args` is empty for BOTH `full: true` (run the whole matrix — a broad or
unclassified change) and "nothing selected" (skip). Skip only when `full` is false AND `args` is empty.
-Unit suite: `python3 test/hil/test_hil_select.py` (no hardware).
+Unit suites (no hardware), all four run by the `hil-test`/`hil-select-test` pre-commit
+hooks: `test_hil_select.py` covers only board selection. The containment work --- bounded
+reads, the kill ladders, the build and pool guards --- lives in `test_hil_bounded.py`,
+`test_hil_health.py` and `test_hil_util.py`, so run all four when changing `test/hil`:
+`for f in test/hil/test/test_*.py; do python3 "$f"; done` (~55s).
+
+## Pre-flight rig health check
+
+`hil_test.py` notes any process already in D state when the run starts, as one line above
+the table. It never aborts, and it is a hint rather than a diagnosis. What bounds a stuck
+run is `HIL_POOL_TIMEOUT` plus the job's `timeout-minutes`; what diagnoses a wedged rig is
+the `hil-pool-check` skill.
+
+See the `usb-kernel-recover` skill for what a real wedge looks like and how to clear it.
## Prerequisites
@@ -103,11 +116,44 @@ Env overrides: `REMOTE`, `REMOTE_DIR`, `CONFIG`. Fails fast if the build dir/rep
## Timing
-Runs take 2-5 min. Use a timeout ≥ 20 min (1200000 ms). NEVER cancel early.
+Runs take 2-5 min per board, but a stuck fleet runs to `HIL_POOL_TIMEOUT` — 60 min
+unless the env pins it. The run logs its guard in the startup line; never declare a run
+stuck before THAT value has elapsed.
+The Bash tool caps a foreground timeout at 10 min, so **run it in the background** and
+wait for the completion notification -- never a foreground timeout, which would kill
+the run before its own guard can write a report. NEVER cancel early.
## Reporting
The user-facing answer to a HIL run IS the tool's summary table: paste the complete per-board
table (and footer counts) verbatim — never truncate rows or reduce it to a prose digest; at most
-one line of commentary below it. On failure, retry with `-v`; if that's not enough, add temporary
-debug prints to `hil_test.py`.
+one line of commentary below it.
+
+**First check what sits above the table.** Seven banners can appear there; match on a
+PREFIX, since each carries trailing detail and one is a blockquote:
+
+- `**HIL run abandoned: worker pool timed out after …s.**` — no results were collected this
+ attempt, so any table below is a PREVIOUS attempt's. Report the abandonment, never those
+ rows, and never `"pass": true`.
+- `**HIL run aborted: a worker raised …**` — same rule: a worker crashed before results
+ were collected; any table below is stale. Report the abort, never the rows.
+- `**HIL run abandoned: the worker pool would not shut down.**` — DIFFERENT: the table
+ below IS this run's, but the pool could not be shut down afterwards (the job exits
+ non-zero even if every board passed). Report the results AND the abandonment; never
+ `"pass": true`.
+- `**HIL run selected no boards.**` — the filters intersected to nothing, so there is no
+ table at all. Report that (and the filter shown), never `"pass": true`.
+- `> **Rig note.**` — a process was in D state when the run started. This is NOT a wedge:
+ a healthy in-flight testusb is uninterruptible for most of every case, and the rig
+ supports a dev run alongside CI. On its own it is never `wedged: true` and never turns a
+ green table into `"pass": false`. Mention it only when a board below failed, as the first
+ thing to check.
+- `> **Rig dirty.**` — a process survived SIGKILL and still holds a probe or usbfs node
+ into the NEXT job. The table below is this run's and can be reported, but say the rig is
+ dirty: the next job starts degraded and nothing in the harness can clear it.
+- `> **Not all verdicts are evidence.**` — one or more workers went blind on sysfs, so
+ "device not found" from the named boards means "could not tell". Do NOT report their red
+ cells as broken boards.
+
+On failure, retry with `-v`; if that's not enough, add temporary debug prints to
+`hil_test.py`.
diff --git a/.claude/skills/pre-pr/SKILL.md b/.claude/skills/pre-pr/SKILL.md
index 3829b4b9e..b96750e4f 100644
--- a/.claude/skills/pre-pr/SKILL.md
+++ b/.claude/skills/pre-pr/SKILL.md
@@ -15,7 +15,7 @@ Run the software + hardware gate for the current branch. The user invoking this
## 2. Map changes to boards
-- `python3 test/hil/hil_select.py --base $BASE test/hil/tinyusb.json` → JSON with the affected
+- `python3 test/hil/helper/hil_select.py --base $BASE test/hil/tinyusb.json` → JSON with the affected
bsp `families`, the affected rig `boards`, and per-file `reasons`. `full: true` means a
broad/infra change.
- Affected families = `families` ∪ the family of every name in `boards`. Neither half is
diff --git a/.claude/skills/read-doc/SKILL.md b/.claude/skills/read-doc/SKILL.md
index df845e7b5..feaa914af 100644
--- a/.claude/skills/read-doc/SKILL.md
+++ b/.claude/skills/read-doc/SKILL.md
@@ -8,17 +8,22 @@ description: Use when you need authoritative hardware/protocol facts from a prim
## Overview
Some maintainers keep datasheets, manuals, and books in a Calibre library at
-`$HOME/Documents/calibre-library/`, laid out as
-`AUTHOR/TITLE (id)/TITLE - AUTHOR.pdf|.epub`. For hardware/protocol facts —
-registers, bitfields, memory maps, pinouts, electrical/timing specs, errata, USB
-spec — read the doc instead of answering from training knowledge or the web.
+`$HOME/Documents/calibre-library/`. For hardware/protocol facts — registers,
+bitfields, memory maps, pinouts, electrical/timing specs, errata, USB spec —
+read the doc instead of answering from training knowledge or the web.
+
+Search the library's `metadata.db`, never the filesystem. The database indexes
+title, authors, tags, series, publisher, description and the stored filename;
+most part numbers live in the tags, which the filesystem does not carry.
## Gate first
-The library is per-user. Check it exists before anything else:
+The library is per-user and usually on a network mount, so test the database
+file, not the directory — an unmounted or half-synced mountpoint is still a
+directory:
```bash
-[ -d "$HOME/Documents/calibre-library" ] && echo present || echo absent
+[ -f "${CALIBRE_LIBRARY:-$HOME/Documents/calibre-library}/metadata.db" ] && echo present || echo absent
```
Absent → the skill does not apply; fall back to normal sources silently (don't
@@ -35,27 +40,50 @@ Not for general concepts, repo/code questions, or when no such doc is likely.
## Find
-Keywords from `/read-doc <keywords>`, else derived from the question (part number,
-peripheral, spec name). AND them with chained case-insensitive grep:
+Keywords from `/read-doc <keywords>`, else derived from the question (part
+number, peripheral, spec name). `search.py` ANDs them across every metadata
+field and prints the best matches first — at most 40, and the header says when
+more matched:
```bash
-find "$HOME/Documents/calibre-library/" -maxdepth 3 \( -iname '*.pdf' -o -iname '*.epub' \) | grep -i "kw1" | grep -i "kw2"
+python3 .claude/skills/read-doc/search.py errata RT1064 # AND (default)
+python3 .claude/skills/read-doc/search.py RT1060 RT1064 --any
```
-One match → read it. Several → list and ask via AskUserQuestion. None → drop the
-weakest keyword and broaden (filenames hold title+author, not tags); still none →
-list the closest author/title matches.
+Exit 0 matched, 1 nothing matched, 2 bad usage or no library — 2 means the
+search never ran, so fix the invocation instead of broadening.
+
+One match → read it. Several → list and ask via AskUserQuestion. Nothing
+(exit 1) → retry with fewer keywords; the part number alone often works where
+`<part> datasheet` does not, because words like "datasheet" and "manual" are
+rarely in the metadata. `--any` only changes anything with two or more
+keywords. Still nothing → say the document is missing rather than answering
+from memory.
+
+Set `CALIBRE_LIBRARY` to search a library elsewhere.
## Read
-- **PDF:** Read with `pages`; for >10 pages start `pages: "1-20"` (TOC/overview),
+`search.py` prints one `FORMAT path` line per stored file:
+
+- **PDF** — Read with `pages`; for >10 pages start `pages: "1-20"` (TOC/overview),
report the page count, then read sections on demand.
-- **EPUB:** Read the path directly.
-- Summarize in one line (title, pages, coverage) and keep as reference context.
+- **Any other format** (EPUB, MOBI, CHM, ZIP…) — Read has no decoder for these
+ and returns mojibake rather than an error. Say the document is not in a
+ readable format; do not paste what Read returned.
+- **`MISSING`** — the metadata is real but the file is not on disk (library
+ mid-sync, or the file was deleted). Report the file as unavailable, not the
+ document as nonexistent.
+
+Summarize in one line (title, pages, coverage) and keep as reference context.
## Common mistakes
+- Searching with `find`/`grep` over the library tree. It sees only truncated
+ filenames, missing the tags, series and descriptions where part numbers and
+ errata IDs actually live. Query the database.
- Skipping the gate on a machine with no library.
- Answering a register/spec question from memory when the datasheet is on disk.
- Loading a 1000-page PDF up front instead of TOC-first.
-- Requiring all keywords to match — broaden on zero hits.
+- Requiring all keywords to match — broaden, or use `--any`, on zero hits.
+- Treating a `MISSING` file, or an exit 2, as proof the document is absent.
diff --git a/.claude/skills/read-doc/search.py b/.claude/skills/read-doc/search.py
new file mode 100755
index 000000000..c70d36805
--- /dev/null
+++ b/.claude/skills/read-doc/search.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""Search the Calibre library by metadata and print matching document paths.
+
+Usage: search.py KEYWORD [KEYWORD...] all keywords must match (AND)
+ search.py --any KEYWORD [KEYWORD...] any keyword matches (OR)
+
+Matches title, authors, tags, series, publisher, description and stored
+filename, and prints the exact path to read, best match first.
+
+Exit 0 matched, 1 nothing matched, 2 bad usage or no library.
+"""
+import glob
+import os
+import sqlite3
+import sys
+import unicodedata
+import urllib.parse
+
+LIB = os.path.realpath(os.path.expanduser(os.environ.get("CALIBRE_LIBRARY") or "~/Documents/calibre-library"))
+DB = os.path.join(LIB, "metadata.db")
+LIMIT = 40
+
+QUERY = """
+SELECT b.id, b.title, b.path,
+ (SELECT group_concat(a.name, ', ') FROM authors a
+ JOIN books_authors_link l ON l.author = a.id WHERE l.book = b.id),
+ (SELECT group_concat(t.name, ', ') FROM tags t
+ JOIN books_tags_link l ON l.tag = t.id WHERE l.book = b.id),
+ (SELECT group_concat(s.name, ', ') FROM series s
+ JOIN books_series_link l ON l.series = s.id WHERE l.book = b.id),
+ (SELECT group_concat(p.name, ', ') FROM publishers p
+ JOIN books_publishers_link l ON l.publisher = p.id WHERE l.book = b.id),
+ (SELECT c.text FROM comments c WHERE c.book = b.id),
+ (SELECT group_concat(d.format || '/' || d.name, char(10)) FROM data d WHERE d.book = b.id)
+FROM books b
+"""
+
+_authors = None
+
+
+def norm(s):
+ # NFKC + casefold so MICRO SIGN/GREEK MU, curly quotes and dashes compare equal.
+ return unicodedata.normalize("NFKC", s).casefold()
+
+
+def resolve(bid, path, fmt, name):
+ """Absolute path of one format row, or None if the file is not on disk.
+
+ Calibre renames `<author>/<title> (<id>)` when metadata is edited and leaves
+ the old directory behind, so on a miss retry by the stable book id.
+ """
+ ext = "." + fmt.lower()
+ exact = os.path.join(LIB, path, name + ext)
+ if os.path.exists(exact):
+ return exact
+ global _authors
+ if _authors is None:
+ _authors = {}
+ for d in os.listdir(LIB): # case-only duplicates exist on a case-sensitive mount
+ _authors.setdefault(d.lower(), []).append(d)
+ for author in _authors.get(path.split("/")[0].lower(), ()):
+ for d in glob.glob(os.path.join(glob.escape(os.path.join(LIB, author)), "* (%d)" % bid)):
+ for f in sorted(glob.glob(os.path.join(glob.escape(d), "*" + ext))):
+ return f
+ return None
+
+
+def main(argv):
+ match_any = "--any" in argv
+ keywords = [norm(k) for k in argv if k != "--any"]
+ if not keywords:
+ print(__doc__, file=sys.stderr)
+ return 2
+
+ if not os.path.exists(DB):
+ print(f"no Calibre database at {DB}", file=sys.stderr)
+ return 2
+
+ db = sqlite3.connect("file:" + urllib.parse.quote(DB) + "?mode=ro", uri=True)
+ hits = []
+ for bid, title, path, authors, tags, series, publisher, comments, files in db.execute(QUERY):
+ entries = [e.split("/", 1) for e in (files or "").split("\n") if e]
+ hay = norm(" ".join(x for x in (title, authors, tags, series, publisher, comments) if x)
+ + " " + " ".join(n for _, n in entries))
+ found = sum(k in hay for k in keywords)
+ if not found or (not match_any and found < len(keywords)):
+ continue
+ in_title = sum(k in norm(title) for k in keywords)
+ hits.append((-found, -in_title, title, authors, tags, bid, path, entries))
+
+ if not hits:
+ print("no match")
+ return 1
+
+ hits.sort(key=lambda h: h[:3]) # authors/tags may be None and are not comparable
+ print(f"{len(hits)} book(s)" + (f", showing the {LIMIT} best" if len(hits) > LIMIT else ""))
+ for _, _, title, authors, tags, bid, path, entries in hits[:LIMIT]:
+ print(f"\n{title}" + (f" [{authors}]" if authors else "") + (f" tags: {tags}" if tags else ""))
+ if not entries:
+ print(" (no file in this library)")
+ for fmt, name in entries:
+ p = resolve(bid, path, fmt, name)
+ print(f" {fmt} {p}" if p else f" {fmt} MISSING (library mid-sync or file deleted)")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main(sys.argv[1:]))
+ except BrokenPipeError:
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
+ sys.exit(0)
diff --git a/.claude/skills/target-debug/SKILL.md b/.claude/skills/target-debug/SKILL.md
index 28678c309..050a697b9 100644
--- a/.claude/skills/target-debug/SKILL.md
+++ b/.claude/skills/target-debug/SKILL.md
@@ -30,9 +30,9 @@ Hold the board lock for the WHOLE manual session; never stop the
actions-runner (see the `hil` skill for the full lock protocol):
```bash
-python3 test/hil/hil_lock.py hold <board> --reason "target debug: <bug>"
+python3 test/hil/helper/hil_lock.py hold <board> --reason "target debug: <bug>"
# ... instrument / build / flash / capture / GDB ...
-python3 test/hil/hil_lock.py release <board>
+python3 test/hil/helper/hil_lock.py release <board>
```
Board → probe mapping: `test/hil/tinyusb.json` — `flasher.name` is the probe
@@ -347,7 +347,7 @@ the wire itself: `usb-sniffer` skill (hardware tap, PID-level).
- J-Link (UM08001): <https://kb.segger.com/UM08001_J-Link_/_J-Trace_User_Guide> — flash breakpoints, RTT, SWO, monitor mode, Commander.
- OpenOCD: <https://openocd.org/doc/html/index.html> — `rtt`, `bp`/`wp`, `cortex_m vector_catch`/`maskisr`, `itm`/`tpiu`.
- "Debugging with GDB" (§5.1 = break/watch/dprintf): Tenth Edition (GDB 18)
- via calibre/`read-doc`, or
+ via the `read-doc` skill, or
`curl -sL -o /tmp/gdb.pdf https://sourceware.org/gdb/current/onlinedocs/gdb.pdf`
(the HTML mirror blocks fetchers). Installed `arm-none-eabi-gdb`
`help <cmd>` is authoritative here.
diff --git a/.claude/skills/usb-kernel-recover/SKILL.md b/.claude/skills/usb-kernel-recover/SKILL.md
index ea5931cc4..009090769 100644
--- a/.claude/skills/usb-kernel-recover/SKILL.md
+++ b/.claude/skills/usb-kernel-recover/SKILL.md
@@ -1,136 +1,206 @@
---
name: usb-kernel-recover
-description: Use when a USB device or fixture attached to the ci HIL rig's Linux host is stuck, hung, not enumerating, or wedged after a failed flash or test, or when processes touching USB (testusb, JLinkExe, uhubctl, libusb tools) start hanging in D state. Linux-kernel-side only — a bus owned by a TinyUSB host is out of reach (reset the target / cycle its VBUS instead); the rig's probes and serial fixtures always remain in scope.
+description: Use when a USB device or fixture on a HIL rig's Linux host (ci.lan, hifiphile/tusb, a bench PC) is wedged, not enumerating, or when processes touching USB (testusb, JLinkExe, uhubctl, openocd, libusb tools) hang in D state. Linux-host side only — a bus owned by a TinyUSB host is out of reach.
---
# USB Recovery on the HIL Rig (Linux kernel side)
-Run this skill's `scripts/usb_recover.sh` with `sudo`. It wraps the sysfs reset
-actions, a uhubctl power-cycle escalator, and a resolver:
+**The rule:** a wedged usbfs ioctl holds that device's `device_lock`
+(`usbdev_do_ioctl` takes `usb_lock_device`, the uninterruptible variant —
+v6.12.96 devio.c:2609) and the driver under it waits in a plain
+`wait_for_completion()` with no timeout (usbtest.c:1404; `usb_sg_wait`,
+message.c:765). Nothing that also takes that lock can help. Only two levers
+don't: **failing the URB at the device** (rung 1) and **the port-side data-line
+drop** (rung 2).
+
+## 1. Triage: find the holder
```bash
-# all examples below abbreviate: sudo .claude/skills/usb-kernel-recover/scripts/usb_recover.sh
-sudo usb_recover.sh resolve /dev/ttyACM3 # /dev node -> busport (e.g. 3-4.7); also ttyUSB*, sg*
-sudo usb_recover.sh authorized <busport> # deauthorize+reauthorize: re-enumerate, no VBUS cut
-sudo usb_recover.sh rebind <busport> # usb driver unbind+bind: re-probe
-sudo usb_recover.sh hub-cycle <busport> # uhubctl VBUS cycle of the feeding port, walking parent hub
- # -> root port until the device re-enumerates
-sudo usb_recover.sh root-cycle <busport> [serial] # uhubctl VBUS cut straight at the ROOT port (real ppps), no
- # leaf walk, no device-lock touch: the D-state cure.
- # [serial] is checked and a mismatch refused.
-sudo usb_recover.sh pci-rebind <pciaddr> # whole HCD controller unbind+bind, e.g. 0000:02:00.0
-sudo usb_recover.sh pci-bind <pciaddr> [drv] # re-bind a DRIVERLESS controller (auto-tries xHCI drivers)
+ps -eo pid,stat,etimes,wchan:22,args | awk '$2 ~ /D/'
+sudo cat /proc/<pid>/stack # never opens the node, so it cannot block
```
-`hub-cycle` caveats: leaf hubs that gang (or fake) port power switching bounce
-**all siblings** on that hub when cycled; a **self-powered** leaf hub keeps
-downstream VBUS up, so cycling it only resets its uplink — that's why the walk
-escalates to the root port, where the Renesas cards' per-port power (ppps) is
-real. A device that is wedged but bus-powered from a switching hub gets a true
-power cycle; one on a self-powered hub may only get a re-enumeration.
+- **`S` = victim.** Lock-taking sysfs *reads* use `usb_lock_device_interruptible`
+ (sysfs.c:124-139, 11 sites), so readers are killable and `timeout` bounds them.
+ Ignore them; they unwind by themselves.
+- **`D` = the holder, or a writer that took the uninterruptible path.**
+
+| Stack shows | Meaning | Go to |
+|---|---|---|
+| `usbdev_ioctl` + a driver module (`[usbtest]`) | **owner, holds the lock** | rung 3 — terminal |
+| `usbdev_ioctl`, no driver frames | owner waiting on a URB | rung 1 (DUT) / rung 2 (probe) |
+| `usbdev_open`, sysfs reads | victim | ignore |
+| `tee .../usbtest/new_id`, `bind`, `unbind` | **victim that SPREADS it** | stop issuing them |
+| `hub_event` in a kworker | teardown stuck behind an owner | rung 3 |
-## Decide first: is anything stuck in D state?
+Driver-bind writes are not passive: `__device_driver_lock` (drivers/base/dd.c)
+takes `device_lock()` uninterruptibly **and `device_lock(parent)`**, because
+`usb_bus_type` sets `.need_parent_lock = true` (driver.c:2048) — each one holds
+the HUB's lock, which is how one wedged port takes a whole bus down.
+
+Map the holder to a busport with **lock-free attrs only** (`devnum`, `idVendor`,
+`idProduct` are `usb_descriptor_attr*`, plain `sysfs_emit`, sysfs.c:688-705):
```bash
-ps -eo pid,stat,wchan:30,cmd | awk '$2 ~ /D/'
+for d in /sys/bus/usb/devices/<bus>-*/; do
+ [ "$(cat $d/devnum)" = "<devnum>" ] && echo "$d $(cat $d/idVendor):$(cat $d/idProduct)"
+done
+grep -l <SERIAL> /sys/bus/usb/devices/*/serial # only on a HEALTHY device
```
-**If yes** (uninterruptible sleep, typically a usbfs ioctl — e.g. testusb inside
-`usb_sg_wait`): cut VBUS at the root port, and nothing else.
+## 2. Shield first (prerequisite for anything using libusb)
+
+A wedged device blocks every enumerator that reads its locking attributes —
+JLinkExe, uhubctl, openocd's HID fallback. `chmod 000` makes the VFS reject the
+read before `->show()` runs, so they skip it and keep enumerating:
```bash
-sudo usb_recover.sh root-cycle <busport> # e.g. 11-3.7 -> cycles bus 11 root port 3
+for f in bNumInterfaces bmAttributes bMaxPower configuration bConfigurationValue \
+ product manufacturer serial avoid_reset_quirk; do
+ sudo chmod 000 /sys/bus/usb/devices/<busport>/$f
+done
```
-This drops power to the wedged device, so its in-flight URB fails and the ioctl
-returns. It targets the *root hub* — a different USB device from the wedged one —
-and never *writes* the wedged device's sysfs. It reads a few attributes from it —
-`idVendor`/`idProduct`/`serial`/`product` to report and check the target, and the
-directory inode plus `devnum` afterwards — none of which take the device lock, so
-it does not join the convoy the way `authorized`/`rebind`/`pci-rebind` do.
-Recovery is proven by that inode changing — a real disconnect destroys the
-kobject and reconnecting creates a new one, whereas a disconnect blocked on the
-device lock leaves it untouched. It exits non-zero if the device does not come
-back; a **zero exit only means it re-enumerated**, so still confirm the D-state
-process actually let go. Pass the expected serial as a third argument and it
-refuses a busport that now names a different device.
+- Shield the **leaf, its parent hub, and the root hub** (`usb<N>`) — a stuck
+ uhubctl locks the root hub too.
+- **Run the recovery tool as NON-root**: root has `CAP_DAC_OVERRIDE`, ignores the
+ `000`, and blocks anyway.
+- **Only those nine.** `descriptors`, `busnum`, `devnum`, `speed`, `idVendor`,
+ `idProduct` are lock-free and libusb needs them; a blanket `chmod` breaks
+ enumeration instead of fixing it.
+- `chmod` never blocks (inode setattr, no `show()`), so it works on a fully
+ wedged device.
+- **Not needed for openocd pinned with `vid_pid`** — it matches the cached
+ descriptor and skips a foreign device before `libusb_open`
+ (cmsis_dap_usb_bulk.c:107, bulk backend; the HID fallback ignores the pin).
+- Leaf shields vanish on re-enumeration; **the root hub's must be restored**:
+ `sudo chmod "$(stat -c %a /sys/bus/usb/devices/usb<healthy>/$f)" …/usb<N>/$f`
-It bounces **every fixture under that root port** — on ci that is up to 25
-devices. Hold the affected boards' locks first if you can, but note
-`hil_lock.py` uses `LOCK_EX | LOCK_NB` and so fails immediately when CI already
-holds them; there is no wait-for-lock. When CI is mid-run you are choosing
-between bouncing its fixtures and leaving the bus wedged for everything. The
-automated path in `usbtest.py` takes no locks at all and accepts that collateral
-deliberately: by the time a D-state wedge exists the convoy will take the bus
-down anyway.
+## 3. The rungs — go straight to the one triage names
+
+**Rung 1 — wedged DUT: reset it through its own probe.**
+
+```bash
+printf "r\ng\nq\n" > /tmp/rec.jlink
+JLinkExe -device <DEV> -if SWD -speed 4000 -SelectEmuBySN <probe-sn> \
+ -autoconnect 1 -nogui 1 -CommandFile /tmp/rec.jlink
+```
-(The VBUS mechanism is verified on the ci rig — the leaf hubs report
-`bmAttributes=e0`, "self-powered", but are physically bus-powered with no adapter,
-so a root-port cut really does kill downstream power. Do not re-derive this from
-the descriptor; it lies. Not yet confirmed against a live D-state wedge. If
-`uhubctl` itself hangs, the convoy has already spread — escalate.)
+Reset **before** park-flash: non-destructive (the firmware under test survives
+for autopsy), no flash wear, and no bad park image — a `wfe`/`wfi` park has
+bricked SWD on mimxrt1064_evk and max32666fthr through a power cycle.
+`ResetTarget` measures 128-129 ms; cleared 57 → 0, 26 → 0 and 5 → 0 D-state
+processes, single shot each. Mechanism: chip reset drops the pull-up →
+`usb_hcd_flush_endpoint` unlinks the URB `-ESHUTDOWN` (hcd.c:1783) → the
+completion fires → the ioctl returns → the lock releases.
-If `root-cycle` does not free the D-state process, there is no software cure
-left: ask the operator for a full PVE **host** power cycle. A VM reboot is NOT
-reliable (downstream hubs can latch up across the PCIe reset and need a physical
-replug), and a graceful reboot stalls on the D-state process anyway. Do NOT fall
-through to `pci-rebind` (see next).
+Works on i.MX RT (`USBCMD.RS` = 0 detaches, RT1050 RM Rev 3 p.2453) **and on
+DWC2** — measured 2026-08-16 on stm32f407disco: `r; g` gave
+`usb 13-2.2: USB disconnect, device number 107`, re-enumerating 325 ms later.
+(A bare **halt** does not: the core keeps running with the pull-up asserted.)
-**`pci-rebind` can strand the controller driverless.** Its unbind succeeds but,
-with a D-state process still holding a URB, the *re-bind* hangs — leaving the
-PCI device with **no driver** (`/sys/bus/pci/devices/<addr>/driver` gone) and the
-whole controller's fixtures offline. A second `pci-rebind` then dies with "no
-driver bound". Recover with `pci-bind <addr>` (re-attaches the xHCI driver);
-if that also hangs because the D-state URB is unkillable, only a full PVE host
-power cycle (operator action) recovers. The Renesas binds via `xhci-pci-renesas` (firmware loader), others via
-`xhci_hcd` — `pci-bind` auto-tries both, or pass the driver explicitly.
+**Park-flash** (`--recover-board`/`--recover-fw`, what `usbtest.py` automates) is
+the fallback where the reset cannot reach the peripheral. Delivery must be
+convoy-safe: **openocd pinned with `vid_pid`**, or esptool (`-p <ttyACM>`).
+JLinkExe selects by serial, which needs `libusb_open`, so it needs the shield.
-**Ordering is critical.** `authorized`/`rebind`/`pci-rebind` all take the
-per-device lock the stuck ioctl holds — they block and join the convoy, and
-soon every libusb tool (uhubctl, JLinkExe) hangs too. Worse, a blocked
-`pci-rebind` grabs the PCI device lock on its way in and can wedge the whole
-function, after which **only a full PVE host power cycle recovers**. `root-cycle`
-first, and never `pci-rebind` a D-state wedge.
+**Rung 2 — wedged PROBE: `root-cycle`.** A probe has no probe to reset it, so the
+port-side drop is the only lock-free lever left. It commands the ROOT hub and
+never touches the wedged device's lock.
-**If no** (device merely dead or silent), escalate gently:
+```bash
+sudo usb_recover.sh root-cycle <busport> [expected-serial]
+```
-1. `authorized <busport>` — re-enumerates just that device
-2. `rebind <busport>` — re-probe; also worth trying on the parent hub's busport
-3. `hub-cycle <busport>` — VBUS cycle of the feeding port, walking up to the
- root port; may bounce sibling fixtures on ganged hubs
-4. `pci-rebind <pciaddr>` — last resort: bounces every fixture on that controller
+Bounces **every fixture under that root port** (up to 25 here). Renesas `ppps`
+disables D+/D− only — VBUS stays up, so it is a forced re-enumeration, not a
+power cycle. Success is the sysfs inode changing, not uhubctl's exit code.
-## Finding targets
+**Rung 3 — terminal case: a driver ioctl that OWNS the lock.** No software cure:
+the task is uninterruptible and SIGKILL is queued, not delivered. Reboot with
+**sysrq**, never `reboot(2)` — a graceful reboot runs `device_shutdown()`, which
+takes every device lock and stalls on the wedged one.
```bash
-grep -l <SERIAL> /sys/bus/usb/devices/*/serial # serial -> busport (dir name)
-readlink -f /sys/bus/usb/devices/usb<N> # bus N -> its PCI addr in the path
+echo b | sudo tee /proc/sysrq-trigger # after: sync; sudo umount -a
```
-Rig layout (2026-07-15, two Renesas uPD720201 cards; bus numbers renumber every
-boot — re-derive with `readlink`): AMD `0000:02:00.0` = the debug-probe tree
-(J-Links, ST-Links, WCH-Links), no port power switching; Renesas `0000:01:00.0`
-and `0000:03:00.0` = DUT device hubs + serial fixtures, and ALL their root-hub
-ports have real per-port power (`ppps`, 4+4 each) — `sudo uhubctl -l <bus> -p
-<port> -a cycle` cuts VBUS to the leaf hub on that port. The 1a40:0201 leaf
-hubs themselves claim "ganged" switching but do not actually cut power.
+**Rung 4 — hypervisor.** ci.lan only, and never needed in eight recorded wedges:
+`qm stop <vmid> && qm start <vmid>` from the PVE host. A VM *reboot* is not
+reliable — hubs can latch across the PCIe reset.
+
+## 3b. If the CONTROLLER is dead, not a device
+
+Signature: `xhci-pci-renesas <addr>: Timeout while waiting for setup device
+command`, devices on that controller failing to enumerate, or its buses gone —
+as opposed to ONE device wedged. The rungs above cannot help; the controller
+itself needs re-initialising.
+
+```bash
+sudo usb_recover.sh pci-rebind <pciaddr> # unbind + bind the whole xHCI
+sudo usb_recover.sh pci-bind <pciaddr> # only if it ends up driverless
+```
+
+Measured on ci.lan 2026-08-17 02:34:41 after a `hub-cycle` failed to take: unbind
+deregistered buses 17 and 18, the re-bind registered new buses **1 and 2** one
+second later, and every fixture re-enumerated. **It renumbers every bus that
+controller owns**, so hold all affected boards' locks first (`hil_lock.py hold
+--all`) and re-derive busports afterwards.
+
+Do NOT reach for it while a device-lock convoy is live — see Common mistakes.
+
+## 4. If nothing is in D state
+
+The device is dead or silent, not wedged. `sudo usb_recover.sh authorized
+<busport>` unconfigures and reconfigures it (`usb_set_configuration(dev, -1)`
+then re-choose, hub.c) — it fixes stale driver/interface state, does **not**
+replug: the `usb_device` survives, so most probes keep their sysfs node. If that
+does not take, the device is wedged rather than silent — go to rung 1 or 2.
+`resolve <dev-node>` maps `/dev/ttyACM3` → busport.
+
+**It takes `usb_lock_device` uninterruptibly** (hub.c `usb_deauthorize_device`),
+so it is safe only while nothing is in D state.
+
+## 5. Before declaring the rig healthy
+
+```bash
+ps -eo stat,args | awk '$1 ~ /D/' | wc -l # must be 0
+timeout 15 lsusb # rc 0 and a sane device count
+sudo uhubctl -l <bus> -p <port> # "0000 off" = never came back
+sudo uhubctl -l <bus> -p <port> -a on
+```
+
+Observed: 5 boards missing with a completely clean D-state list, because
+`usb17-port2` sat at `disable=1`.
## Common mistakes
-- `resolve` takes a **/dev node**, not a busport or serial ("no such device node").
-- `authorized`/`rebind`/`hub-cycle`/`root-cycle` take a **busport** (`3-4.7`);
- `pci-rebind`/`pci-bind` take a **PCI addr**.
-- Command produces no output and doesn't return → it is blocked on the device
- lock: a D-state holder exists; see above.
-- Trying `pci-rebind` on a D-state hang — its re-bind hangs and strands the
- controller **driverless**; recover with `pci-bind <addr>`, or a PVE host power
- cycle if the D-state URB is unkillable. Use `root-cycle` for D-state, never
- `pci-rebind`.
-- Writing `/sys/bus/pci/devices/<addr>/reset` because the attribute is there. No
- rig controller has FLR, so it becomes a PCIe bus reset that resets the xHCI
- behind its live driver — the write succeeds, the card is halted for good, and
- only a PVE host power cycle brings it back. Use `root-cycle`.
-- `root-cycle` bounces **every** fixture under that root port, not just the target
- — hold the sibling boards' locks first.
-- A J-Link reset (`r; go`) does not disconnect a wedged DUT from the host: the
- DWC2 soft-connect pullup stays up through a core halt, so stuck URBs stay stuck.
+- **`uhubctl -a cycle` on a root port without `-S`.** It writes sysfs
+ `disable`, and `disable_store` takes `usb_lock_device(hdev)` uninterruptibly
+ then calls `usb_disconnect(child)` inside it (port.c) — against a wedged child
+ that blocks while holding the root hub's lock, poisoning the bus.
+ `usb_recover.sh` passes `-S`.
+- **`echo 1 > .../remove`** to make a wedged device "go away": `remove_store` is
+ the one attribute in sysfs.c taking the uninterruptible `usb_lock_device`
+ (sysfs.c:765). It joins the convoy instead of clearing it.
+- **`authorized` on anything wedged** — same uninterruptible lock. Driver
+ unbind/bind (`/sys/bus/usb/drivers/usb/{unbind,bind}`) does the same
+ unconfigure/reconfigure via `usb_generic_driver_disconnect` (generic.c) but ALSO
+ takes the parent hub's lock (`need_parent_lock`), so it is strictly worse; it was
+ removed from `usb_recover.sh` for that reason.
+- **`pci-rebind` for a wedged DEVICE.** It is the cure for a dead CONTROLLER (see
+ below), not for a device-lock convoy: with a live D-state URB the re-bind can
+ hang and leave the controller with **no driver** and every fixture offline
+ (observed once). Recover that with `pci-bind <addr>`.
+- **Writing `/sys/bus/pci/devices/<addr>/reset`** — no rig controller has FLR, so
+ it becomes a bus reset behind a live driver: card halted, host power cycle.
+- **Resetting a victim's board.** Two boards were reset innocently before anyone
+ found the holder. Map by `devnum`, not by which board "should" be running.
+- **Assuming one controller.** Observed: 26 D-state processes across three xHCI
+ controllers, all cleared by one probe reset on one device.
+
+## Rig layout (ci.lan, bus numbers renumber every boot)
+
+`readlink -f /sys/bus/usb/devices/usb<N>` → its PCI address. AMD `0000:02:00.0`
+has no port-power switching; Renesas `0000:01:00.0` (probe tree) and
+`0000:03:00.0`/`0000:05:00.0` (DUT hubs) have real per-port `ppps`.
diff --git a/.claude/skills/usb-kernel-recover/scripts/usb_recover.sh b/.claude/skills/usb-kernel-recover/scripts/usb_recover.sh
index 2230602b9..876e0938b 100755
--- a/.claude/skills/usb-kernel-recover/scripts/usb_recover.sh
+++ b/.claude/skills/usb-kernel-recover/scripts/usb_recover.sh
@@ -4,21 +4,15 @@
#
# Usage:
# sudo usb_recover.sh authorized <busport> # e.g. 3-2 -> deauthorize+reauthorize (re-enumerate, NO VBUS cut)
-# sudo usb_recover.sh rebind <busport> # e.g. 3-2 -> usb driver unbind+bind (re-probe)
-# sudo usb_recover.sh pci-rebind <pciaddr> # e.g. 0000:01:00.0 -> HCD unbind+bind (WHOLE controller)
-# sudo usb_recover.sh pci-bind <pciaddr> [driver] # bind a DRIVERLESS controller (e.g. after a pci-rebind
-# # whose re-bind hung and left it unbound). Auto-tries the xHCI
-# # drivers (xhci-pci-renesas, xhci_hcd) unless one is named.
-# sudo usb_recover.sh hub-cycle <busport> # e.g. 13-1.6 -> uhubctl power-cycle of the port feeding it,
-# # walking upstream (parent hub -> root port) until the device
-# # re-enumerates. Ganged/fake-switching hubs may bounce ALL
-# # siblings; self-powered hubs only reset their uplink, which
-# # is why the walk ends at the root port (real xHCI ppps).
-# sudo usb_recover.sh root-cycle <busport> [serial] # e.g. 13-1.6 -> uhubctl VBUS cut at the ROOT port feeding
+# sudo usb_recover.sh root-cycle <busport> [serial] # e.g. 13-1.6 -> uhubctl port-off/on at the ROOT port feeding
# # it; [serial] is verified against the device and refused on mismatch,
# # skipping the leaf hubs (which fake ganged switching and do not
# # actually cut power). Bounces every sibling under that root port.
# # The D-state escape: no device lock, so it cannot convoy.
+# sudo usb_recover.sh pci-rebind <pciaddr> # e.g. 0000:05:00.0 -> unbind+bind the whole xHCI
+# # controller. For a DEAD CONTROLLER, not a wedged
+# # device: it renumbers every bus it owns.
+# sudo usb_recover.sh pci-bind <pciaddr> [drv] # re-attach a driver to a DRIVERLESS controller
# sudo usb_recover.sh resolve <devnode> # e.g. /dev/ttyACM3 -> print its <busport> (no privilege needed)
set -euo pipefail
@@ -28,6 +22,34 @@ DRIVER_RE='^[A-Za-z0-9_-]+$'
die() { echo "usb_recover: $*" >&2; exit 1; }
+lock_read() {
+ # Read an attribute served under the device lock (serial, product) with a 2s bound.
+ # Prints the value, '' when the attribute is absent, or '?' when it did not answer.
+ #
+ # Bounding these is load-bearing, not defensive: they are the FIRST thing root-cycle
+ # does, so on a real wedge an unbounded read blocks before reaching uhubctl at all
+ # (observed live: one attempt sat 3h; three concurrent invocations all frozen there).
+ # The operator then reads that as "recovery didn't work" and escalates to a bare
+ # `uhubctl -a cycle`, which tears the subtree down and blocks holding the ROOT HUB
+ # lock -- taking the whole bus with it. That is how one wedge becomes an incident.
+ #
+ # `timeout` is enough, though this said for a while that it was not (claiming the read
+ # sits in D state, where SIGKILL is not delivered, so timeout waitpid()s forever). It
+ # does not: v6.12.101 drivers/usb/core/sysfs.c takes the lock for every READ through
+ # usb_lock_device_interruptible -> device_lock_interruptible -> mutex_lock_interruptible,
+ # so the waiter sleeps INTERRUPTIBLY and SIGTERM ends it. Uninterruptible is the usbfs
+ # ioctl HOLDER, not us. The abandon-a-background-reader dance that claim justified is
+ # gone, and with it a fail-open where an absent attribute answered '?' -- the wedge
+ # signature, which root-cycle reads as "cannot confirm serial, proceed".
+ local v rc=0
+ # `|| rc=$?`, never a bare assignment: under this script's `set -e` a command
+ # substitution that FAILS (an absent attribute -- most hubs and probes have no
+ # iSerialNumber, and `product` is often missing) exits the whole recovery script.
+ v=$(timeout 2 cat "$1" 2>/dev/null) || rc=$?
+ [ "$rc" -eq 124 ] && { echo '?'; return; } # timed out: nobody answered
+ printf '%s\n' "$v"
+}
+
# Generation marker for "did this device actually re-enumerate". A real disconnect destroys the
# usb_device and its sysfs kobject; reconnecting creates a new one, and kernfs hands out inode
# numbers monotonically, so the directory inode changes. Verified on the rig: ports re-enumerated
@@ -49,9 +71,6 @@ die() { echo "usb_recover: $*" >&2; exit 1; }
# The trailing slash is load-bearing: /sys/bus/usb/devices/<busport> is a SYMLINK with its own
# separate inode, so without it stat reports the link rather than the device it points at, and the
# value would never change. Do not "tidy" it away.
-sysfs_gen() { stat -c %i "/sys/bus/usb/devices/$1/" 2>/dev/null || echo none; }
-usage() { grep -E '^# sudo usb_recover' "$0" >&2; exit 2; }
-
# Refuse to touch a PCI function that is not a USB controller (class 0x0c03xx), so a stray or
# mistyped BDF can't unbind/reset an unrelated device (storage, NIC) on a shared HIL host.
require_usb_controller() {
@@ -60,6 +79,9 @@ require_usb_controller() {
[[ "$cls" =~ ^0x0c03 ]] || die "$addr is not a USB controller (class $cls); refusing"
}
+sysfs_gen() { stat -c %i "/sys/bus/usb/devices/$1/" 2>/dev/null || echo none; }
+usage() { grep -E '^# sudo usb_recover' "$0" >&2; exit 2; }
+
# Resolve a /dev node (ttyACMx, ttyUSBx, sgN, ...) up to its USB device busport.
resolve() {
local node=$1 syspath dev
@@ -89,13 +111,6 @@ case "$action" in
echo 0 > "$d/authorized"; sleep 1; echo 1 > "$d/authorized"
echo "re-authorized $target"
;;
- rebind)
- [[ "$target" =~ $USBPATH_RE ]] || die "bad usb path: $target"
- [ -e "/sys/bus/usb/devices/$target" ] || die "no such usb device: $target"
- echo "$target" > /sys/bus/usb/drivers/usb/unbind; sleep 1
- echo "$target" > /sys/bus/usb/drivers/usb/bind
- echo "rebound $target"
- ;;
pci-rebind)
[[ "$target" =~ $PCI_RE ]] || die "bad pci addr: $target"
require_usb_controller "$target"
@@ -128,39 +143,10 @@ case "$action" in
die "could not bind $target with a known xHCI driver; pass the driver explicitly"
fi
;;
- hub-cycle)
- [[ "$target" =~ $USBPATH_RE ]] || die "bad usb path: $target"
- UHUBCTL=$(command -v uhubctl || echo /sbin/uhubctl)
- [ -x "$UHUBCTL" ] || die "uhubctl not installed"
- # sysfs generation, not node existence: a disconnect blocked on the device lock leaves the
- # old node (and its idVendor) in place, so an existence check reports success without anything
- # having happened -- and the walk to the root port, which is the part that actually cuts power
- # on these fake-ganged leaf hubs, would never run.
- gen=$(sysfs_gen "$target")
- dev="$target"
- while :; do
- if [[ "$dev" =~ ^([0-9]+)-([0-9]+)$ ]]; then # parent is the root hub
- loc="${BASH_REMATCH[1]}"; port="${BASH_REMATCH[2]}"; up=""
- else # parent is a downstream hub
- loc="${dev%.*}"; port="${dev##*.}"; up="$loc"
- fi
- echo "hub-cycle: power-cycling hub $loc port $port (feeds $dev)"
- "$UHUBCTL" -l "$loc" -p "$port" -a cycle -d 5 -f || echo " (uhubctl failed at $loc; walking up)"
- for _ in $(seq 1 10); do
- sleep 1
- now=$(sysfs_gen "$target")
- if [ "$now" != none ] && [ "$now" != "$gen" ]; then
- echo "recovered: $target re-enumerated (gen $gen -> $now)"; exit 0
- fi
- done
- [ -n "$up" ] || break
- dev="$up"
- done
- die "hub-cycle: $target still not enumerated after cycling up to the root port"
- ;;
root-cycle)
- # VBUS cut at the ROOT port, where xHCI ppps is real. Unlike hub-cycle this does not walk up
- # from the leaf (the 1a40:0201 hubs claim ganged switching but never cut power) and never
+ # Port-off/on at the ROOT port. NOTE: the Renesas ppps only disables D+/D- (VBUS stays up),
+ # so this is a forced re-enumeration, not a power cycle. It goes straight at the root port --
+ # no leaf walk (the 1a40:0201 hubs claim ganged switching but never cut power) -- and never
# writes the wedged device's sysfs or takes its lock, so it cannot join a D-state convoy.
# uhubctl exits 0 even when it does nothing ("No compatible devices detected" still returns
# 0), so its status proves nothing -- the sysfs_gen check below is the only real verdict.
@@ -174,21 +160,33 @@ case "$action" in
# wrong target is at least visible.
[ -e "/sys/bus/usb/devices/$target" ] || die "no such usb device: $target"
idf="/sys/bus/usb/devices/$target"
- serial=$(cat "$idf/serial" 2>/dev/null || echo -)
+ serial=$(lock_read "$idf/serial")
expect=${3:-}
- [ -z "$expect" ] || [ "$expect" = "$serial" ] || \
+ if [ -n "$expect" ] && [ "$serial" = '?' ]; then
+ # Warn and PROCEED: an unreadable serial is the wedge signature itself, so refusing
+ # here would block the cure on exactly the condition it exists for. The identity
+ # guard is lost for this call -- say so, because the cost of a wrong target is the
+ # whole subtree.
+ echo "root-cycle: WARNING $target's serial did not answer (it is wedged), so '$expect'" \
+ "could NOT be confirmed; proceeding, but verify the busport if siblings drop" >&2
+ elif [ -n "$expect" ] && [ "$expect" != "$serial" ]; then
die "root-cycle: $target has serial '$serial', expected '$expect' — stale busport, refusing"
+ fi
+ # idVendor/idProduct are usb_descriptor_attr_le16: served WITHOUT the device lock, so
+ # a plain cat is safe on a wedged device. serial/product are usb_string_attr and are not.
echo "root-cycle: target $target is $(cat "$idf/idVendor" 2>/dev/null || echo -):$(cat "$idf/idProduct" 2>/dev/null || echo -)" \
- "serial=$serial product=$(cat "$idf/product" 2>/dev/null || echo -)"
+ "serial=$serial product=$(lock_read "$idf/product")"
bus=${target%%-*}; rest=${target#*-}; rootport=${rest%%.*}
gen=$(sysfs_gen "$target")
- echo "root-cycle: cutting VBUS on bus $bus root port $rootport (feeds $target, bounces its siblings)"
- # -S is load-bearing. By default uhubctl writes /sys/.../usb<bus>-port<n>/disable (verified:
+ echo "root-cycle: disabling D+/D- on bus $bus root port $rootport (no VBUS cut; feeds $target, bounces its siblings)"
+ # -S is load-bearing. By default uhubctl writes /sys/.../usb<bus>-port<n>/disable (observed:
# two O_WRONLY opens per cycle), and the kernel's disable_store() takes the ROOT HUB's lock and
- # synchronously usb_disconnect()s the child BEFORE cutting power -- against a wedged device that
- # blocks on the lock we are trying to free, so power would never drop and uhubctl would D-state
- # holding the root hub's lock, poisoning the whole bus. -S forces the libusb path, which sends
- # the power-off control transfer straight to the root hub with no child-disconnect in front.
+ # synchronously usb_disconnect()s the child BEFORE cutting power -- confirmed in v6.12.96
+ # drivers/usb/core/port.c: usb_lock_device(hdev), the UNINTERRUPTIBLE variant, then
+ # usb_disconnect(&port_dev->child) inside it. Against a wedged device that disconnect blocks on
+ # the very lock we are trying to free, so power never drops and uhubctl D-states holding the
+ # root hub's lock, poisoning the whole bus. -S forces the libusb path, which sends the
+ # power-off control transfer straight to the root hub with no child-disconnect in front.
"$UHUBCTL" -S -l "$bus" -p "$rootport" -a cycle -d 5 \
|| die "uhubctl failed to cycle bus $bus port $rootport"
for _ in $(seq 1 10); do
diff --git a/.claude/skills/usbtest/SKILL.md b/.claude/skills/usbtest/SKILL.md
index 76a01839c..32c2be913 100644
--- a/.claude/skills/usbtest/SKILL.md
+++ b/.claude/skills/usbtest/SKILL.md
@@ -29,6 +29,11 @@ python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case
```
- **Always `--keep-binding`**: the cleanup unbind path has wedged host xHCIs (`usb_hcd_alloc_bandwidth`).
+- CI (`hil_test.py`) additionally passes `--budget`, `--outer-timeout` and
+ `--recover-board`/`--recover-fw`: on a HUNG case the battery aborts, RESETS the DUT
+ through its roster probe (non-destructive, ~130 ms) and reflashes only if that does not
+ clear the wedge (see usb-kernel-recover). Manual runs without those flags leave a HUNG
+ device wedged and skip cleanup — expected; reset or reflash it yourself.
- Always settle a few seconds after flashing — enumeration can bounce once; testusb into the gap sees
the device drop mid-case.
- On a CI rig: stop the actions runner before touching hardware; restart after. Never run two
@@ -87,6 +92,28 @@ python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case
| 5 | EIO — iso packet errors (check `dmesg`: "N errors out of M") |
| 71 | EPROTO — device answered wrong / too slow (after HC retries) |
+**Step 0 — read what the case actually does.** The kernel module is ground truth;
+the table above is a summary. Do this before theorising, and always before deciding
+whether a hung case is recoverable. Fetch the rig's exact version (`uname -r`):
+
+```bash
+curl -sO "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/drivers/usb/misc/usbtest.c?h=v6.12.96"
+# case N lives under `case N:` in usbtest_do_ioctl(); tools/usb/testusb.c maps the flags:
+# -c = param.iterations, -s = param.length, -g = param.sglen (NOT what they read like)
+```
+
+- **Real traffic and pass criteria.** Case 24 at `-c 256 -s 1024 -g 8` is 256 rounds
+ of 8 bulk-OUT URBs, unlinking `urbs[num-4]`/`urbs[num-2]` and requiring
+ `-ECONNRESET` on those two plus normal completion on the other 6 — not the
+ "256 URBs" the flags suggest.
+- **Whether the wait is bounded** — decisive for recovery. `simple_io` uses
+ `wait_for_completion_timeout` (:481); the unlink paths use a bare
+ `wait_for_completion` (:1502, :1615). A device stalling there wedges the ioctl in
+ **D state permanently** — it holds the device lock, so nothing recovers it
+ (usb-kernel-recover, "The terminal case"). Knowing this first stops you burning
+ the rig on attempts that cannot work.
+- **Which DCD path is implicated**, precisely rather than by category.
+
1. `usbtest.py` per-case output + its captured `dmesg` (`TEST n` markers bracket each case).
2. **usbmon** (`usbmon` skill): URB-level ground truth. **It cannot show data toggles or NAKs** —
a toggle desync and a dead endpoint look identical (Submits without Completes); distinguish
@@ -94,7 +121,7 @@ python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case
3. **On-device gdb/openocd**: read the EP control registers and DCD structs at the hang.
4. Heisenbugs (vanish under logging): RAM ring-buffer trace dumped over openocd; for silent lockups
JLink PC-sampling (`halt`+`regs` repeatedly — a pinned PC names the spin).
-5. **Cross-check the reference manual** (calibre library) before changing any register-level code —
+5. **Cross-check the reference manual** (`read-doc` skill) before changing any register-level code —
per CLAUDE.md, and because comments/assumptions in DCDs have been wrong about hardware caps.
6. Check the vendor's **silicon errata** early for timing/DMA hangs (an unimplemented erratum
workaround caused a case-10 hang on one port).
@@ -121,3 +148,6 @@ python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case
- "It works on gcc" → clang/IAR/LTO/make still pending.
- "Fixed iso IN" → apply the same exemption to iso OUT (toggle logic is symmetric).
- A clean single-board run does not validate concurrent/fleet behavior — batteries serialize.
+- Reasoning about a case from its name or table row → open `usbtest.c` (step 0). The
+ flags don't mean what they look like, and recoverability is a property of that
+ case's wait, not of the rig.
diff --git a/.claude/workflows/driver-review.js b/.claude/workflows/driver-review.js
index 3638aa179..255b8ac74 100644
--- a/.claude/workflows/driver-review.js
+++ b/.claude/workflows/driver-review.js
@@ -16,7 +16,7 @@ if (!args || !Array.isArray(args.dirs) || args.dirs.length === 0) {
const DIMS = args.question ? [args.question] : (args.dimensions || [
'correctness: transfer state machines, endpoint bookkeeping, completion and error paths',
'ISR safety: work deferred to task context, shared-state races, register access ordering',
- 'register use vs datasheet and MCU errata: cross-check the reference manual AND errata sheets in $HOME/Documents/calibre-library; a missing erratum workaround is a finding',
+ 'register use vs datasheet and MCU errata: cross-check the reference manual AND errata sheets via the read-doc skill (python3 .claude/skills/read-doc/search.py <keywords>); a missing erratum workaround is a finding',
'style: repo conventions (TU_ASSERT, no dynamic allocation, include order, naming)',
])
if (!DIMS.length) {
diff --git a/.claude/workflows/hil-validate.js b/.claude/workflows/hil-validate.js
index 50559135f..136f9075e 100644
--- a/.claude/workflows/hil-validate.js
+++ b/.claude/workflows/hil-validate.js
@@ -26,8 +26,8 @@ const runBoard = (b) => agent(
? 'THE USER HAS EXPLICITLY AUTHORIZED FORCING: run hil_test.py with HIL_NO_BOARD_LOCK=1 in the environment (bypasses the board lock check; do NOT release or kill the existing holder). '
: 'If the run fails because the board lock is held (a dev session or concurrent CI job), report pass=false and set detail to start EXACTLY with "board locked:" followed by the holder JSON verbatim — never force the lock. ') +
'Reserve the phrase "board locked" strictly for lock contention; describe a frozen or non-enumerating board as "unresponsive" instead. ' +
- `Firmware is in examples/cmake-build-${b}. Use the config for this host (hostname first), single-board flag -b ${b}, Bash timeout >= 20 min, never cancel early. ` +
- 'On non-lock failures retry once with -v -r 1 (one verbose attempt for diagnosis — the first run already did the flake-retries). wedged=true if the board/fixture is unresponsive after the run (capture dmesg | tail -50 into detail).',
+ `Firmware is in examples/cmake-build-${b}. Use the config for this host (hostname first), single-board flag -b ${b}. Run hil_test.py as a BACKGROUND Bash task and wait for it (a stuck fleet runs to its pool guard, 60 min by default — beyond any foreground timeout); never cancel it early. ` +
+ 'On non-lock failures retry once with -v -r 1 (one verbose attempt for diagnosis; note a usbtest battery that produced per-case verdicts is NOT auto-retried, so its result already stands). wedged=true if the board/fixture is unresponsive after the run (capture dmesg | tail -50 into detail).',
{ label: `hil:${b}`, phase: 'HIL', agentType: 'hil-operator', schema: HIL },
)
diff --git a/.claude/workflows/pr-babysit.js b/.claude/workflows/pr-babysit.js
index 8bb414114..406213a5f 100644
--- a/.claude/workflows/pr-babysit.js
+++ b/.claude/workflows/pr-babysit.js
@@ -109,7 +109,7 @@ const history = []
const repliedIds = new Set() // issue comments can't be thread-resolved, so they re-harvest every cycle — never reply twice
for (let cycle = 1; cycle <= maxCycles; cycle++) {
const t = await agent(
- `Triage PR #${args.pr}. If checks are still running, wait for them first (gh pr checks ${args.pr} --watch, Bash timeout >= 30 min). ` +
+ `Triage PR #${args.pr}. If checks are still running, wait for them first (gh pr checks ${args.pr} --watch as a BACKGROUND Bash task; the foreground timeout is capped at 10 min). ` +
'Then follow your triage procedure: classify CI failures, re-run infra ones, harvest and adversarially validate bot review findings, draft replies for invalid/stale ones.',
{ label: `triage#${cycle}`, phase: 'Triage', agentType: 'pr-monitor', schema: TRIAGE },
)
diff --git a/.github/workflows/ci_set_matrix.py b/.github/scripts/ci_set_matrix.py
index 50ada5964..50ada5964 100755
--- a/.github/workflows/ci_set_matrix.py
+++ b/.github/scripts/ci_set_matrix.py
diff --git a/test/hil/hil_ci_set_matrix.py b/.github/scripts/hil_ci_set_matrix.py
index bca989bd1..65f50788e 100644
--- a/test/hil/hil_ci_set_matrix.py
+++ b/.github/scripts/hil_ci_set_matrix.py
@@ -7,9 +7,11 @@ def _resolve_config_path(config_file):
if os.path.exists(config_file):
return config_file
- script_relative = os.path.join(os.path.dirname(__file__), config_file)
- if os.path.exists(script_relative):
- return script_relative
+ # bare roster names resolve against the repo's test/hil (this script lives in
+ # .github/scripts); build.yml passes explicit paths, this is for hand-runs
+ repo_relative = os.path.join(os.path.dirname(__file__), '..', '..', 'test', 'hil', config_file)
+ if os.path.exists(repo_relative):
+ return repo_relative
raise FileNotFoundError(f'Config file not found: {config_file}')
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bdef81553..8f6014f48 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -41,7 +41,7 @@ jobs:
- '.github/actions/**'
- '.github/workflows/build.yml'
- '.github/workflows/build_util.yml'
- - '.github/workflows/ci_set_matrix.py'
+ - '.github/scripts/**'
set-matrix:
runs-on: ubuntu-latest
@@ -72,10 +72,16 @@ jobs:
# so a missing origin/<base>, a shallow-clone hiccup or a selector traceback
# must fall back to the FULL matrix (no --select, run=true, no args) instead
# of failing the job. Same fail-open shape as pr_comment.yml's `|| true`.
+ #
+ # The selector's own unit suite gates it (stdlib-only, seconds): a selector
+ # whose tests fail can still exit 0 with valid-but-WRONG JSON -- fail-open alone
+ # never catches that class, and the pre-commit hil-test hook is a separate,
+ # advisory workflow that nothing here can `needs:`. Test-failing selector =>
+ # full matrix, same as a crashing one.
SELECT_JSON=''
- if ! python3 test/hil/test_hil_select.py; then
- echo "::error::hil_select unit tests failed - falling back to the full HIL matrix"
- elif ! SELECT_JSON=$(python3 test/hil/hil_select.py --base "origin/$BASE_REF" test/hil/tinyusb.json test/hil/hfp.json); then
+ if ! python3 test/hil/test/test_hil_select.py; then
+ echo "::warning::hil_select unit suite failed - falling back to the full HIL matrix"
+ elif ! SELECT_JSON=$(python3 test/hil/helper/hil_select.py --base "origin/$BASE_REF" test/hil/tinyusb.json test/hil/hfp.json); then
echo "::warning::hil_select failed - falling back to the full HIL matrix"
SELECT_JSON=''
fi
@@ -113,7 +119,7 @@ jobs:
SELECT: ${{ steps.hil-select.outputs.select }}
run: |
# build matrix
- MATRIX_JSON=$(python .github/workflows/ci_set_matrix.py)
+ MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)
echo "matrix=$MATRIX_JSON"
echo "matrix=$MATRIX_JSON" >> $GITHUB_OUTPUT
@@ -121,13 +127,13 @@ jobs:
# Scoping is best-effort too: fall back to the unscoped (full) matrix.
HIL_MATRIX_JSON=''
if [ -n "$SELECT" ]; then
- HIL_MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py --select "$SELECT" test/hil/tinyusb.json test/hil/hfp.json) || HIL_MATRIX_JSON=''
+ HIL_MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py --select "$SELECT" test/hil/tinyusb.json test/hil/hfp.json) || HIL_MATRIX_JSON=''
if [ -z "$HIL_MATRIX_JSON" ]; then
echo "::warning::scoped HIL matrix failed - falling back to the full HIL matrix"
fi
fi
if [ -z "$HIL_MATRIX_JSON" ]; then
- HIL_MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py test/hil/tinyusb.json test/hil/hfp.json)
+ HIL_MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py test/hil/tinyusb.json test/hil/hfp.json)
fi
echo "hil_matrix=$HIL_MATRIX_JSON"
echo "hil_matrix=$HIL_MATRIX_JSON" >> $GITHUB_OUTPUT
@@ -338,7 +344,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- # These names are the bucket keys of test/hil/hil_ci_set_matrix.py: every
+ # These names are the bucket keys of .github/scripts/hil_ci_set_matrix.py: every
# non-esptool roster board must land in one of them (esptool boards go to
# 'esp-idf', built by hil-build-esp below). hil_ci_set_matrix.py rejects a
# board whose "toolchain" is not a bucket, so a new bucket must be added in
@@ -379,6 +385,14 @@ jobs:
hil-tinyusb:
needs: [ hil-build, set-matrix ]
name: hil-tinyusb (${{ matrix.display }})
+ # Above hil_test.py's pool guard (HIL_POOL_TIMEOUT, 60 min) so the guard fires first
+ # and still gets to write its report. The 30 min on top is what the job pays OUTSIDE
+ # the guard clock: workspace cleanup, checkout, the multi-board artifact merge and
+ # the D-state note before it; kill_worker_children, shutdown_pool's 30 s grace, the
+ # report write and the upload after it. On a multi-stray convoy that tail alone is
+ # minutes, and a ceiling below guard+tail cancels the job before hil_report.md exists
+ # -- the inversion this branch removes. Both legs share the script and the guard.
+ timeout-minutes: 90
strategy:
fail-fast: false
matrix:
@@ -395,6 +409,9 @@ jobs:
test_args: ''
runs-on: ${{ matrix.runner }}
env:
+ # HIL_POOL_TIMEOUT deliberately unset: hil_test.py's 60 min default is below every
+ # ceiling here, so ceiling > guard holds by construction. Pin it to SHORTEN a run
+ # only -- pinning it above a ceiling re-inverts the two.
HIL_JSON: ${{ matrix.hil_json }}
steps:
- name: Set HIL report dir (per run+job; persists across run attempts)
@@ -482,6 +499,11 @@ jobs:
needs: [ hil-build-esp, set-matrix ]
name: hil-tinyusb (tinyusb-esp.json)
runs-on: [ self-hosted, X64, hathach, hardware-in-the-loop ]
+ # above hil_test.py's pool guard (60 min) with room for the pre-pool checkout
+ # and the post-guard sweep + report upload, so its own guard still writes a report;
+ # only a job wedged past that (unkillable D-state worker) hits this ceiling, which
+ # must exist because the runner has one job slot and holds every queued job hostage
+ timeout-minutes: 90
env:
HIL_JSON: test/hil/tinyusb.json
TEST_ARGS: '--flasher esptool'
@@ -564,7 +586,13 @@ jobs:
github.repository_owner == 'hathach' &&
!(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true)
runs-on: [ self-hosted, Linux, X64, hifiphile ]
- timeout-minutes: 30
+ # Unlike the hil-tinyusb jobs, this one BUILDS with IAR in the same job before running
+ # hil_test.py -- hfp.json's 3 boards, 4 variant entries, "up to 30 minutes" (see the
+ # comment above the selection step). The ceiling has to cover build + the 60 min pool
+ # guard + overhead, or GitHub cancels before the guard can write its report -- the
+ # inversion this branch removes. 30 + 60 = 90; the remaining 30 is the full-history
+ # checkout, get_deps, the post-guard sweep and the report upload.
+ timeout-minutes: 120
env:
IAR_LMS_BEARER_TOKEN: ${{ secrets.IAR_LMS_BEARER_TOKEN }}
PYTHONUNBUFFERED: '1'
@@ -601,7 +629,12 @@ jobs:
# failures cannot kill hfp coverage - a selector failure here must likewise
# fall back to the full hfp matrix (no hil_select.json, no SEL_* vars), never
# fail the job.
- if ! python3 test/hil/hil_select.py --base "origin/$BASE_REF" test/hil/hfp.json > hil_select.json; then
+ if ! python3 test/hil/test/test_hil_select.py; then
+ echo "::warning::hil_select unit suite failed - running the full hfp matrix"
+ rm -f hil_select.json
+ exit 0
+ fi
+ if ! python3 test/hil/helper/hil_select.py --base "origin/$BASE_REF" test/hil/hfp.json > hil_select.json; then
echo "::warning::hil_select failed - running the full hfp matrix"
rm -f hil_select.json
exit 0
@@ -629,9 +662,9 @@ jobs:
if: env.SEL_RUN != 'false'
run: |
if [ -f hil_select.json ]; then
- MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py --select "$(cat hil_select.json)" test/hil/hfp.json)
+ MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py --select "$(cat hil_select.json)" test/hil/hfp.json)
else
- MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py test/hil/hfp.json)
+ MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py test/hil/hfp.json)
fi
# Each variant carries its own --build-name/--cflag, which are global to a
# single build.py invocation — so keep one matrix entry per line and build
@@ -648,6 +681,13 @@ jobs:
- name: Build
if: env.SEL_RUN != 'false'
+ # Bounded SEPARATELY from the job. This is the only HIL job that builds inline
+ # (hil-tinyusb downloads artifacts), and the job ceiling went 30 -> 120 to give the
+ # HIL step room -- which would hand a stalled IAR build the whole two hours on the
+ # shared self-hosted runner, never reaching hil_test.py or the report upload. That
+ # is the stranded-runner-with-no-report failure this branch exists to prevent.
+ # Typical full build here is a few minutes; 30 leaves generous headroom.
+ timeout-minutes: 30
run: |
readarray -t ENTRIES < hil_build_entries.txt
for entry in "${ENTRIES[@]}"; do
@@ -666,7 +706,12 @@ jobs:
fi
# empty/absent on a non-PR event or a selector fallback -> full hfp matrix
SEL_ARGS=$(cat hil_sel_args.txt 2>/dev/null || true)
- python3 test/hil/hil_test.py $SEL_ARGS hfp.json
+ # --retry 1, like the other two HIL legs. The pool guard is a FLAT 3600s and
+ # does NOT scale with max_retry, so argparse's default of 3 would multiply the
+ # serialized usbtest tail (hfp.json runs four batteries) by three against an
+ # unchanged guard -- on a runner with a single job slot that queues every other
+ # job behind it.
+ python3 test/hil/hil_test.py --retry 1 $SEL_ARGS hfp.json
- name: Upload HIL report
if: always() && github.event_name == 'pull_request'
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index 70dd3894d..09f912bd3 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -30,6 +30,7 @@ jobs:
#cd test/unit-test
#ceedling test:all
+ # runs --all-files, so the hil-test hook fires here regardless of its `files:` scope
- name: Run pre-commit
uses: pre-commit/[email protected]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e87b935dd..3d9c8482b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -48,6 +48,27 @@ repos:
types_or: [c, header]
language: system
+ # Two hooks, split by what each suite actually reads. The full discovery run costs
+ # ~55s (deliberate hang/timeout simulations); only test_hil_select (~0.1s) reads
+ # hw/bsp (board.cmake), src (portable dirs + class include graph) and examples
+ # (tusb_config.h per test) -- renaming a board, port dir or example breaks it without
+ # touching test/hil, and catching that here beats waiting for pre-commit CI.
+ # No types_or: the rig rosters (*.json) are inputs too.
+ # examples/device/mtp/src is in scope: test_hil_bounded parses README_TXT_CONTENT
+ # and md5-checks the logo header from there as its MTP fixtures.
+ - id: hil-test
+ name: hil-test
+ files: ^(test/hil/|examples/device/mtp/src/)
+ entry: python3 -m unittest discover -s test/hil/test
+ pass_filenames: false
+ language: system
+ - id: hil-select-test
+ name: hil-select-test
+ files: ^(hw/bsp/|src/|examples/)
+ entry: python3 test/hil/test/test_hil_select.py
+ pass_filenames: false
+ language: system
+
# - id: build-fuzzer
# name: build-fuzzer
# files: ^(src/|test/fuzz/)
diff --git a/CLAUDE.md b/CLAUDE.md
index 94b8192b7..762473714 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,6 +20,7 @@ Bias toward caution over speed. For trivial tasks, use judgment.
- **Safety:** no dynamic allocation; defer ISR work to task context; use `TU_ASSERT()` for error checks; always check return values; include order: C stdlib → tusb common → drivers → classes.
- **Layout:** `src/` core, `hw/{mcu,bsp}/` MCU+BSP, `examples/{device,host,dual}/`, `test/{unit-test,fuzz,hil}/`, `docs/`, `tools/`.
- **Commits/PRs:** imperative mood, scoped changes, link issues, include test/build evidence. After opening a PR, drive it to green: address automated review comments (Copilot/Codex/Claude) and fix failing CI, pushing follow-ups until checks pass and threads resolve. Useful: `gh pr checks <num> --watch`, `gh pr view <num> --comments`.
+- **Deferred work:** work that is worth doing but is a *separate scope* from the current PR — it deserves its own PR, written by a different session. Write it as a **handoff** with the `superpowers:writing-plans` skill, one doc per follow-up, in `docs/superpowers/followup/pr<NNN>-<topic>.md` (the PR it was split out of, so the origin stays traceable). Say what is already established (with citations/measurements), what remains, and why it was split out. Delete the doc when its PR lands. Never bundle unrelated follow-ups into one file.
- **Formatting/lint:** `clang-format` (`.clang-format`), `codespell` (`.codespellrc`); run `pre-commit run --all-files` before submitting.
## Bootstrap
@@ -99,7 +100,8 @@ Use the `pvs` skill (`.claude/skills/pvs/SKILL.md`) — it builds the examples w
## Validation After Changes
-1. `pre-commit run --all-files` — format, spell, unit tests (10-15 s).
+1. `pre-commit run --all-files` — format, spell, unit tests, HIL suites (~55 s; the
+ HIL hooks deliberately exercise real timeouts and hangs).
2. Build at least one board's full example set (Build → "All examples for a board") for modules you touched.
3. Run relevant unit tests; add fuzz/HIL coverage for parsers or protocol state machines.
@@ -116,7 +118,15 @@ Cutting a release — version bump, regenerated files, the per-release changelog
## References
-- MCU reference manuals, datasheets, schematics: before answering register/bitfield/pinout/errata/timing questions from memory or the web — or changing a specific dcd/hcd driver — use the `read-doc` skill (`.claude/skills/read-doc/SKILL.md`) to cross-check against docs in `$HOME/Documents/calibre-library`; tell the user if the needed document is missing (skill no-ops if the library is absent).
+- MCU reference manuals, datasheets, schematics: before answering register/bitfield/pinout/errata/timing questions from memory or the web — or changing a specific dcd/hcd driver — use the `read-doc` skill (`.claude/skills/read-doc/SKILL.md`) to cross-check against the maintainer's document library; tell the user if the needed document is missing (skill no-ops if the library is absent). Never search the library tree directly — the skill owns its location and search.
+- Linux kernel behaviour (usbfs, usbtest, sysfs attributes, device locks, D state): never
+ infer it from symptoms — read the source for the *running* version. It refutes as often
+ as it confirms: it has killed two plausible dcd theories and corrected a recovery skill's
+ own attribute list.
+ ```bash
+ V=$(uname -r | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+') # on the rig: ssh ci.lan uname -r
+ curl -fsSL "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/drivers/usb/core/sysfs.c?h=v$V"
+ ```
- Supported MCUs/boards: `hw/bsp/` and `docs/reference/boards.rst`.
- USB classes: `src/class/{cdc,hid,msc,audio,…}/` — each has `*_device.c` and `*_host.c`.
- Key files: `src/tusb.h`, `src/tusb_config.h`, `tools/get_deps.py`, `tools/build.py`, `test/unit-test/project.yml`.
diff --git a/docs/superpowers/followup/pr3803-flasher-recover.md b/docs/superpowers/followup/pr3803-flasher-recover.md
new file mode 100644
index 000000000..e9fff7480
--- /dev/null
+++ b/docs/superpowers/followup/pr3803-flasher-recover.md
@@ -0,0 +1,280 @@
+# `flasher_recover` Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give the 15 HIL boards whose flasher cannot reach its probe past a poisoned usbfs
+node a second, convoy-safe flasher used only for recovery.
+
+**Architecture:** An optional roster key `flasher_recover` beside `flasher`.
+`hil_flash.recover_flasher(board)` picks it when present; `hil_test` substitutes it into the
+`--recover-board` JSON so `usbtest.py` never learns a second entry exists. Delivery over
+openocd's jlink driver is convoy-safe by construction, but the flash command form must
+differ from the one `flash_openocd` uses, so the recovery gets its own flasher name.
+
+**Tech Stack:** Python 3.13 stdlib, openocd 0.12.0+dev (build 0ce743125 on ci.lan),
+libjaylink, J-Link probes.
+
+## Global Constraints
+
+- Roster JSON: `test/hil/tinyusb.json`. `flasher_recover` is OPTIONAL; absent means today's
+ behaviour (`recover_flasher` returns the primary).
+- Never change the shape of `board['flasher']` — it is read as a dict in `hil_flash`,
+ `hil_test`, `usbtest`, `hil_pool_check`, `hil_select` and the roster lint, and is shipped
+ as JSON to a subprocess.
+- Flasher dispatch is by name: `getattr(hil_flash, f'flash_{name}')` / `reset_{name}`.
+- `RECOVER_FLASH_TIMEOUT = 90`, `RECOVER_RESET_TIMEOUT = 30` (`usbtest.py`). Any board whose
+ flash cannot finish inside 90 s is not a candidate.
+- Tests run offline: `cd test/hil && python3 test/test_hil_select.py`.
+
+## What is already established
+
+**Landed on PR #3803 and inert without roster entries:** `hil_flash.recover_flasher()`,
+`convoy_safe()` accepting openocd-over-jlink, `hil_test` substituting the recovery flasher
+into `--recover-board`, and `test_hil_select.FlasherRecoverEntry` (4 tests).
+
+**Verified in source:**
+- openocd's jlink driver ignores `adapter usb vid_pid` — `jlink.c` never reads
+ `adapter_usb_get_vids/pids`; selection is `adapter serial` / USB address / usb location.
+ Do NOT lint a jlink recovery entry for `vid_pid`.
+- It is convoy-safe anyway: libjaylink `discovery_usb.c` returns early unless
+ `idVendor == 0x1366` and the PID is in its table, and only THEN calls `libusb_open`. A
+ wedged `cafe:4010` DUT is never opened.
+- CMSIS-DAP stays pin-gated: `cmsis_dap_usb_bulk.c:107` skips before `libusb_open`, and
+ `id_filter` is only `vids[0] || pids[0]`.
+
+**Measured on ci.lan 2026-08-17**, base args
+`-f interface/jlink.cfg -c "transport select swd" -c "adapter speed 4000" -f target/<cfg>`:
+
+| Board | target cfg | flash | reset |
+|--------------------------|--------------|-------|-------|
+| stm32f407disco | stm32f4x | OK | OK |
+| stm32f072disco | stm32f0x | OK | OK |
+| stm32f723disco | stm32f7x | OK | OK |
+| stm32l476disco | stm32l4x | OK | OK |
+| feather_nrf52840_express | nrf52 | OK | OK |
+| metro_m4_express | atsame5x | OK | OK |
+| frdm_k64f | k60 | OK | OK |
+
+`frdm_k64f` is host-only (`tests.device == false`) — verify its reset over UART
+(`/dev/serial/by-id/usb-SEGGER_J-Link_000621000000-if00`), never by USB disconnect.
+
+**Excluded, with reasons:** `lpcxpresso11u37` — 118 s for 24 KB at 1 MHz with a verify
+mismatch, versus 0.277 s via JLinkExe; cannot fit `RECOVER_FLASH_TIMEOUT`.
+`mimxrt1064_evk`, `ra4m1_ek`, `nrf54lm20dk` — no target config exists in this openocd
+build, so they cannot be covered at all. **The board that wedges most (mimxrt1064_evk) is
+therefore still uncovered by this work.**
+
+**The blocker this plan solves:** `flash_openocd` issues `program <fw> verify reset exit`,
+which fails over the jlink transport on BOTH families tried (`stm32f4x`, `stm32f0x`) with
+`Examination failed` → `auto_probe failed`, with or without a preceding `init; reset halt`.
+Every successful flash above used the explicit sequence in Task 1.
+
+**Why this is a separate PR:** it adds a roster capability and a new flasher backend, which
+is a different scope from containing a wedge; and it needs bench time on seven boards.
+
+## File Structure
+
+- `test/hil/hil_flash.py` — add `flash_openocd_seq` / `reset_openocd_seq`; extend
+ `convoy_safe` to accept the new name. This is the only file that learns the command form.
+- `test/hil/tinyusb.json` — seven `flasher_recover` entries.
+- `test/hil/test/test_hil_select.py` — extend `FlasherRecoverEntry`; add a roster lint.
+
+---
+
+### Task 1: `openocd_seq` flasher backend
+
+**Files:**
+- Modify: `test/hil/hil_flash.py` (beside `flash_openocd`, ~line 100)
+- Test: `test/hil/test/test_hil_select.py`
+
+**Interfaces:**
+- Consumes: `_openocd_cmd_base(flasher)`, `hil_util.run_cmd`.
+- Produces: `flash_openocd_seq(board, firmware, timeout=None)`,
+ `reset_openocd_seq(board, timeout=None)`, both returning
+ `subprocess.CompletedProcess`; `convoy_safe()` returns True for
+ `{'name': 'openocd_seq', 'args': '...interface/jlink.cfg...'}`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+ def test_openocd_seq_is_convoy_safe_over_jlink(self):
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd_seq', 'args': '-f interface/jlink.cfg -f target/stm32f4x.cfg'}))
+
+ def test_openocd_seq_uses_explicit_flash_commands_not_program(self):
+ """`program` fails over the jlink transport: Examination failed -> auto_probe
+ failed, measured on stm32f4x and stm32f0x."""
+ seen = {}
+ real = hil_util.run_cmd
+ hil_util.run_cmd = lambda cmd, **k: seen.setdefault('cmd', cmd) or real('true')
+ try:
+ hil_flash.flash_openocd_seq(
+ {'flasher': {'name': 'openocd_seq', 'uid': 'X', 'args': '-f interface/jlink.cfg'}},
+ '/tmp/fw.elf', timeout=5)
+ finally:
+ hil_util.run_cmd = real
+ self.assertIn('flash write_image erase /tmp/fw.elf', seen['cmd'])
+ self.assertIn('verify_image /tmp/fw.elf', seen['cmd'])
+ self.assertNotIn('program ', seen['cmd'])
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd test/hil && python3 test/test_hil_select.py FlasherRecoverEntry -v`
+Expected: FAIL — `module 'hil_flash' has no attribute 'flash_openocd_seq'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+def flash_openocd_seq(board, firmware, timeout=None):
+ # Explicit commands, NOT `program`: over the jlink transport `program` fails at the
+ # flash bank probe ("Examination failed" -> "auto_probe failed"), measured on
+ # stm32f4x and stm32f0x, with or without a preceding reset halt. This sequence
+ # succeeded on all seven candidate boards.
+ flasher = board['flasher']
+ verify = f' -c "verify_image {firmware}"' if flasher.get('verify', True) else ''
+ return hil_util.run_cmd(
+ f'{_openocd_cmd_base(flasher)} -c "init" -c "reset halt" '
+ f'-c "flash write_image erase {firmware}"{verify} -c "reset run" -c "shutdown"',
+ timeout=timeout)
+
+
+def reset_openocd_seq(board, timeout=None):
+ flasher = board['flasher']
+ return hil_util.run_cmd(
+ f'{_openocd_cmd_base(flasher)} -c "init" -c "reset run" -c "shutdown"',
+ timeout=timeout)
+```
+
+In `convoy_safe`, replace `if name != 'openocd':` with:
+
+```python
+ if name not in ('openocd', 'openocd_seq'):
+ return False
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd test/hil && python3 test/test_hil_select.py FlasherRecoverEntry -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/hil_flash.py test/hil/test/test_hil_select.py
+git commit -m "hil: add openocd_seq flasher for convoy-safe recovery delivery"
+```
+
+---
+
+### Task 2: Roster entries for the seven validated boards
+
+**Files:**
+- Modify: `test/hil/tinyusb.json`
+- Test: `test/hil/test/test_hil_select.py`
+
+**Interfaces:**
+- Consumes: `flash_openocd_seq` / `reset_openocd_seq` from Task 1.
+- Produces: seven boards for which `hil_flash.convoy_safe(hil_flash.recover_flasher(b))`
+ is True.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+ def test_roster_recover_entries_are_convoy_safe_and_named_openocd_seq(self):
+ import json, pathlib
+ roster = json.loads((pathlib.Path(__file__).parent.parent / 'tinyusb.json').read_text())
+ recover = [b for b in roster['boards'] if 'flasher_recover' in b]
+ self.assertGreaterEqual(len(recover), 7)
+ for b in recover:
+ f = b['flasher_recover']
+ self.assertEqual(f['name'], 'openocd_seq', b['name'])
+ self.assertIn('interface/jlink.cfg', f['args'], b['name'])
+ self.assertIn('adapter speed', f['args'], b['name']) # required; see below
+ self.assertTrue(hil_flash.convoy_safe(f), b['name'])
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd test/hil && python3 test/test_hil_select.py FlasherRecoverEntry -v`
+Expected: FAIL — `0 >= 7`
+
+- [ ] **Step 3: Add the entries**
+
+`adapter speed` is REQUIRED: without it examination fails outright on the jlink driver.
+Add to each board below, using the SAME `uid` as its primary jlink entry:
+
+```json
+"flasher_recover": {
+ "name": "openocd_seq",
+ "uid": "<same probe serial as flasher.uid>",
+ "args": "-f interface/jlink.cfg -c \"transport select swd\" -c \"adapter speed 4000\" -f target/<cfg>.cfg"
+}
+```
+
+| Board | `uid` | `<cfg>` |
+|--------------------------|----------------|-----------|
+| stm32f407disco | 000773661813 | stm32f4x |
+| stm32f072disco | 779541626 | stm32f0x |
+| stm32f723disco | 000776606156 | stm32f7x |
+| stm32l476disco | 777632258 | stm32l4x |
+| feather_nrf52840_express | 681295394 | nrf52 |
+| metro_m4_express | 123456 | atsame5x |
+| frdm_k64f | 000621000000 | k60 |
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd test/hil && python3 test/test_hil_select.py -v`
+Expected: PASS, and no other selector test regresses.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/tinyusb.json test/hil/test/test_hil_select.py
+git commit -m "hil: give seven J-Link boards a convoy-safe recovery flasher"
+```
+
+---
+
+### Task 3: Bench validation on the rig
+
+**Files:** none — this task produces evidence, not code.
+
+- [ ] **Step 1: Confirm the rig is idle and take the locks**
+
+```bash
+ssh [email protected] 'if pgrep -f "[h]il_test.py" >/dev/null; then echo BUSY; exit 1; fi'
+ssh [email protected] 'cd ~/actions-runner/_work/tinyusb/tinyusb && \
+ nohup timeout 900 python3 test/hil/helper/hil_lock.py hold <boards...> --reason "flasher_recover validation" &'
+```
+
+Guard with `if`, never `cmd && echo || echo` — that form only gates the echo and will take
+locks during a live CI run.
+
+- [ ] **Step 2: For each board, flash then reset through the recovery entry**
+
+```bash
+python3 test/hil/hil_test.py -b <board> test/hil/tinyusb.json # normal path still works
+```
+
+Then force the recovery path by running usbtest with the recovery flags and a firmware that
+hangs a case, or drive `hil_flash.flash_openocd_seq` / `reset_openocd_seq` directly.
+
+- [ ] **Step 3: Verify**
+
+Device boards: `sudo dmesg` shows `USB disconnect` then a fresh enumeration.
+`frdm_k64f`: UART shows the boot banner (see above).
+Every flash must finish well inside `RECOVER_FLASH_TIMEOUT` (90 s).
+
+- [ ] **Step 4: Release locks and record the results in the PR body**
+
+---
+
+## Out of scope, and why
+
+- **`mimxrt1064_evk`** needs an i.MX RT target config that this openocd build does not
+ have. Sourcing or writing one is its own investigation; until then the board with the
+ most wedges has no automated recovery.
+- **Changing `flash_openocd`** to the explicit form would cover these boards without a new
+ name, but `program` is what nine pinned CMSIS-DAP boards use in CI daily and no CMSIS-DAP
+ image could be built in the originating worktree (no pico-sdk) to re-validate it.
diff --git a/docs/superpowers/followup/pr3803-hil-blindness-reporting.md b/docs/superpowers/followup/pr3803-hil-blindness-reporting.md
new file mode 100644
index 000000000..69ff939b0
--- /dev/null
+++ b/docs/superpowers/followup/pr3803-hil-blindness-reporting.md
@@ -0,0 +1,185 @@
+# Blindness Reporting Gaps Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make a HIL worker's sysfs blindness reach the report in the two cases where it
+currently does not — an untested producer, and a board that raises.
+
+**Architecture:** A worker returns `hil_util.sysfs_blind()` as the last field of its result
+tuple; `_blind_note()` turns that into a report banner. Two holes: nothing tests the
+producer, and a board that raises returns no tuple at all, so its blindness is lost.
+
+**Tech Stack:** Python 3.13 stdlib, multiprocessing Pool with `maxtasksperchild=1`.
+
+## Global Constraints
+
+- A blind worker answers `SYSFS_UNKNOWN` for every attribute, so its "device not found"
+ means "could not tell". The report must say so or a red cell reads as a broken board.
+- `maxtasksperchild=1`: one worker per board, so the flag is per-board and must not be
+ smeared across boards.
+- Tests: `cd test/hil && python3 test/test_hil_bounded.py`.
+
+## What is already established
+
+- `hil_test.test_board` returns `(..., hil_util.sysfs_blind(), stray)`; `_blind_note(mret)`
+ renders the banner; wired into all three report paths.
+- **The producer is provably untested**: replacing `hil_util.sysfs_blind()` with `False` in
+ the return leaves all tests green. Nothing drives `test_board` — it needs a board dict, a
+ real flock, a flasher and `test_example` per test.
+- Blindness fired for real on ci.lan: four workers went blind in one run, and cells failed
+ *because* of it (`Printer device not found ... (this worker is blind)`).
+
+**Why this is a separate PR:** closing it means making `test_board` testable, which is a
+refactor of the harness's orchestration layer — a different scope from the containment
+work, and the reason the gap was accepted rather than papered over.
+
+## File Structure
+
+- `test/hil/hil_test.py` — extract the result-tuple assembly from `test_board` so it can be
+ built and asserted without running a board; carry blindness out of the raise path.
+- `test/hil/test/test_hil_bounded.py` — tests for both.
+
+---
+
+### Task 1: Make the result tuple assembly testable
+
+**Files:**
+- Modify: `test/hil/hil_test.py` (`test_board`, the `return (name, err_count, ...)` at the
+ end of the try block)
+- Test: `test/hil/test/test_hil_bounded.py`
+
+**Interfaces:**
+- Produces: `_board_result(name, err_count, failed_tests, rows, t_total, board_wide_fail)`
+ returning the 7-tuple `(name, err_count, failed, rows, t_total, blind, stray)`, reading
+ `hil_util.sysfs_blind()` and `hil_health.kill_own_children()` itself.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+class BoardResultCarriesBlindness(unittest.TestCase):
+ def test_a_blind_worker_reports_it(self):
+ from helper import hil_util, hil_health
+ self.addCleanup(setattr, hil_util, 'sysfs_blind', hil_util.sysfs_blind)
+ self.addCleanup(setattr, hil_health, 'kill_own_children', hil_health.kill_own_children)
+ hil_util.sysfs_blind = lambda: True
+ hil_health.kill_own_children = lambda: 0
+ row = hil_test._board_result('b', 0, [], [], 1.0, False)
+ self.assertTrue(row[5], 'blindness did not reach the result tuple')
+ self.assertIn('b', hil_test._blind_note([row]))
+
+ def test_a_sighted_worker_does_not(self):
+ from helper import hil_util, hil_health
+ self.addCleanup(setattr, hil_util, 'sysfs_blind', hil_util.sysfs_blind)
+ self.addCleanup(setattr, hil_health, 'kill_own_children', hil_health.kill_own_children)
+ hil_util.sysfs_blind = lambda: False
+ hil_health.kill_own_children = lambda: 0
+ row = hil_test._board_result('b', 0, [], [], 1.0, False)
+ self.assertFalse(row[5])
+ self.assertEqual(hil_test._blind_note([row]), '')
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd test/hil && python3 test/test_hil_bounded.py BoardResultCarriesBlindness -v`
+Expected: FAIL — `module 'hil_test' has no attribute '_board_result'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+def _board_result(name, err_count, failed_tests, rows, t_total, board_wide_fail):
+ """Assemble a worker's result tuple. Separate from test_board so the two fields only
+ the WORKER can answer -- its process-global blindness latch and what it could not kill
+ -- are testable without running a board."""
+ stray = hil_health.kill_own_children()
+ return (name, err_count, [] if board_wide_fail else sorted(set(failed_tests)),
+ rows, t_total, hil_util.sysfs_blind(), stray)
+```
+
+Replace the tail of `test_board` with:
+
+```python
+ return _board_result(name, err_count, failed_tests, rows, t_total, board_wide_fail)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd test/hil && python3 test/test_hil_bounded.py -v`
+Expected: PASS, and the existing `BlindWorkerReachesTheReport` tests still pass.
+
+- [ ] **Step 5: Verify the mutation is now caught**
+
+Replace `hil_util.sysfs_blind()` with `False` inside `_board_result` and re-run; the suite
+MUST fail. Restore it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add test/hil/hil_test.py test/hil/test/test_hil_bounded.py
+git commit -m "test/hil: make the worker result tuple testable, covering blindness"
+```
+
+---
+
+### Task 2: Carry blindness out of the worker-raise path
+
+**Files:**
+- Modify: `test/hil/hil_test.py` (`test_board`'s except/finally, and `main`'s worker-raise
+ handler that builds synthetic rows)
+- Test: `test/hil/test/test_hil_bounded.py`
+
+**Interfaces:**
+- Consumes: `_board_result` from Task 1.
+- Produces: a board that raises still contributes a row whose blindness field is accurate.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+ def test_a_board_that_raises_still_reports_blindness(self):
+ """The result tuple is returned inside a try whose finally only releases the lock,
+ so a board that dies by exception contributed nothing -- and its blindness, the
+ thing that most explains its failure, was lost with it."""
+ from helper import hil_util
+ self.addCleanup(setattr, hil_util, 'sysfs_blind', hil_util.sysfs_blind)
+ hil_util.sysfs_blind = lambda: True
+ row = hil_test._board_result_on_error('b', RuntimeError('boom'))
+ self.assertTrue(row[5])
+ self.assertIn('b', hil_test._blind_note([row]))
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd test/hil && python3 test/test_hil_bounded.py BoardResultCarriesBlindness -v`
+Expected: FAIL — no `_board_result_on_error`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+def _board_result_on_error(name, exc):
+ """A row for a board that died by exception. err_count 1, no per-test detail, but the
+ blindness and stray fields are still accurate -- they explain the failure more often
+ than the exception text does."""
+ rows = [(name, {BOUNDARY_CELL: f'{REPORT_CELL["fail"]} {type(exc).__name__}'}, None)]
+ return _board_result(name, 1, [], rows, 0.0, True)
+```
+
+Wrap the body of `test_board` so the exception path returns it instead of propagating.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd test/hil && python3 test/test_hil_bounded.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/hil_test.py test/hil/test/test_hil_bounded.py
+git commit -m "test/hil: keep a raising board's blindness in the report"
+```
+
+---
+
+## Caution
+
+`test_board`'s `finally` releases the board flock. Any restructuring MUST keep that
+release on every path, including the new error path — a leaked flock locks the board until
+the host reboots.
diff --git a/docs/superpowers/followup/pr3803-hil-iar-rerun-spec.md b/docs/superpowers/followup/pr3803-hil-iar-rerun-spec.md
new file mode 100644
index 000000000..fe377f741
--- /dev/null
+++ b/docs/superpowers/followup/pr3803-hil-iar-rerun-spec.md
@@ -0,0 +1,118 @@
+# IAR HIL Leg Re-run Spec Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let the `hil-hfp-iar` CI leg re-run only its failed boards, as the other two HIL
+legs already do.
+
+**Architecture:** `hil_test.py` writes a `<config>.failed` spec into `HIL_REPORT_DIR`; a
+workflow step reads it on the next attempt and passes the boards back as arguments. The IAR
+leg passes `--retry 1` like the others but sets no `HIL_REPORT_DIR` and has no read-back
+step, so its spec is written into the workspace and never read.
+
+**Tech Stack:** GitHub Actions YAML, self-hosted runner.
+
+## Global Constraints
+
+- `.github/workflows/build.yml`. The two working legs are `hil-tinyusb` (matrix) — see its
+ `Set HIL report dir (per run+job; persists across run attempts)` and `Get re-run spec from
+ previous attempt` steps — and they are the pattern to copy.
+- The report dir must be keyed by run id AND job so a matrix leg does not collide with
+ another, and must survive across run attempts (that is the whole point).
+- The IAR leg is the only HIL job that BUILDS inline; its `Build` step is bounded at
+ `timeout-minutes: 30` under a 120-minute job ceiling. Do not disturb that.
+
+## What is already established
+
+- Verified by reading the workflow: `hil-hfp-iar` has neither `HIL_REPORT_DIR` nor a
+ `Get re-run spec` step, while passing `--retry 1`.
+- Consequence: a GitHub re-run of that job re-tests its whole matrix. **This is not a
+ regression** — that leg never had the mechanism — and the unread spec costs only a file.
+- The report artifact upload for that leg is named `hil-report-hfp-iar`.
+
+**Why this is a separate PR:** it is CI plumbing with no code change, it needs a real
+re-run on the self-hosted runner to prove, and it duplicates ~15 lines of workflow that
+would be better factored — a decision worth making on its own.
+
+## File Structure
+
+- `.github/workflows/build.yml` — the `hil-hfp-iar` job only.
+
+---
+
+### Task 1: Give the IAR leg a persistent report dir and a re-run spec
+
+**Files:**
+- Modify: `.github/workflows/build.yml` (job `hil-hfp-iar`)
+
+**Interfaces:**
+- Consumes: `hil_test.py`'s existing `--report-dir` / `.failed` behaviour — no code change.
+- Produces: `env.HIL_REPORT_DIR` for the job, and `$RERUN_ARGS` for the test step.
+
+- [ ] **Step 1: Copy the two steps from `hil-tinyusb`, before the Build step**
+
+```yaml
+ - name: Set HIL report dir (per run+job; persists across run attempts)
+ run: |
+ BASE=$HOME/hil-reports
+ echo "HIL_REPORT_DIR=$BASE/${GITHUB_RUN_ID}-hfp-iar" >> "$GITHUB_ENV"
+
+ - name: Get re-run spec from previous attempt
+ run: |
+ SPEC="$HIL_REPORT_DIR/hfp.json.failed"
+ if [ -f "$SPEC" ]; then
+ echo "RERUN_ARGS=$(cat "$SPEC")" >> "$GITHUB_ENV"
+ echo "re-running only: $(cat "$SPEC")"
+ fi
+```
+
+Match the exact spec filename `hil_test.py` writes for this leg's config — read
+`_write_failed_spec` and the `failed_fname` construction rather than assuming.
+
+- [ ] **Step 2: Pass the spec to the test step**
+
+```yaml
+ python3 test/hil/hil_test.py --retry 1 $SEL_ARGS hfp.json $RERUN_ARGS
+```
+
+`--retry 1` stays FIRST so argparse's last-wins keeps any explicit override working.
+
+- [ ] **Step 3: Point the artifact upload at the report dir**
+
+```yaml
+ path: ${{ env.HIL_REPORT_DIR }}/hil_report.md
+```
+
+- [ ] **Step 4: Validate the YAML**
+
+Run: `python3 -c "import yaml,sys; d=yaml.safe_load(open('.github/workflows/build.yml')); j=d['jobs']['hil-hfp-iar']; print(j['timeout-minutes'], [s.get('name') for s in j['steps']])"`
+Expected: the ceiling is still 120, the Build step still carries `timeout-minutes: 30`, and
+the two new steps appear before Build.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add .github/workflows/build.yml
+git commit -m "ci: let the IAR HIL leg re-run only its failed boards"
+```
+
+---
+
+### Task 2: Prove it on a real re-run
+
+**Files:** none — evidence only.
+
+- [ ] **Step 1:** Push and let `hil-hfp-iar` run to a failure (or force one).
+- [ ] **Step 2:** Confirm `$HIL_REPORT_DIR/hfp.json.failed` exists on the runner after the
+ job.
+- [ ] **Step 3:** Use GitHub's "Re-run failed jobs" and confirm the log line
+ `re-running only: ...` and that only those boards are tested.
+- [ ] **Step 4:** Record the run URL in the PR body.
+
+---
+
+## Consider first
+
+Three jobs would then carry the same ~15 lines. Factoring them into a composite action, or
+computing the report dir inside `hil_test.py` from `GITHUB_RUN_ID`, may be the better
+change — decide that before copying the block a third time.
diff --git a/docs/superpowers/followup/pr3803-pci-rebind-stranding.md b/docs/superpowers/followup/pr3803-pci-rebind-stranding.md
new file mode 100644
index 000000000..de1f7163b
--- /dev/null
+++ b/docs/superpowers/followup/pr3803-pci-rebind-stranding.md
@@ -0,0 +1,157 @@
+# `pci-rebind` Stranding Investigation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Settle when a PCI unbind/rebind of an xHCI controller strands it driverless, so
+the `usb-kernel-recover` skill can state a rule instead of a hypothesis.
+
+**Architecture:** No product code. This is a controlled reproduction against the rig's
+kernel, ending in a documentation change and — if the boundary turns out to be
+detectable — a guard in `usb_recover.sh`.
+
+**Tech Stack:** Linux 6.12.96 (ci.lan), Renesas uPD720201 xHCI, `usb_recover.sh`.
+
+## Global Constraints
+
+- ci.lan is a live CI rig. Take every affected board's lock first
+ (`hil_lock.py hold --all --reason ...`) and confirm no `hil_test.py` is running, with an
+ `if`, not an `&&` chain.
+- A stranded controller takes every fixture on it offline; recovery is
+ `usb_recover.sh pci-bind <addr>` or, failing that, a PVE **host** power cycle — an
+ operator action. Do not start this without being able to reach the host.
+- The rig has two Renesas controllers plus an AMD one; pick the controller with the fewest
+ fixtures for the experiment.
+
+## What is already established
+
+**The skill claimed, unconditionally, that `pci-rebind`'s re-bind hangs on the D-state URB
+and leaves the controller with no driver.** That claim was generalised from ONE observation
+and was used to delete `pci-rebind` and `pci-bind` from `usb_recover.sh` entirely.
+
+**It was refuted in the field on 2026-08-17.** After `hub-cycle 17-2.7` failed to clear a
+wedge, `pci-rebind 0000:05:00.0` recovered the controller in about one second:
+
+```
+02:34:41 remove, state 4 / USB bus 18 deregistered
+02:34:41 remove, state 1 / USB bus 17 deregistered
+02:34:42 xHCI Host Controller / new USB bus registered, assigned bus number 1
+02:34:42 new USB bus registered, assigned bus number 2
+```
+
+Both actions were restored, with the guidance scoped to failure mode: **dead controller →
+use it; device-lock convoy → do not**. Buses renumbered 17/18 → 1/2, which is why rig-wide
+operations need every board's lock.
+
+**What is NOT known:** why the earlier attempt stranded and this one did not. The leading
+hypothesis is that it turns on whether a live D-state URB exists **on that controller** at
+the moment of the re-bind — but in the 02:34 incident the wedged board (17-2.7) was on that
+very controller, which weakens it. An alternative is that `hub-cycle` had already cleared
+the holder, leaving only a dead controller.
+
+**Why this is a separate PR:** it is an experiment that risks taking the rig offline, and
+its output is a documentation change plus possibly a guard — a different scope from any
+code change.
+
+## File Structure
+
+- `.claude/skills/usb-kernel-recover/SKILL.md` — replace the hypothesis in section 3b and
+ the Common-mistakes entry with whatever the experiment establishes.
+- `.claude/skills/usb-kernel-recover/scripts/usb_recover.sh` — only if the boundary is
+ detectable from userspace.
+
+---
+
+### Task 1: Reproduce a controller-scoped D-state wedge
+
+**Files:** none.
+
+- [ ] **Step 1: Establish the safety net**
+
+```bash
+ssh [email protected] 'if pgrep -f "[h]il_test.py" >/dev/null; then echo BUSY; exit 1; fi'
+# hold ALL boards on the target controller
+```
+
+Confirm host access to pve.lan before continuing.
+
+- [ ] **Step 2: Create a wedge deliberately**
+
+Run `usbtest.py` against a board known to hang (`mimxrt1064_evk` has wedged eight times,
+TEST 9/10/24/27), or drive `testusb` directly until a case does not return.
+
+- [ ] **Step 3: Confirm the holder and its controller**
+
+```bash
+ps -eo pid,stat,etimes,wchan:22,args | awk '$2 ~ /D/'
+sudo cat /proc/<pid>/stack # usbdev_ioctl + [usbtest] = the owner
+readlink -f /sys/bus/usb/devices/usb<N> # bus -> PCI addr
+```
+
+Record whether the holder is on the SAME controller you will rebind.
+
+---
+
+### Task 2: Rebind and record the outcome
+
+**Files:** none.
+
+- [ ] **Step 1: Rebind, with a bounded observer**
+
+```bash
+timeout 120 sudo usb_recover.sh pci-rebind <addr>; echo "rc=$?"
+```
+
+- [ ] **Step 2: Record which of the three outcomes occurred**
+
+1. Re-bind completes, controller recovers (as on 2026-08-17).
+2. Re-bind hangs; `/sys/bus/pci/devices/<addr>/driver` is gone → **stranded**.
+3. Re-bind completes but the wedge persists.
+
+Capture `sudo journalctl -k --since ...` around the attempt either way.
+
+- [ ] **Step 3: If stranded, recover**
+
+```bash
+sudo usb_recover.sh pci-bind <addr>
+```
+
+If that hangs too, the only remaining step is a PVE host power cycle — an operator action.
+
+- [ ] **Step 4: Repeat at least three times**
+
+One observation is what produced the wrong rule in the first place. Vary whether a D-state
+holder is live on that controller at rebind time; that is the hypothesis under test.
+
+---
+
+### Task 3: Write down what was learned
+
+**Files:**
+- Modify: `.claude/skills/usb-kernel-recover/SKILL.md`
+
+- [ ] **Step 1: Replace section 3b's scoping with the measured rule**
+
+State the condition under which stranding occurs, with the journal lines. If the experiment
+does NOT reproduce stranding, say that too, with the attempt count — "not reproduced in N
+attempts" is a better record than an unexplained warning.
+
+- [ ] **Step 2: If the boundary is detectable, guard the script**
+
+For example, refuse `pci-rebind` when a D-state holder exists on that controller, since the
+holder is enumerable from `/proc` and the controller from `readlink`. Only add this if the
+experiment shows it predicts the outcome.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .claude/skills/usb-kernel-recover/
+git commit -m "skills: replace the pci-rebind stranding hypothesis with measurement"
+```
+
+---
+
+## Abort criteria
+
+Stop and hand back to the operator if: a rebind strands the controller and `pci-bind` does
+not recover it; `uhubctl` starts hanging (the convoy has spread to the hub locks); or a CI
+run starts while the rig is in a broken state.
diff --git a/docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md b/docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md
new file mode 100644
index 000000000..eb8959520
--- /dev/null
+++ b/docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md
@@ -0,0 +1,175 @@
+# usbtest Recovery Reserve Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the post-hang recovery reserve a derived, asserted property instead of an
+accident of four independently-set constants.
+
+**Architecture:** `hil_test` passes `--budget` and `--outer-timeout` to `usbtest.py`, which
+decides at runtime whether a recovery still fits. Today the reserve survives only because
+the four numbers happen to line up; nothing ties them together or fails when they stop.
+
+**Tech Stack:** Python 3.13 stdlib.
+
+## Global Constraints
+
+- `usbtest.py`: `RECOVER_FLASH_TIMEOUT = 90`, `RECOVER_RESET_TIMEOUT = 30`.
+- `hil_test.py`: `USBTEST_BATTERY_BUDGET = 260`, `USBTEST_RECOVERY_BUDGET = 250`,
+ `USBTEST_OVERSHOOT = 120`; `outer = BATTERY_BUDGET + (RECOVERY_BUDGET if recovery else
+ OVERSHOOT)`, used for both the child's `--outer-timeout` and the parent's `run_cmd` bound.
+- All five are env-overridable via `hil_util.pos_int_env`, so a rig can change them.
+- Tests: `cd test/hil && python3 test/test_hil_health.py` and `test_hil_bounded.py`.
+
+## What is already established
+
+The reserve holds at the shipped values, checked by hand:
+
+- The battery checks its budget BEFORE dispatching a case, so it can overshoot by one
+ case — worst case `260 + 60 + 5 = 325 s`.
+- Recovery is gated on `_time_left() >= RECOVER_RESET_TIMEOUT`, where
+ `_time_left() = outer_timeout - elapsed - 35`; with `outer = 510` that allows recovery
+ until `elapsed = 445 s`, and the reflash until `385 s`.
+- So ~60 s of margin survives, and recovery does fire.
+
+**The defect is structural, not arithmetic:** lower `--outer-timeout`, raise `--timeout`, or
+raise `USBTEST_BATTERY_BUDGET` via the env and the reserve silently disappears. The failure
+mode is a skipped reflash that leaves the D-state holder for the next job — the exact thing
+the containment exists to prevent — with no error anywhere.
+
+**Why this is a separate PR:** it changes the timing contract between `hil_test` and
+`usbtest.py`, which affects every board's run duration, so it wants its own review and a
+full rig run.
+
+## File Structure
+
+- `test/hil/usbtest.py` — a `reserve_ok()` predicate plus a startup assertion.
+- `test/hil/hil_test.py` — derive the battery budget from the outer bound rather than
+ setting both independently.
+- `test/hil/test/test_hil_health.py` — tests.
+
+---
+
+### Task 1: Assert the reserve at startup
+
+**Files:**
+- Modify: `test/hil/usbtest.py` (constants block, and `main()` after argparse)
+- Test: `test/hil/test/test_hil_health.py`
+
+**Interfaces:**
+- Produces: `usbtest.reserve_ok(budget, outer, case_timeout)` returning bool.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+class RecoveryReserveIsChecked(unittest.TestCase):
+ """The battery may overshoot its budget by ONE already-started case, so the outer bound
+ must leave room for that overshoot AND a bounded recovery afterwards."""
+
+ def setUp(self):
+ import usbtest
+ self.u = usbtest
+
+ def test_the_shipped_numbers_leave_room(self):
+ self.assertTrue(self.u.reserve_ok(budget=260, outer=510, case_timeout=60))
+
+ def test_a_tighter_outer_bound_is_rejected(self):
+ self.assertFalse(self.u.reserve_ok(budget=260, outer=380, case_timeout=60))
+
+ def test_a_longer_case_timeout_is_rejected(self):
+ self.assertFalse(self.u.reserve_ok(budget=260, outer=510, case_timeout=200))
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd test/hil && python3 test/test_hil_health.py RecoveryReserveIsChecked -v`
+Expected: FAIL — `module 'usbtest' has no attribute 'reserve_ok'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+def reserve_ok(budget: int, outer: int, case_timeout: int) -> bool:
+ """Does `outer` leave room for the battery's worst case AND a bounded recovery?
+
+ The budget is checked BEFORE dispatch, so the battery can run to
+ `budget + case_timeout + 5` (the +5 is run_case's reap). _time_left() subtracts a
+ further 35 s of fixed tail. A reflash needs RECOVER_FLASH_TIMEOUT beyond that.
+ """
+ worst_case_end = budget + case_timeout + 5
+ return outer - worst_case_end - 35 >= RECOVER_FLASH_TIMEOUT
+```
+
+In `main()`, after parsing args:
+
+```python
+ if args.budget and args.outer_timeout and not reserve_ok(
+ args.budget, args.outer_timeout, args.timeout):
+ print(f'warning: --outer-timeout {args.outer_timeout} leaves no room for a bounded '
+ f'recovery after a --budget {args.budget} battery with --timeout '
+ f'{args.timeout} cases; a HUNG board will be left wedged', file=sys.stderr)
+```
+
+Warn, do not exit: a caller that deliberately runs without recovery is legitimate.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd test/hil && python3 test/test_hil_health.py RecoveryReserveIsChecked -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/usbtest.py test/hil/test/test_hil_health.py
+git commit -m "usbtest: check the recovery reserve instead of assuming it"
+```
+
+---
+
+### Task 2: Derive the outer bound from one place
+
+**Files:**
+- Modify: `test/hil/hil_test.py` (constants block ~line 227, and `test_device_usbtest`)
+- Test: `test/hil/test/test_hil_bounded.py`
+
+**Interfaces:**
+- Consumes: `usbtest.reserve_ok` semantics (duplicate the arithmetic, do not import
+ usbtest — `hil_test` must not import it).
+- Produces: an assertion at module import that the shipped constants satisfy the reserve.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+ def test_the_shipped_constants_satisfy_the_reserve(self):
+ """Whatever the env overrides, the pair hil_test computes must leave recovery room:
+ outer - (budget + case_timeout + 5) - 35 >= 90."""
+ outer = hil_test.USBTEST_BATTERY_BUDGET + hil_test.USBTEST_RECOVERY_BUDGET
+ self.assertGreaterEqual(outer - (hil_test.USBTEST_BATTERY_BUDGET + 60 + 5) - 35, 90)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Temporarily set `HIL_USBTEST_RECOVERY_BUDGET=100` and run; expect FAIL. Unset.
+
+- [ ] **Step 3: Add the guard**
+
+```python
+# The recovery reserve is a PROPERTY of these two, not a coincidence: the battery may
+# overshoot its budget by one already-started case (checked before dispatch), and a bounded
+# reflash needs 90 s after a 35 s fixed tail. Env overrides make this checkable at import
+# rather than discoverable when a wedge is left unrecovered.
+if USBTEST_RECOVERY_BUDGET - 60 - 5 - 35 < 90:
+ print(f'warning: HIL_USBTEST_RECOVERY_BUDGET={USBTEST_RECOVERY_BUDGET} leaves no room '
+ f'for a bounded reflash after a one-case overshoot; HUNG boards will stay wedged',
+ file=sys.stderr)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd test/hil && python3 test/test_hil_bounded.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/hil_test.py test/hil/test/test_hil_bounded.py
+git commit -m "hil: warn when the timeout constants leave no recovery reserve"
+```
diff --git a/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md b/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md
index 8158758bc..898b3c8ab 100644
--- a/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md
+++ b/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md
@@ -1,4 +1,4 @@
-# PR-scoped HIL selection: hil_select.py
+# PR-scoped HIL selection: helper/hil_select.py
**Date:** 2026-07-29
**Branch:** `claude/hil-select` (based on `claude/hil-pool-check`, which carries the
@@ -26,17 +26,17 @@ confident; every uncertainty widens to the full matrix.
- Scoping push/master/scheduled runs (always full).
- Changing hil_test.py behavior (the selector only *composes* existing `-b`/`-bt` args).
-## Component: `test/hil/hil_select.py`
+## Component: `test/hil/helper/hil_select.py`
Stdlib-only, importable and CLI. Lives beside the harness so `hil_ci.sh` copies are unaffected
(it runs on the GitHub runner / dev PC, not on the rig). It must NOT import `hil_test.py`
(which drags pyserial/pymtp onto the bare GitHub runner): the three test lists
-(`device_tests`, `dual_tests`, `host_test`) move verbatim into a tiny stdlib-only
-`test/hil/hil_examples.py` that both `hil_test.py` and `hil_select.py` import (behavior
-preserving; `hil_ci.sh` scp list gains the new file).
+(`device_tests`, `dual_tests`, `host_test`) move verbatim into the stdlib-only
+`test/hil/helper/hil_util.py` that both `hil_test.py` and `hil_select.py` import (behavior
+preserving; `hil_ci.sh` copies the whole `helper/` directory).
```
-python3 test/hil/hil_select.py --base <ref> [--diff-file <path>] CONFIG.json [CONFIG.json...]
+python3 test/hil/helper/hil_select.py --base <ref> [--diff-file <path>] CONFIG.json [CONFIG.json...]
```
- `--base REF`: changed files = `git diff --name-only $(git merge-base HEAD REF)..HEAD`
@@ -118,7 +118,7 @@ is skipped, not widened (running unrelated boards would test nothing relevant).
## CI wiring (`.github/workflows/build.yml`)
- `set-matrix` (PR events only): after generating today's matrices, run
- `hil_select.py --base origin/${{ github.base_ref }} test/hil/tinyusb.json test/hil/hfp.json`
+ `helper/hil_select.py --base origin/${{ github.base_ref }} test/hil/tinyusb.json test/hil/hfp.json`
(checkout with enough history to reach the merge base: `fetch-depth: 0` on this one job, or
an explicit `git fetch origin $BASE_REF`). New job outputs: `hil_select_full`,
`hil_args_tinyusb`, `hil_args_hfp`, plus the selected-board list consumed by the matrix
@@ -137,15 +137,15 @@ is skipped, not widened (running unrelated boards would test nothing relevant).
## Local use
- pre-pr's "Map changes to boards" step delegates to
- `python3 test/hil/hil_select.py --base $BASE test/hil/tinyusb.json` and derives its
+ `python3 test/hil/helper/hil_select.py --base $BASE test/hil/tinyusb.json` and derives its
one-board-per-family sample from the selector's board set (its capping/sampling policy is
unchanged — the selector provides the affected set, pre-pr samples it).
-- Manual: `python3 test/hil/hil_test.py -B examples $(python3 test/hil/hil_select.py --base master test/hil/tinyusb.json | jq -r '.args["tinyusb.json"]') test/hil/tinyusb.json`
+- Manual: `python3 test/hil/hil_test.py -B examples $(python3 test/hil/helper/hil_select.py --base master test/hil/tinyusb.json | jq -r '.args["tinyusb.json"]') test/hil/tinyusb.json`
— documented in the hil skill.
## Testing
-`test/hil/test_hil_select.py` — stdlib `unittest`, no hardware, injected diffs via
+`test/hil/test/test_hil_select.py` — stdlib `unittest`, no hardware, injected diffs via
`--diff-file`/API. Cases (the acceptance examples):
1. `src/portable/raspberrypi/rp2040/dcd_rp2040.c` → only rp2040-family roster boards, device
tests only, host-only boards absent, `full` false.
@@ -161,7 +161,7 @@ is skipped, not widened (running unrelated boards would test nothing relevant).
7. `hw/bsp/rp2040/family.cmake` → rp2040-family boards, all their tests.
8. Mixed device+host diff → no pruning (both roles present).
The suite runs in `set-matrix` before the selector is used, and locally via
-`python3 test/hil/test_hil_select.py`.
+`python3 test/hil/test/test_hil_select.py`.
## Safety properties
diff --git a/docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md b/docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md
new file mode 100644
index 000000000..3ed0c1519
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md
@@ -0,0 +1,236 @@
+# HIL fleet-wedge containment
+
+Date: 2026-07-30
+Status: implemented, then superseded in part — addendum last checked 2026-08-12
+against the shipped code; where they disagree the CODE and the usb-kernel-recover
+skill win, never this document.
+
+- **Pool guard.** A single constant, not the flat 4200s below and not a derivation:
+ `POOL_TIMEOUT = pos_int_env('HIL_POOL_TIMEOUT', 3600)`. A per-controller model briefly
+ lived here and was removed -- it under-modelled the flash phase and could INVERT
+ (adding a usbtest board lowered the guard, because the derived value fell below the
+ baseline it was meant to raise). The guard's only job is to stop a wedged pool short
+ of the job ceiling so the report still gets written; predicting a healthy run's
+ duration is a different problem. `pos_int_env` warns only on a non-integer or a value
+ <= 0: there is NO upper clamp and no warning above any threshold, so a pin larger than
+ a job ceiling silently restores the inversion this work removed.
+- **Job ceilings.** 90/90/120 min (build.yml), not 60/60/90 and not the 85/115 below.
+ They must clear the 3600s guard plus the pre-pool checkout/artifact merge and the
+ post-guard sweep and report upload. No job pins `HIL_POOL_TIMEOUT`.
+- **Battery budgets.** `USBTEST_BATTERY_BUDGET` 260s, `USBTEST_RECOVERY_BUDGET` 250s.
+ The 200s-with-a-197s-floor derivation recorded here was never shipped; the floor
+ assertion was removed with it.
+- **HUNG recovery.** Reflash of the DUT through its roster flasher
+ (`usbtest.py --recover-board/--recover-fw`), not the root-cycle-first recovery in
+ section 1d — replaced after the 2026-08-11 ppps measurement (uhubctl never cuts
+ VBUS; root-cycle is probe-only). Since 2026-08-12 the reflash is SKIPPED
+ when `hil_flash.convoy_safe(board['flasher'])` is false (usbtest.py:675): the flasher
+ would enumerate by opening usbfs nodes, block on the same convoy, and become a second
+ stray rather than clear the first. A holder that owns the device lock inside a driver
+ ioctl is terminal either way -- a reflash only produces a disconnect, and
+ `usb_disconnect()` needs that same lock -- and that state needs a reboot.
+
+Step 0 done — the host was rebooted 2026-07-30 14:11 and the rig
+came back clean. The device that triggered this incident was removed from the rig, so
+only the containment work remains relevant.
+Rig: `ci.lan` (Proxmox guest on `pve.lan`)
+
+## Problem
+
+On 2026-07-29/30 every board in the `ci.lan` usbtest fleet failed, `openocd` processes
+landed in uninterruptible sleep, and no subsequent HIL run could start. Two GitHub
+Actions runs were stranded: `30484641269` sat `in_progress` for over eight hours
+(past GitHub's own 360-minute default), and `30485082274` sat `queued` behind it from
+2026-07-29 19:35 UTC onward. Both report directories were written empty.
+
+A reboot of the `ci` guest at 10:48 did not clear the condition: the same kernel state
+re-formed at 10:52:23.
+
+## Root cause
+
+Five layers, each independently observable.
+
+### 1. A permanently wedged hub worker holds a root-hub device lock
+
+A device that repeatedly re-asserts connect while failing to enumerate keeps
+`hub_event()` busy, and `hub_event()` holds `usb_lock_device(hdev)` on its hub for its
+whole run (hub.c:5896/5989). The `usb_hub_wq` worker sits in `hub_port_reset`, so that
+hub's `device_lock` is effectively never released:
+
+```
+kworker/14:6+usb_hub_wq (state D, 400+ s)
+ msleep+0x2b
+ hub_port_reset+0x1a4 [usbcore]
+ hub_event+0x727 [usbcore]
+```
+
+`usb usbN-portM: Cannot enable. Maybe the USB cable is bad?` is logged every four seconds
+for as long as it lasts.
+
+Verified against hub.c v6.12.96 rather than inferred: the kernel does **not** retry
+without bound, and root and downstream ports are bounded identically —
+`hub_port_reset()` tries `PORT_RESET_TRIES` then logs that message (hub.c:3149),
+`hub_port_connect()` wraps it in `PORT_INIT_TRIES` = 4 and disables the port on give-up
+(hub.c:5455/5619). A count in the thousands is therefore that many separate connect
+events, not one runaway loop, and it indicts the device rather than the port.
+
+### 2. A parked board storms the second controller
+
+`ra6m5_ek` (`test/hil/tinyusb.json`, uid `8419032D32363657364EF4622D294B4E`, at
+`13-3.3`) runs dfu firmware (`cafe:400b`) and re-enumerates every 1-2 seconds
+continuously, wrapping the entire bus-13 devnum space (`...120 -> 127 -> 4 -> 6 -> 10`).
+This is standing `hub_event` and Address-Device pressure on controller `03:00.0`,
+concurrent with parallel usbtest batteries on the same silicon.
+
+The board is already listed in `boards-skip`, which is precisely why it storms:
+`boards-skip` stops testing a board but never parks it, so it keeps running whatever
+firmware it last received. Park-flash only runs as teardown of a board that actually
+executed tests.
+
+### 3. The kernel `usbtest` control-queue case waits without a timeout
+
+`test_ctrl_queue` blocks on an untimed `wait_for_completion()` while `usbdev_ioctl`
+holds the DUT's `device_lock`:
+
+```
+wait_for_completion+0x8a <- no _timeout variant
+test_ctrl_queue+0x4ab [usbtest]
+usbtest_do_ioctl+0x501 [usbtest]
+usbdev_ioctl+0x6b8 [usbcore]
+```
+
+`--timeout 60` in `test/hil/usbtest.py` is a subprocess timeout only. `SIGKILL` is not
+delivered to a task in uninterruptible sleep. `usbtest.py` already recognises this and
+reports `HUNG`, then calls `usb_recover.sh root-cycle`.
+
+### 4. openocd inherits the convoy and the whole fleet dies
+
+Once a device lock is stuck, `port_event()` takes a child device's lock to warm-reset
+it and blocks while still holding its hub's lock. Any later
+`open("/dev/bus/usb/BBB/DDD")` against such a device blocks uninterruptibly:
+
+```
+usbdev_open+0xdc [usbcore] -> __mutex_lock
+chrdev_open -> do_sys_openat2 -> __x64_sys_openat
+```
+
+That is the state of the three `openocd` processes at 04:16:51 (pids 207921, 207987,
+208034) — the flasher, unkillable. Because one controller carries two buses, a single
+convoy takes out every board on both, which is why the failure presents as the entire
+fleet.
+
+The existing `HUNG` recovery cannot help here. A root-port VBUS cycle frees a
+*device-lock* holder; it cannot free a lock held by a stuck *hub worker*, and on this
+rig the cycle lands on the controller that is already wedged.
+
+### 5. Nothing bounds the damage, so one bad run becomes a CI outage
+
+- `hil-tinyusb` and `hil-tinyusb-esp` in `.github/workflows/build.yml` carry no
+ `timeout-minutes`. Only `hil-hfp-iar` does.
+- `ci.lan` runs a single runner service, so there is one job slot.
+- `test/hil/hil_test.py` bounds the pool with `POOL_TIMEOUT` (4200 s), and that guard
+ fires correctly — but the recovery path does not survive a D-state worker:
+
+```python
+with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=initargs) as pool:
+ async_ret = pool.map_async(test_board, config_boards)
+ try:
+ mret = async_ret.get(timeout=POOL_TIMEOUT)
+ except MpTimeoutError:
+ pool.terminate()
+ pool.join() # blocks forever: a D-state worker never reaps
+ raise RuntimeError(f'HIL worker pool timed out after {POOL_TIMEOUT}s')
+```
+
+`multiprocessing` joins workers unbounded, so both `pool.terminate()` and
+`pool.join()` hang, as does the `with Pool(...)` exit on the success path. Normal
+`hil-tinyusb (tinyusb.json)` runs take 10-20 minutes; one recent run took 71.3
+minutes, which is the 70-minute guard firing and succeeding. The eight-hour run is the
+pathological case.
+
+## Design
+
+### Step 0 — recovery (manual prerequisite)
+
+Power-cycle the PVE **host**, not the `ci` guest. A guest reboot is not sufficient;
+hubs latch up across the PCIe reset, which the 10:48 reboot demonstrated. Nothing
+below can be verified until the rig is clean.
+
+### Section 1 — CI containment
+
+**1a. Two layered timers.** An inner guard inside `hil_test.py` (`POOL_TIMEOUT`, 70 min)
+that fails gracefully -- it writes a report naming the timeout and the dispatched boards,
+shuts the pool down and exits -- and an outer `timeout-minutes` per rig job (85 for the
+hil-tinyusb jobs; 115 for hil-hfp-iar, which also builds four boards with IAR in the same
+job) as the backstop for when even exiting cannot free the runner. The ceiling must stay
+ABOVE the inner guard, or GitHub kills the job before the report is written.
+
+> **Corrected after measurement.** An earlier revision cut the guard to 30 min on the
+> reading that real runs take 9-17 min and everything longer was the old guard firing.
+> That was wrong. `hil_lock.py` records 22.2/14.3/12.5/10.8 min at usbtest width 1/2/3/4,
+> and raising the per-battery budget to 380s made hung boards cost more again. The 30 min
+> guard then fired on 5 of the last 8 HIL job executions across both rigs, and because
+> `map_async` is all-or-nothing each of those runs published a banner instead of any
+> per-board result. Restored to 4200s, the value whose original rationale -- usbtest
+> batteries are serialized fleet-wide, lengthening the tail -- was correct.
+
+**1b. Bound the pool shutdown.** Add a helper to `test/hil/hil_test.py`:
+
+```python
+def _shutdown_pool(pool, grace=30):
+ """terminate() a Pool without ever blocking forever: multiprocessing joins its
+ workers unbounded, and a worker in uninterruptible sleep (wedged usbfs) never
+ reaps -- which would hold the runner's only job slot indefinitely."""
+ t = threading.Thread(target=pool.terminate, daemon=True)
+ t.start()
+ t.join(grace)
+ return not t.is_alive()
+```
+
+On the `MpTimeoutError` path: write the report first, recording the boards that never
+reported so the run stops producing an empty report directory; then `_shutdown_pool`;
+then `os._exit(1)` if it did not return. The hard exit is the point — it is the only
+way past a kernel-side unkillable child. Use the same helper for the `with Pool(...)`
+exit path.
+
+**1c. Pre-flight rig health check.** `check_rig_health()` runs before the build and
+**never aborts**. It probes `/proc` unprivileged (dmesg is restricted on the rig) for a
+wedged `usb_hub_wq` worker, and reports a `/proc` too restricted to trust as its own
+distinct cause rather than as a diagnosed fault.
+
+It is deliberately non-fatal: the rig is unattended and every remedy for a real wedge is
+manual, so aborting would not fix anything -- it would discard the per-board results the
+run can still collect and leave CI red until a human noticed. It emits a GitHub
+`::error::` annotation and continues. The automatic containment is 1a and 1b, which bound
+a stuck run and explain it without anyone touching the rig.
+
+**1d. Order the recovery correctly.** In `test/hil/usbtest.py`, attempt
+`usb_recover.sh root-cycle` FIRST on a `HUNG` case, and only check for a wedged hub worker
+*afterwards*.
+
+> **Corrected during implementation.** This section originally said to check for a wedged
+> worker *before* the cycle and skip it on a hit. That is backwards. Our own stuck
+> `testusb` holds the DUT's device lock, so any port event drives a hub worker into
+> `usb_lock_device()` on it -- uninterruptible, so it reads `D` in ~100% of samples and the
+> confirmation window makes the wrong verdict *more* confident, not less. Cutting VBUS is
+> precisely what completes the in-flight URB, returns the ioctl and frees that worker, so
+> gating on that signature would suppress the recovery in the exact ordering it exists for.
+> A worker still wedged after the cycle is the genuinely unrecoverable case, and that is
+> what the code now reports.
+
+## Verification
+
+- Unit-test `shutdown_pool` and the `hil_health` detectors against a synthetic `/proc`.
+ A real wedge cannot be manufactured on demand, so they are tested against fabricated
+ inputs rather than live hardware.
+- Confirm the detectors flag a genuinely wedged rig, and return clean on a healthy one.
+- One clean full-fleet `hil_test.py` run to prove `check_rig_health` does not
+ false-abort.
+
+## Out of scope
+
+- **`ra6m5_ek` park and its dfu reset loop.** Dropped by decision. Consequence: the
+ layer-2 devnum storm remains as standing pressure on controller `03:00.0`. Unplugging
+ the board or flashing `board_test` by hand resolves it without any code change.
+- **An unattended PVE watchdog** that detects the wedge and power-cycles the host.
+ Declined: more moving parts, and it can cut a running CI job.
diff --git a/hw/bsp/lpc43/family.c b/hw/bsp/lpc43/family.c
index 411ea7d58..7f0722a33 100644
--- a/hw/bsp/lpc43/family.c
+++ b/hw/bsp/lpc43/family.c
@@ -59,6 +59,30 @@ void SystemInit(void);
// Invoked by startup code
void SystemInit(void)
{
+#if defined(__ICCARM__) && !defined(DONT_RESET_ON_RESTART)
+ __disable_irq();
+#endif
+
+ if (Chip_CREG_OnChipFlashIsPresent()) {
+ // The boot ROM configures flash for its 96 MHz clock, and debugger core
+ // resets can preserve it. Use safe timing before switching the M4 to 204 MHz.
+ Chip_CREG_SetFLASHAccess(FLASHTIM_SAFE_SETTING);
+ __DSB();
+ __ISB();
+ }
+
+#if defined(__ICCARM__) && !defined(DONT_RESET_ON_RESTART)
+ // A debugger restart resets the M4 core, but can leave LPC43 peripherals and
+ // pending interrupts active. Match the GCC startup sequence, which the IAR
+ // startup lacks, before the C runtime can reuse peripheral DMA memory.
+ LPC_RGU->RESET_CTRL[0] = 0x10DF1000u;
+ LPC_RGU->RESET_CTRL[1] = 0x01DFF7FFu;
+ for (uint32_t i = 0; i < 8; i++) {
+ NVIC->ICPR[i] = UINT32_MAX;
+ }
+ __enable_irq();
+#endif
+
#ifdef __USE_LPCOPEN
unsigned int *pSCB_VTOR = (unsigned int *) 0xE000ED08;
diff --git a/hw/bsp/mcx/family.cmake b/hw/bsp/mcx/family.cmake
index 60f43e152..b2b4fd45b 100644
--- a/hw/bsp/mcx/family.cmake
+++ b/hw/bsp/mcx/family.cmake
@@ -95,7 +95,7 @@ function(family_configure_example TARGET RTOS)
endif()
# PORT is set per board (board.cmake), so pick the driver at configure time. Spelled out
- # rather than $<IF:${PORT},...> so the port path stays greppable: test/hil/hil_select.py
+ # rather than $<IF:${PORT},...> so the port path stays greppable: test/hil/helper/hil_select.py
# maps a portable-driver change to the families whose build file names that directory.
if (PORT)
set(PORT_SRC ${TOP}/src/portable/chipidea/ci_hs/dcd_ci_hs.c)
diff --git a/hw/bsp/stm32l4/boards/stm32l412nucleo/board.h b/hw/bsp/stm32l4/boards/stm32l412nucleo/board.h
index a5250eda9..7f63ec431 100644
--- a/hw/bsp/stm32l4/boards/stm32l412nucleo/board.h
+++ b/hw/bsp/stm32l4/boards/stm32l412nucleo/board.h
@@ -64,9 +64,10 @@
* AHB Prescaler = 1
* APB1 Prescaler = 1
* APB2 Prescaler = 1
- * MSI Frequency(Hz) = 8000000
- * PLL_M = 1
- * PLL_N = 10
+ * MSI Frequency(Hz) = 48000000
+ * LSE Frequency(Hz) = 32768
+ * PLL_M = 6
+ * PLL_N = 20
* PLL_Q = 2
* PLL_R = 2
* VDD(V) = 3.3
@@ -78,29 +79,35 @@ static inline void board_clock_init(void)
{
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
- RCC_CRSInitTypeDef RCC_CRSInitStruct = {0};
RCC_PeriphCLKInitTypeDef PeriphClkInitStruct = {0};
/** Configure the main internal regulator output voltage
*/
HAL_PWREx_ControlVoltageScaling(PWR_REGULATOR_VOLTAGE_SCALE1);
+ /* HAL clock setup reconfigures its tick while MSI is the reset SYSCLK. */
+ HAL_InitTick((1UL << __NVIC_PRIO_BITS) - 1UL);
+
/** Initializes the RCC Oscillators according to the specified parameters
* in the RCC_OscInitTypeDef structure.
*/
- RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI48|RCC_OSCILLATORTYPE_HSI;
- RCC_OscInitStruct.HSIState = RCC_HSI_ON;
- RCC_OscInitStruct.HSI48State = RCC_HSI48_ON;
- RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
+ RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_LSE | RCC_OSCILLATORTYPE_MSI;
+ RCC_OscInitStruct.LSEState = RCC_LSE_ON;
+ RCC_OscInitStruct.MSIState = RCC_MSI_ON;
+ RCC_OscInitStruct.MSICalibrationValue = RCC_MSICALIBRATION_DEFAULT;
+ RCC_OscInitStruct.MSIClockRange = RCC_MSIRANGE_11;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
- RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI;
- RCC_OscInitStruct.PLL.PLLM = 1;
- RCC_OscInitStruct.PLL.PLLN = 10;
+ RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_MSI;
+ RCC_OscInitStruct.PLL.PLLM = 6;
+ RCC_OscInitStruct.PLL.PLLN = 20;
RCC_OscInitStruct.PLL.PLLQ = RCC_PLLQ_DIV2;
RCC_OscInitStruct.PLL.PLLR = RCC_PLLR_DIV2;
HAL_RCC_OscConfig(&RCC_OscInitStruct);
+ /* Stabilize MSI against the on-board 32.768 kHz LSE crystal. */
+ HAL_RCCEx_EnableMSIPLLMode();
+
/** Initializes the CPU, AHB and APB buses clocks
*/
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
@@ -112,24 +119,9 @@ static inline void board_clock_init(void)
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_4);
- /** Enable the SYSCFG APB clock
- */
- __HAL_RCC_CRS_CLK_ENABLE();
-
- /** Configures CRS
- */
- RCC_CRSInitStruct.Prescaler = RCC_CRS_SYNC_DIV1;
- RCC_CRSInitStruct.Source = RCC_CRS_SYNC_SOURCE_USB;
- RCC_CRSInitStruct.Polarity = RCC_CRS_SYNC_POLARITY_RISING;
- RCC_CRSInitStruct.ReloadValue = __HAL_RCC_CRS_RELOADVALUE_CALCULATE(48000000,1000);
- RCC_CRSInitStruct.ErrorLimitValue = 34;
- RCC_CRSInitStruct.HSI48CalibrationValue = 32;
-
- HAL_RCCEx_CRSConfig(&RCC_CRSInitStruct);
-
- /* Select HSI48 output as USB clock source */
+ /* Use the same LSE-trimmed MSI source for USB and the CPU PLL. */
PeriphClkInitStruct.PeriphClockSelection = RCC_PERIPHCLK_USB;
- PeriphClkInitStruct.UsbClockSelection = RCC_USBCLKSOURCE_HSI48;
+ PeriphClkInitStruct.UsbClockSelection = RCC_USBCLKSOURCE_MSI;
HAL_RCCEx_PeriphCLKConfig(&PeriphClkInitStruct);
/* Select PLL output as UART clock source */
diff --git a/src/class/mtp/mtp_device.c b/src/class/mtp/mtp_device.c
index 7657899ec..275c9f858 100644
--- a/src/class/mtp/mtp_device.c
+++ b/src/class/mtp/mtp_device.c
@@ -437,8 +437,11 @@ bool mtpd_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t event, uint32_t
TU_LOG_DRV(" MTP Data %s CB: xferred_bytes=%lu, xferred_len/total_len=%lu/%lu, is_complete=%d\r\n",
is_data_in ? "IN" : "OUT", xferred_bytes, p_mtp->xferred_len, p_mtp->total_len, is_complete ? 1 : 0);
- // Send/queue ZLP if packet is full-sized but transfer is complete
- if (is_complete && xferred_bytes > 0 && !(xferred_bytes & (threshold - 1))) {
+ // Send/queue ZLP if packet is full-sized but transfer is complete.
+ // OUT must deliver this final payload to the application before receiving
+ // its terminating ZLP below.
+ const bool need_zlp = is_complete && xferred_bytes > 0 && !(xferred_bytes & (threshold - 1));
+ if (is_data_in && need_zlp) {
TU_LOG_DRV(" queue ZLP\r\n");
TU_VERIFY(usbd_edpt_claim(p_mtp->rhport, ep_addr));
TU_ASSERT(usbd_edpt_xfer(p_mtp->rhport, ep_addr, NULL, 0, false));
@@ -466,9 +469,16 @@ bool mtpd_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t event, uint32_t
cb_data.io_container = headerless_packet;
cb_data.io_container.payload_bytes = xferred_bytes;
}
- tud_mtp_data_xfer_cb(&cb_data);
+ if (xferred_bytes > 0) {
+ tud_mtp_data_xfer_cb(&cb_data);
+ }
- if (is_complete) {
+ if (need_zlp) {
+ TU_LOG_DRV(" queue ZLP\r\n");
+ TU_VERIFY(usbd_edpt_claim(p_mtp->rhport, ep_addr));
+ TU_ASSERT(usbd_edpt_xfer(p_mtp->rhport, ep_addr, NULL, 0, false));
+ return true;
+ } else if (is_complete) {
// back to header + payload for response
cb_data.io_container = headered_packet;
cb_data.io_container.header->len = sizeof(mtp_container_header_t);
diff --git a/src/portable/chipidea/ci_hs/ci_hs_imxrt.h b/src/portable/chipidea/ci_hs/ci_hs_imxrt.h
index f0f918fe2..8f0d6083e 100644
--- a/src/portable/chipidea/ci_hs/ci_hs_imxrt.h
+++ b/src/portable/chipidea/ci_hs/ci_hs_imxrt.h
@@ -36,6 +36,9 @@ static const ci_hs_controller_t _ci_controller[] =
#define CI_HS_REG(_port) ((ci_hs_regs_t*) _ci_controller[_port].reg_base)
+// NXP recommends AHBBRST = INCR16 (remainder as unspecified-length bursts)
+#define CI_HS_SET_AHB_BURST(_p) (CI_HS_REG(_p)->SBUSCFG = SBUSCFG_AHBBRST_INCR16_UNSPEC)
+
//------------- DCD -------------//
#define CI_DCD_INT_ENABLE(_p) NVIC_EnableIRQ ((IRQn_Type)_ci_controller[_p].irqnum)
#define CI_DCD_INT_DISABLE(_p) NVIC_DisableIRQ((IRQn_Type)_ci_controller[_p].irqnum)
diff --git a/src/portable/chipidea/ci_hs/ci_hs_lpc18_43.h b/src/portable/chipidea/ci_hs/ci_hs_lpc18_43.h
index f2061bd7a..c7dc7e69f 100644
--- a/src/portable/chipidea/ci_hs/ci_hs_lpc18_43.h
+++ b/src/portable/chipidea/ci_hs/ci_hs_lpc18_43.h
@@ -34,4 +34,9 @@ static const ci_hs_controller_t _ci_controller[] =
#define CI_HCD_INT_ENABLE(_p) NVIC_EnableIRQ ((IRQn_Type)_ci_controller[_p].irqnum)
#define CI_HCD_INT_DISABLE(_p) NVIC_DisableIRQ((IRQn_Type)_ci_controller[_p].irqnum)
+// USB0 (high-speed) only: NXP recommends AHBBRST = INCR16 (remainder as
+// unspecified-length bursts)
+#define CI_HS_SET_AHB_BURST(_p) \
+ do { if ((_p) == 0) { CI_HS_REG(_p)->SBUSCFG = SBUSCFG_AHBBRST_INCR16_UNSPEC; } } while (0)
+
#endif
diff --git a/src/portable/chipidea/ci_hs/ci_hs_type.h b/src/portable/chipidea/ci_hs/ci_hs_type.h
index 70817a6e3..b209c7545 100644
--- a/src/portable/chipidea/ci_hs/ci_hs_type.h
+++ b/src/portable/chipidea/ci_hs/ci_hs_type.h
@@ -71,11 +71,18 @@ enum {
USBMODE_VBUS_POWER_SELECT = TU_BIT(5), // Need to be enabled for LPC18XX/43XX in host mode
};
+// SBUSCFG
+enum {
+ SBUSCFG_AHBBRST_INCR16_UNSPEC = 7, // INCR16 burst, remainder as unspecified-length bursts
+};
+
// Device Registers
typedef struct
{
//------------- ID + HW Parameter Registers-------------//
- volatile uint32_t TU_RESERVED[64]; ///< For iMX RT10xx, but not used by LPC18XX/LPC43XX
+ volatile uint32_t TU_RESERVED[36]; ///< ID/HW parameter registers, not used by this driver
+ volatile uint32_t SBUSCFG; ///< System Bus Interface Configuration (not present on every MCU)
+ volatile uint32_t TU_RESERVED[27];
//------------- Capability Registers-------------//
volatile uint8_t CAPLENGTH; ///< Capability Registers Length
diff --git a/src/portable/chipidea/ci_hs/dcd_ci_hs.c b/src/portable/chipidea/ci_hs/dcd_ci_hs.c
index fa98d6882..8c08c6bd5 100644
--- a/src/portable/chipidea/ci_hs/dcd_ci_hs.c
+++ b/src/portable/chipidea/ci_hs/dcd_ci_hs.c
@@ -237,6 +237,10 @@ bool dcd_init(uint8_t rhport, const tusb_rhport_init_t *rh_init) {
usbmode |= USBMODE_CM_DEVICE;
dcd_reg->USBMODE = usbmode;
+ #ifdef CI_HS_SET_AHB_BURST
+ CI_HS_SET_AHB_BURST(rhport);
+ #endif
+
#ifdef CFG_TUD_CI_HS_VBUS_CHARGE
dcd_reg->OTGSC = OTGSC_VBUS_CHARGE | OTGSC_OTG_TERMINATION;
#else
diff --git a/src/portable/chipidea/ci_hs/hcd_ci_hs.c b/src/portable/chipidea/ci_hs/hcd_ci_hs.c
index 3cb69acfa..0f24f5bb6 100644
--- a/src/portable/chipidea/ci_hs/hcd_ci_hs.c
+++ b/src/portable/chipidea/ci_hs/hcd_ci_hs.c
@@ -82,6 +82,10 @@ bool hcd_init(uint8_t rhport, const tusb_rhport_init_t *rh_init) {
hcd_reg->USBMODE = USBMODE_CM_HOST;
#endif
+ #ifdef CI_HS_SET_AHB_BURST
+ CI_HS_SET_AHB_BURST(rhport);
+ #endif
+
#if !TUH_OPT_HIGH_SPEED
hcd_reg->PORTSC1 |= PORTSC1_FORCE_FULL_SPEED;
#endif
diff --git a/src/portable/synopsys/dwc2/dcd_dwc2.c b/src/portable/synopsys/dwc2/dcd_dwc2.c
index 86aa54510..b2f1a93a4 100644
--- a/src/portable/synopsys/dwc2/dcd_dwc2.c
+++ b/src/portable/synopsys/dwc2/dcd_dwc2.c
@@ -1143,7 +1143,12 @@ static void handle_incomplete_iso_in(uint8_t rhport) {
xfer_ctl_t *xfer = XFER_CTL_BASE(epnum, TUSB_DIR_IN);
if (xfer->iso_retry > 0) {
xfer->iso_retry--;
- // Restart ISO transfe: re-write TSIZ and CTL
+ // Restart ISO transfer: re-write DMA address, TSIZ, and CTL
+ #if CFG_TUD_DWC2_DMA_ENABLE
+ if (dma_device_enabled(dwc2)) {
+ epin->diepdma = (uintptr_t) xfer->buffer;
+ }
+ #endif
dwc2_ep_tsize_t deptsiz = {.value = 0};
deptsiz.xfer_size = xfer->total_len;
deptsiz.packet_count = tu_div_ceil(xfer->total_len, xfer->max_size);
diff --git a/test/hil/helper/__init__.py b/test/hil/helper/__init__.py
new file mode 100644
index 000000000..a080a2f55
--- /dev/null
+++ b/test/hil/helper/__init__.py
@@ -0,0 +1,4 @@
+# Marks helper/ as a REGULAR package. Without this it is only a PEP 420 namespace portion,
+# and a regular package named `helper` anywhere on sys.path wins over it even though
+# test/hil is sys.path[0] -- one transitive pip install would break every HIL entry point
+# at import. `helper` is a real distribution name on PyPI.
diff --git a/test/hil/helper/hil_health.py b/test/hil/helper/hil_health.py
new file mode 100644
index 000000000..92f0accc8
--- /dev/null
+++ b/test/hil/helper/hil_health.py
@@ -0,0 +1,380 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+"""Shutting a wedged HIL run down: kill what the workers spawned, then report.
+
+A device whose usbfs node is held by a D-state process cannot be freed -- SIGKILL is not
+delivered in uninterruptible sleep -- so the goal is never to fix the rig from here. It is
+to free the runner's single job slot and leave a report naming what survived, instead of
+letting the job sit until GitHub cancels it with nothing to show.
+
+Deliberately shallow. We SIGKILL the process groups the workers spawned, wait a grace,
+and report whoever is still alive; we do not re-scan groups, prove pid ownership or
+escalate through sudo. A root-owned survivor is named in the report for hil_pool_check
+and the usb-kernel-recover skill to deal with -- signalling a pid we cannot prove is ours
+is the worse failure, and the job ceiling backstops whatever this misses.
+
+Everything here is stdlib-only and reads /proc unprivileged (dmesg is restricted on the
+rig), which keeps it importable -- and testable -- on a bare runner.
+"""
+import os
+import signal
+import threading
+import time
+from pathlib import Path
+
+PROC = Path('/proc')
+
+
+# How long to let a SIGKILL land before calling a process a survivor. Generous enough to
+# cover scheduling delay on a loaded rig, short enough that a fleet-wide sweep stays quick.
+CONFIRM_KILL_GRACE = 2.0
+
+
+def _p(*args, **kwargs) -> None:
+ # These run on the free-the-runner path, where stdout can already be a dead pipe (a
+ # dropped ssh session). An unguarded print would raise BrokenPipeError out of
+ # hil_test's inner finally, skipping shutdown_pool AND the report writing.
+ try:
+ print(*args, **kwargs)
+ except (OSError, ValueError):
+ # ValueError, not just OSError: printing to a CLOSED stream raises
+ # "ValueError: I/O operation on closed file", and both hil_pool_check and
+ # hil_test redirect stdout into a StringIO that can be closed under us. Escaping
+ # here skips shutdown_pool/kill_pool_children/os._exit -- stranding the runner,
+ # the exact failure this wrapper exists to prevent.
+ pass
+
+
+def _state(pid_dir: Path) -> str:
+ """The state letter from /proc/<pid>/stat. comm can contain ')', so the field is
+ located from the right rather than by splitting."""
+ # bytes, not read_text(): read_text decodes with the LOCALE encoding, so under LANG=C
+ # (systemd services, self-hosted runners) a non-ASCII comm raises UnicodeDecodeError
+ # and the entry silently vanishes from the scan.
+ stat = (pid_dir / 'stat').read_bytes()
+ return chr(stat[stat.rindex(b')') + 2])
+
+
+def _pids():
+ """/proc pid entries. Yields nothing rather than raising if /proc is unreadable."""
+ try:
+ entries = list(PROC.iterdir())
+ except OSError:
+ return
+ for entry in entries:
+ if entry.name.isdigit():
+ yield entry
+
+
+def d_state_note() -> str:
+ """Pids in uninterruptible sleep, for the report. Never aborts, never blocks.
+
+ A D-state process at start-up is NOT a fault on its own -- a healthy in-flight testusb
+ looks exactly like this, and the rig supports a dev run alongside CI. It is a hint for
+ whoever reads a red cell below. Diagnosis proper is hil_pool_check and the
+ usb-kernel-recover skill; this is one line, not a probe."""
+ stuck = []
+ for d in PROC.glob('[0-9]*'):
+ try:
+ if _state(d) == 'D':
+ stuck.append(d.name)
+ except (OSError, ValueError, IndexError):
+ pass # raced with exit, or /proc is restricted: not our problem here
+ if not stuck:
+ return ''
+ return (f'{len(stuck)} process(es) in D state when this run started: '
+ f'{sorted(stuck)[:10]}')
+
+
+def shutdown_pool(pool, grace: float = 30) -> bool:
+ """terminate() a worker Pool without ever blocking forever.
+
+ multiprocessing joins its workers unbounded (util.py _exit_function terminate()s the
+ daemonic ones, then calls p.join() -- no timeout -- on every remaining active child,
+ CPython 3.13.5), and a worker in uninterruptible sleep never
+ reaps -- so terminate() itself hangs, taking the runner's only job slot with it. False
+ when the pool refuses to die within `grace` (the caller must then abandon it); a
+ terminate() that *raises* counts as failure too, the pool being just as alive."""
+ outcome = {}
+
+ def _term():
+ try:
+ pool.terminate()
+ outcome['ok'] = True
+ except BaseException as e: # noqa: BLE001 - any failure means the pool is still up
+ # Say what happened: Pool._terminate_pool really can raise (CPython:
+ # AssertionError 'Cannot have cache with result_handler not alive'), and a
+ # swallowed one is indistinguishable from an unkillable D-state worker.
+ outcome['err'] = e
+ _p(f'warning: Pool.terminate() raised {type(e).__name__}: {e}', flush=True)
+
+ t = threading.Thread(target=_term, daemon=True)
+ t.start()
+ t.join(grace)
+ # Decide on the thread, not the dict: _term may set outcome['ok'] after join(grace)
+ # expired, reporting a merely-slow terminate as success on one read and abandoned on
+ # another. Still inside terminate() == not shut down.
+ if t.is_alive():
+ return False
+ return outcome.get('ok', False)
+
+
+def child_procs(pids) -> dict:
+ """{ancestor pid in `pids`: [(descendant pid, its pgid), ...]}, from ONE walk of /proc.
+
+ DESCENDANTS, not direct children: a worker's usbtest.py spawns its recovery flasher
+ through run_cmd (own session), so it is a GRANDCHILD that a direct-child sweep misses
+ and a kill mid-recovery would orphan on the probe. pgid comes back too because the two
+ kinds of child need different signals (see kill_pool_children)."""
+ wanted = set(pids)
+ by_parent: dict = {} # ppid -> [(pid, pgid), ...] for EVERY process
+ for entry in _pids():
+ try:
+ stat = (entry / 'stat').read_bytes()
+ except OSError:
+ continue # exited between the scan and the read, or not readable
+ # comm (field 2) is parenthesised and may contain spaces and ')' -- so split only
+ # what follows the LAST ')': state, ppid, pgrp, ...
+ try:
+ fields = stat[stat.rindex(b')') + 2:].split()
+ ppid, pgid = int(fields[1]), int(fields[2])
+ except (ValueError, IndexError):
+ continue # truncated or unparsable stat line
+ by_parent.setdefault(ppid, []).append((int(entry.name), pgid))
+ out: dict = {}
+ for root in wanted:
+ todo = list(by_parent.get(root, []))
+ while todo:
+ pid, pgid = todo.pop()
+ out.setdefault(root, []).append((pid, pgid))
+ todo += by_parent.get(pid, [])
+ return out
+
+
+def _pool_procs(pool, extra) -> list:
+ """The pool's worker Process objects, plus each extra's own process.
+
+ Manager() runs in its own child process and inherits the same descriptors as the
+ workers, so leaving it behind defeats the point: os._exit skips its finalizer."""
+ procs = list(getattr(pool, '_pool', []) or [])
+ for e in extra:
+ procs.append(getattr(e, '_process', e))
+ return procs
+
+
+
+
+def kill_worker_children(pool, *extra) -> int:
+ """SIGKILL what the pool's workers spawned; returns how many SURVIVED.
+
+ For the TIMEOUT path only. On the normal path each worker has already run
+ kill_own_children() and retired (maxtasksperchild=1), so this walks fresh idle workers
+ and finds nothing -- measured: 4 tasks, zero overlap with the pool at sweep time.
+
+ Call it BEFORE shutdown_pool(): terminate() reaps the (interruptible) worker and its
+ flasher is reparented to init, erasing the ppid link this matches on. Signalling the
+ worker's own group instead cannot work -- a forked pool worker inherits OUR group
+ (CPython 3.13.5 multiprocessing never setsid/setpgid) and run_cmd gives every flasher
+ a session of its own.
+
+ TWO passes because our SIGKILL can fail an in-flight flash and the worker then retries
+ in a fresh session, which one /proc snapshot misses. `seen` stops a pid signalled in
+ pass 1 being confirmed twice.
+ """
+ seen: set = set()
+ total = 0
+ for i in range(2):
+ if i:
+ time.sleep(0.5)
+ procs = _pool_procs(pool, extra)
+ total += _kill_kids(
+ child_procs(getattr(p, 'pid', None) for p in procs if p is not None), seen)
+ return total
+
+
+def kill_own_children() -> int:
+ """SIGKILL what THIS process spawned. Returns how many survived.
+
+ For the worker to call before it returns. maxtasksperchild=1 retires it the moment the
+ task ends, reparenting its children to init, so main()'s sweep walks fresh idle workers
+ and finds nothing (measured over 4 tasks: zero overlap, sweep 0, 4 strays alive).
+ Inside the worker the ppid link is still live.
+ """
+ return _kill_kids(child_procs([os.getpid()]), set())
+
+
+def _kill_kids(kids: dict, seen: set) -> int:
+ """SIGKILL every pid in a ppid-tree snapshot; return how many survived.
+
+ Every pid here is a DESCENDANT of a process we own, so it is ours by construction -- no
+ argv identity check, because we never signal anything we did not discover through our
+ own ppid tree.
+ """
+ try:
+ own = os.getpgid(0)
+ except OSError:
+ own = None # cannot tell our own group apart: never killpg, signal pids only
+ # One list: every pid here is a DESCENDANT of one of our own workers, so it is ours by
+ # construction -- no argv identity check needed, because we never signal anything we
+ # did not discover through our own ppid tree.
+ touched: list = []
+ for children in kids.values():
+ for cpid, cpgid in children:
+ if cpid in seen:
+ continue # a previous pass already signalled it
+ seen.add(cpid)
+ try:
+ if own is not None and cpgid != own:
+ # A run_cmd child: its own session, so one killpg also reaps what it
+ # spawned. Recorded because killpg cannot report a partial kill.
+ os.killpg(cpgid, signal.SIGKILL)
+ else:
+ # Shares our group (a plain subprocess.run), so killpg would take
+ # down the whole run -- it is signalled by pid in _kill_and_confirm.
+ pass
+ touched.append(cpid)
+ except PermissionError:
+ # NOT "already gone": the signal did not land, so this pid MUST still be
+ # confirmed, or the one case this handler exists for (an all-root session:
+ # the sudo wrapper died, its root members did not) is the one case that
+ # never reaches the report.
+ touched.append(cpid)
+ except ProcessLookupError:
+ pass # already gone
+ except OSError:
+ pass
+ # Both paths need confirming: a killpg'd flasher and a same-group mtype blocked on a
+ # wedged device are both in D state, and os.kill reported success on either.
+ denied = _kill_and_confirm(touched)
+ if denied:
+ _p(f'warning: could not kill {sorted(denied)}; they still hold whatever they '
+ f'had open (probe, usbfs node) into the next job', flush=True)
+ # SURVIVORS, not the signalled-child count: the caller needs to know the rig is dirty
+ # for the next job, and a count of what we successfully signalled cannot tell it that.
+ # (They are different units anyway -- a killpg is counted once per child sharing the
+ # group -- so the old return was never comparable to anything.)
+ return len(denied)
+
+
+def _kill_and_confirm(pids) -> list:
+ """SIGKILL every pid, then return those STILL alive after ONE grace window.
+
+ SIGKILL is QUEUED, not delivered, for a task in uninterruptible sleep -- and testusb
+ waits in a plain wait_for_completion() with no timeout (v6.12.96 usbtest.c:1404;
+ usb_sg_wait, message.c:765), so that is the normal state of a healthy in-flight case
+ too. os.kill returning success proves nothing; only the recheck does. It is also
+ asynchronous, so probing immediately reports a process we just killed as a survivor
+ (measured: 11 of 20 plain `sleep`s with no grace).
+
+ Signal all, then poll the set against ONE shared deadline: per-pid windows made this
+ scale with stray count, minutes on a convoy. A pid we cannot signal is reported, never
+ sudo-killed.
+ """
+ pending = []
+ for pid in pids:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ continue # already gone
+ except OSError:
+ pass # EPERM (root-owned): it stays, and the poll below reports it
+ pending.append(pid)
+
+ deadline = time.monotonic() + CONFIRM_KILL_GRACE
+ while True:
+ alive = []
+ for pid in pending:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ continue # ESRCH: genuinely gone
+ except OSError:
+ pass # EPERM: it exists; the state check decides
+ try:
+ # a ZOMBIE answers kill(pid, 0) too: dead, merely unreaped. Not a survivor.
+ if _state(PROC / str(pid)) == 'Z':
+ continue
+ except (OSError, ValueError, IndexError):
+ continue # unreadable: assume gone rather than cry wolf
+ alive.append(pid)
+ pending = alive
+ if not pending or time.monotonic() >= deadline:
+ return pending # outlasted SIGKILL: D state, or not ours to kill
+ time.sleep(0.02)
+
+
+def kill_pool_children(pool, *extra) -> int:
+ """SIGKILL the pool's worker processes themselves. Returns how many are STILL ALIVE
+ after the grace -- not how many were signalled.
+
+ Survivors, not signals: the caller turns this number into "power-cycle the host", so
+ counting signals would send someone to a hypervisor over workers that all died.
+
+ Call after a shutdown_pool() that returned False, and after kill_worker_children().
+ A D-state worker ignores SIGKILL, but every other worker dies and drops the inherited
+ descriptors -- a survivor holds the runner's stdout pipe open and the runner waits for
+ EOF even after we exit, so the early exit would not free the job slot."""
+ killed_procs: list = []
+ for proc in _pool_procs(pool, extra):
+ try:
+ # Process.kill(), never a raw pid: multiprocessing's _send_signal re-checks
+ # `self.returncode is None` first, so once shutdown_pool's thread has reaped a
+ # worker this is a no-op instead of signalling a pid the OS may have recycled.
+ # os.pidfd_open(proc.pid) is worse: it skips that guard entirely.
+ if proc is None or not proc.is_alive():
+ continue
+ proc.kill()
+ killed_procs.append(proc)
+ except (OSError, AttributeError, ValueError):
+ continue # already reaped, never started, or not a real process
+ # Re-check the Process objects, never the pids collected a moment ago: shutdown_pool's
+ # thread is STILL join()ing workers, so a pid killed here can be reaped and RECYCLED
+ # before _kill_and_confirm signals it -- and on EPERM that escalates to `sudo -n kill
+ # -9 <stale pid>`, killing an unrelated ROOT process as the last act before os._exit.
+ killed_pids = []
+ for proc in killed_procs:
+ try:
+ if proc.is_alive() and proc.pid is not None:
+ killed_pids.append(proc.pid)
+ except (OSError, AttributeError, ValueError):
+ continue
+ # SIGKILL is asynchronous and a D-state task ignores it: only a confirmed survivor
+ # justifies the caller's power-cycle wording
+ return len(_kill_and_confirm(killed_pids)) if killed_pids else 0
+
+
+def write_timeout_report(report_dir: Path, boards, secs: int, md_name: str,
+ banner: str = '', prefix: str = '') -> None:
+ """Leave a report behind when the worker pool has to be abandoned.
+
+ map_async is all-or-nothing, so a timeout loses every per-board result and the report
+ dir would stay empty with no reason for the failure. Any prior attempt's markdown is
+ kept below the banner."""
+ # `prefix` carries the preflight rig-health verdict: the timeout aborts before
+ # accumulate_report, so without it the report loses the one line saying WHY the pool
+ # never finished. The '\n' stops Markdown lazy continuation pulling the banner into
+ # the blockquote.
+ try:
+ # Built INSIDE the try: a roster entry without a 'name' key raises KeyError while
+ # assembling the board list, and outside the try that escaped and stranded the
+ # runner -- which is exactly what the broad handler below exists to prevent.
+ head = (prefix + '\n' if prefix else '') + (banner or (
+ f'**HIL run abandoned: worker pool timed out after {secs}s.**\n\n'
+ f'No per-board results could be collected for this attempt, so the '
+ f'table below (if any) is from an earlier one. Boards dispatched:\n\n'
+ + '\n'.join(f'- {b.get("name", "?")}' for b in boards) + '\n'))
+ report_dir.mkdir(parents=True, exist_ok=True)
+ md_path = report_dir / md_name
+ # Its own handler so it cannot take the write down with it: a report torn by an
+ # attempt killed mid-write raises UnicodeDecodeError (a ValueError, and prior
+ # reports always contain status emoji), which under a shared try skipped the write
+ # entirely. Losing the old table is a nicety; losing the banner is the failure.
+ try:
+ prior = md_path.read_text(encoding='utf-8') if md_path.is_file() else ''
+ except (OSError, ValueError):
+ prior = ''
+ md_path.write_text(head + (f'\n{prior}' if prior else ''), encoding='utf-8')
+ except Exception as e: # noqa: BLE001
+ # Deliberately broad: this is the first statement of the pool-abandon path, so ANY
+ # escape skips kill_pool_children and os._exit and strands the runner.
+ _p(f'warning: cannot write {md_name} to {report_dir}: {e}', flush=True)
diff --git a/test/hil/hil_lock.py b/test/hil/helper/hil_lock.py
index e570da16a..7757ef17d 100755
--- a/test/hil/hil_lock.py
+++ b/test/hil/helper/hil_lock.py
@@ -10,7 +10,6 @@ batteries per host controller; they have no CLI meaning. The CLI below
"""
import argparse
import fcntl
-import glob
import json
import os
import re
@@ -19,6 +18,9 @@ import signal
import sys
import time
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # helper/ scripts import via the test/hil root
+from helper import hil_util
+
BOARD_LOCK_DIR = '/tmp/tinyusb-hil-locks'
CI_REASON = 'hil_test.py' # release-protected holder tag (release refuses to kill it)
PROTECTED_REASONS = {CI_REASON, 'pool_check'} # cmd_release refuses to SIGTERM these holders
@@ -44,10 +46,9 @@ def flock_nb(board: str):
def write_record(fh, reason: str) -> bool:
- """Holder record; the flock itself is already held. Returns False on a write
- failure — acquire_board_lock stays best-effort (the flock is the authority),
- but cmd_hold aborts on it like board_lock.py did (a hold whose record is
- missing is invisible to status/release)."""
+ """Holder record; the flock itself is already held. Returns False on a write failure:
+ acquire_board_lock stays best-effort (the flock is the authority), but cmd_hold aborts
+ -- a hold whose record is missing is invisible to status/release."""
try:
fh.truncate(0)
fh.seek(0)
@@ -118,22 +119,29 @@ def acquire_board_lock(board_name, reason=CI_REASON):
return fh
-# Per-host-controller concurrency (see controller_of/controller_slot below): a usbtest battery
-# saturates its DUT's host controller, so batteries and flashes are budgeted per controller.
-# - uPD720201 cards need their latest firmware (>= 2.0.2.6; RAM-uploaded, reloads every
-# power cycle): ROM firmware dies under battery + re-enumeration churn, and usbtest.py
-# refuses the unlink-stress cases on it.
-# - widths (profiled 2026-07-13/14): wall time 22.2/14.3/12.5/10.8 min at usbtest width
-# 1/2/3/4, plateau after; flash width beyond 8 only adds flasher-hub contention;
-# battery case failures start at 12/8 (bandwidth stretch on shared leaf-hub uplinks).
-# - a marginal DUT port bouncing during concurrent batteries can wedge/kill a uPD720201
-# ("xHCI host not responding to stop endpoint command"): fix the port/cable or pull
-# the board, don't lower the widths (2026-07-16: every death traced to one board's port).
-FLASH_PARALLEL = int(os.getenv('HIL_FLASH_PARALLEL', '8'))
-USBTEST_PARALLEL = int(os.getenv('HIL_USBTEST_PARALLEL', '4'))
+# Per-host-controller concurrency (see controller_of/controller_slot below): a usbtest
+# battery saturates its DUT's host controller, so batteries and flashes are budgeted per
+# controller. The 4/2 defaults trade ~3.5 min on the usbtest leg for bandwidth margin on
+# the shared leaf-hub uplinks, where battery case failures were observed from 12/8
+# (profiled 2026-07-13/14: 22.2/14.3/12.5/10.8 min at usbtest width 1/2/3/4, plateau
+# after). Raise per run via HIL_FLASH_PARALLEL/HIL_USBTEST_PARALLEL.
+# - uPD720201 cards need firmware >= 2.0.2.6 (RAM-uploaded, reloads every power cycle):
+# the ROM firmware dies under battery + re-enumeration churn.
+# - a marginal DUT port bouncing during concurrent batteries can kill a uPD720201 ("xHCI
+# host not responding to stop endpoint command"): fix the port/cable or pull the board
+# -- lowering the widths does not fix a bad port (2026-07-16, every death).
+FLASH_PARALLEL = hil_util.pos_int_env('HIL_FLASH_PARALLEL', 4)
+USBTEST_PARALLEL = hil_util.pos_int_env('HIL_USBTEST_PARALLEL', 2)
CONTROLLER_SLOTS = 12 # lock slots; controllers are assigned to slots on first sight
-usbtest_sems = None # CONTROLLER_SLOTS semaphores: per-slot usbtest-battery permits
-flash_sems = None # CONTROLLER_SLOTS semaphores: per-slot flash permits
+# Bound on ONE permit wait. Generous: a real queue behind a slow board is normal,
+# and this only has to beat the pool guard so a leaked permit cannot consume it.
+PERMIT_TIMEOUT = hil_util.pos_int_env('HIL_PERMIT_TIMEOUT', 900)
+# CONTROLLER_SLOTS + 1 entries each, built by make_permit_sems: UNKNOWN_SLOT indexes the
+# extra one. Sized to CONTROLLER_SLOTS instead, the first unresolved board IndexErrors
+# inside a pool worker -- which now surfaces through drain_pool as a worker-raise (the
+# finished boards survive), but still loses this board and aborts the run.
+usbtest_sems = None # per-slot usbtest-battery permits
+flash_sems = None # per-slot flash permits
controller_map = None # shared dict: 'pci:<addr>' -> slot, 'uid:<uid>' -> pci addr cache
controller_meta = None # guards slot assignment in controller_map
controller_hints = {} # static uid -> pci from the last run's cache (read-only per worker)
@@ -155,29 +163,34 @@ def init_scheduling(b_sems, f_sems, cmap, cmeta, hints, log_fn=None):
# Per-controller scheduling
# -------------------------------------------------------------
def controller_of(uid: str):
- """Resolve a DUT uid to its root host controller's PCI address, or None if the device
- is not enumerated (e.g. parked in board_test firmware with USB off). Successful
- resolutions are cached — cabling does not change mid-run. Dual-port parts (e.g.
- CH32V307 usbhs/usbfs variants) share one uid and one cache entry: budgeting is only
- exact when both ports sit on the same controller (true on this rig)."""
+ """Resolve a DUT uid to its root host controller's PCI address, or None when it cannot
+ be resolved — the device is not enumerated (e.g. parked in board_test firmware with USB
+ off), or sysfs would not answer. Successful resolutions are cached — cabling does not
+ change mid-run. Dual-port parts (e.g. CH32V307 usbhs/usbfs variants) share one uid and
+ one cache entry: budgeting is only exact when both ports sit on the same controller
+ (true on this rig)."""
if controller_map is None:
return None
cached = controller_map.get(f'uid:{uid}')
if cached:
return cached
- for f in glob.glob('/sys/bus/usb/devices/*/serial'):
- d = os.path.dirname(f)
+ # vid='cafe' first: the target is always a TinyUSB DUT, and the VID is a lock-free
+ # descriptor field. Without it this read every probe's and hub's `serial` -- the
+ # attribute served under device_lock -- so a HEALTHY peer mid-usbtest would strand a
+ # reader here and spend one of this worker's four blindness credits.
+ devs, _ = hil_util.usb_scan(vid='cafe', serial=uid)
+ for dev in devs:
+ busnum = hil_util.read_sysfs(os.path.join(dev['dir'], 'busnum'))
+ if busnum is None or busnum is hil_util.SYSFS_UNKNOWN:
+ continue
try:
- if open(f).read().strip().lower() != uid.lower():
- continue
- bus = int(open(os.path.join(d, 'busnum')).read())
- root = os.path.realpath(f'/sys/bus/usb/devices/usb{bus}')
- m = re.findall(r'[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]', root)
- if m:
- controller_map[f'uid:{uid}'] = m[-1]
- return m[-1]
- except (OSError, ValueError):
+ root = os.path.realpath(f'/sys/bus/usb/devices/usb{int(busnum)}')
+ except ValueError:
continue
+ m = re.findall(r'[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]', root)
+ if m:
+ controller_map[f'uid:{uid}'] = m[-1]
+ return m[-1]
return None
@@ -196,39 +209,73 @@ def controller_slot(pci: str) -> int:
return slot
+# Unresolved boards budget in a slot of their OWN, one past the real ones, and that slot
+# holds exactly ONE permit whatever the per-controller width is. Neither neighbour works:
+# a permit on every slot (the old fail-closed rule) serialized the whole fleet the moment
+# a worker went blind, while a full private budget let unknown boards run a second
+# controller's worth of batteries on top of the resolved ones -- doubling the load on
+# whichever physical controller they actually sit on, which is the saturation the
+# uPD720201 deaths above are attributed to. Width 1 caps the over-subscription at +1.
+UNKNOWN_SLOT = CONTROLLER_SLOTS
+
+
+def make_permit_sems(semaphore, width: int) -> list:
+ """One semaphore per controller slot at `width`, plus the unknown bucket at 1."""
+ return [semaphore(width) for _ in range(CONTROLLER_SLOTS)] + [semaphore(1)]
+
+
class controller_permit:
- """Context manager: one permit from `sems` on the board's controller slot. If the
- controller is unknown, fail closed: take one permit from EVERY slot, in order, so the
- operation respects the budget wherever it might land. `warn_unknown` logs that fallback
- (used by usbtest, where the device is expected to be enumerated by the caller)."""
+ """Context manager: one permit from `sems` on the board's controller slot. An
+ unresolved controller budgets in UNKNOWN_SLOT, which admits one at a time: unresolved
+ boards serialize against each other, never against the whole rig, and never add a
+ second full budget to a controller. `warn_unknown` logs that fallback (used by
+ usbtest, where the device is expected to be enumerated by the caller)."""
def __init__(self, sems, uid: str, warn_unknown: bool = False):
self.sems = sems
self.slots = None
self.uid = uid
+ # what __enter__ actually ACQUIRED. Not the same as self.slots: a bounded acquire
+ # that times out is skipped on purpose, and releasing it anyway would add a permit
+ # that was never taken -- multiprocessing semaphores are unbounded, so the width
+ # grows for the rest of the run, on the controller throttle that exists to keep
+ # concurrent batteries from killing the uPD720201 xHCI.
+ self.taken: list = []
if sems is None:
return
- pci = controller_of(uid)
- if pci is None and not warn_unknown:
- # last-run cabling hint, flash budgeting only: a mis-budgeted flash is harmless,
- # but a battery must never trust a stale hint (it could stack two batteries on
- # one controller). In practice only a board's first flash lands here - batteries
- # assert enumeration before taking their permit.
- pci = controller_hints.get(uid)
+ # Hint FIRST for flash budgeting: a mis-budgeted flash is harmless, and the board
+ # is usually parked in board_test with USB off at this point, so controller_of
+ # cannot resolve it anyway -- it just walks the whole bus to say so, once per
+ # flash permit (~14 examples x ~21 boards a leg), each walk spawning a bounded
+ # reader per device. usbtest still resolves for real (warn_unknown), and by then
+ # the DUT is enumerated, so that walk succeeds and caches.
+ pci = None if warn_unknown else controller_hints.get(uid)
+ if pci is None:
+ pci = controller_of(uid)
if pci is None and warn_unknown:
- log(f'warning: cannot resolve {uid} to a host controller; '
- 'taking a permit on every slot (over-serialized)')
- self.slots = [controller_slot(pci)] if pci else list(range(CONTROLLER_SLOTS))
+ log(f'warning: cannot resolve {uid} to a host controller'
+ f'{hil_util.sysfs_blind_note()}; budgeting it in the unknown bucket')
+ self.slots = [controller_slot(pci) if pci else UNKNOWN_SLOT]
def __enter__(self):
if self.slots:
t0 = time.monotonic()
- taken = []
+ taken = self.taken = []
try:
for s in self.slots:
- self.sems[s].acquire()
+ # BOUNDED. multiprocessing semaphores are NOT released when a holder
+ # dies, and the pool sweep SIGKILLs workers -- so a permit lost that
+ # way would block every later worker on this controller forever, and
+ # boards unrelated to the wedge would burn the whole pool guard. On
+ # expiry proceed over-subscribed and say so: a slower controller is a
+ # far better failure than a hung run.
+ if not self.sems[s].acquire(timeout=PERMIT_TIMEOUT):
+ log(f'warning: waited {PERMIT_TIMEOUT}s for a permit on slot {s} '
+ f'(uid {self.uid}); a holder probably died without releasing '
+ f'it -- proceeding over-subscribed')
+ continue
taken.append(s)
- # stays inside the try: if this raises (e.g. broken stdout), the permits
- # must be released - a failed __enter__ never gets its __exit__
+ # inside the try: a failed __enter__ never gets its __exit__, so a raise
+ # here (e.g. broken stdout) must still release the permits
if PROFILE and time.monotonic() - t0 > 1.0:
log(f'[prof] permit wait {time.monotonic() - t0:.1f}s '
f'(uid {self.uid}, slots {self.slots})')
@@ -240,8 +287,9 @@ class controller_permit:
def __exit__(self, *exc):
if self.slots:
- for s in reversed(self.slots):
+ for s in reversed(self.taken):
self.sems[s].release()
+ self.taken = []
return False
@@ -288,12 +336,10 @@ def is_locked(board: str) -> bool:
def cmd_hold(boards, reason):
os.makedirs(BOARD_LOCK_DIR, exist_ok=True)
- # No pre-check: the holder's own LOCK_NB flock is the only authority — a
- # recorded pid may be stale or recycled (e.g. a live hil_test.py worker
- # that already released this board's flock but not its record).
- # The holder signals success through this pipe. A generic is_locked()
- # poll would be fooled by a RIVAL invocation's flock — only the holder
- # itself knows whether it won every board.
+ # No pre-check: the holder's own LOCK_NB flock is the only authority, since a recorded
+ # pid may be stale or recycled. The holder signals success through this pipe because a
+ # generic is_locked() poll would be fooled by a RIVAL invocation's flock — only the
+ # holder knows whether it won every board.
r_fd, w_fd = os.pipe()
pid = os.fork()
if pid > 0:
@@ -317,12 +363,12 @@ def cmd_hold(boards, reason):
os._exit(0)
# holder (grandchild): acquire all flocks, signal the parent, sleep until killed
os.close(r_fd)
- # Keep the success pipe clear of fds 0-2: invoked with stdio closed,
- # os.pipe() can land there and the dup2 loop below would clobber it.
+ # Keep the success pipe clear of fds 0-2: invoked with stdio closed, os.pipe() can
+ # land there and the dup2 loop below would clobber it.
if w_fd <= 2:
w_fd = fcntl.fcntl(w_fd, fcntl.F_DUPFD, 3)
- # Detach stdio: a `hold` whose output is captured must see EOF when the
- # front-end exits — the immortal holder must not keep that pipe open.
+ # Detach stdio: a `hold` whose output is captured must see EOF when the front-end
+ # exits — the immortal holder must not keep that pipe open.
devnull = os.open(os.devnull, os.O_RDWR)
for std_fd in (0, 1, 2):
os.dup2(devnull, std_fd)
@@ -345,8 +391,8 @@ def cmd_hold(boards, reason):
os.close(w_fd)
def _bow_out(*_):
- # clear the records before dying so read_record/status stay truthful
- # (the kernel drops the flocks themselves on exit either way)
+ # clear the records before dying so read_record/status stay truthful (the kernel
+ # drops the flocks themselves on exit either way)
for h in handles:
clear_record(h)
os._exit(0)
@@ -368,8 +414,8 @@ def cmd_release(boards):
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
- # flock genuinely held — never SIGTERM on a mere pid record: the
- # pid may be recycled, or a live worker that already moved on.
+ # flock genuinely held — never SIGTERM on a mere pid record: the pid may be
+ # recycled, or a live worker that already moved on.
fh.close()
info = read_record(b) or {}
pid = info.get('pid')
@@ -449,9 +495,9 @@ def main():
p_hold.add_argument('boards', nargs='*')
p_hold.add_argument('--all', action='store_true')
p_hold.add_argument('--config',
- default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ default=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'tinyusb.json'),
- help='board roster JSON (default: tinyusb.json beside this script)')
+ help='board roster JSON (default: tinyusb.json in test/hil, one level above this script)')
p_hold.add_argument('--reason', required=True)
p_rel = sub.add_parser('release')
p_rel.add_argument('boards', nargs='*')
diff --git a/test/hil/hil_pool_check.py b/test/hil/helper/hil_pool_check.py
index 98f24288a..371aff1e1 100644
--- a/test/hil/hil_pool_check.py
+++ b/test/hil/helper/hil_pool_check.py
@@ -16,7 +16,7 @@ reported, never waited on or bypassed).
Config is picked by hostname unless given: ci -> tinyusb.json, tusb (hifiphile
rig) -> hfp.json, anything else is a dev PC -> local.json.
-Lives in test/hil/ beside hil_lock.py and hil_flash.py, which it imports; board
+Lives in test/hil/helper/ beside hil_lock.py; imports it and hil_flash; board
recovery uses the repo's .claude/skills/usb-kernel-recover/scripts/usb_recover.sh.
"""
@@ -29,19 +29,17 @@ import re
import shlex
import shutil
import socket
-import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
-REPO_ROOT = Path(__file__).resolve().parents[2]
-sys.path.insert(0, str(Path(__file__).resolve().parent)) # for import-as-module callers
-
-import hil_lock
+sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # hil_flash + the helper package
import hil_flash
+from helper import hil_lock, hil_util
+REPO_ROOT = hil_util.TINYUSB_ROOT
USB_RECOVER = REPO_ROOT / '.claude' / 'skills' / 'usb-kernel-recover' / 'scripts' / 'usb_recover.sh'
SEEN_CACHE = Path.home() / '.cache' / 'tinyusb-hil' / 'pool_seen.json'
CONFIG_BY_HOST = {'ci': 'tinyusb.json', 'tusb': 'hfp.json'} # anything else: dev PC -> local.json
@@ -56,6 +54,7 @@ ENUM_WAIT_RETRY = 8 # s, uid wait after a recovery reset/re-flash
SERIAL_WAIT = 6 # s, host-board serial-output wait
print_mutex = threading.Lock()
+_UNKNOWN_WARNED = False # scan_usb's caveat: once per process, not once per poll
t0 = time.monotonic()
@@ -66,20 +65,33 @@ def say(msg: str) -> None:
def scan_usb() -> dict:
"""busport -> {'serial', 'vidpid', 'ino'} for every enumerated USB device. Only
- <bus>-<port>[.<port>...] dirs match (root hubs, named 'usbN' with no dash, are
- excluded: their fabricated PCI-address 'serial' and slow autosuspend-wake read
- cost 6-7s/scan on this rig). Keyed by busport, not serial: a serial can be
- shared by two different devices (e.g. an Espressif USB-Serial-JTAG bridge and
- the cafe TinyUSB device it flashes derive both from the same MAC) — collapsing
- them into one dict slot would silently drop whichever lost the race."""
+ <bus>-<port>[.<port>...] dirs match; root hubs ('usbN', no dash) are excluded because
+ their 'serial' is a fabricated PCI address, and including them measured 6-7s/scan slower
+ (an observation; NOT an autosuspend wake -- that read is cached and does no I/O).
+ Keyed by busport, not serial: two devices can share a serial (an Espressif
+ USB-Serial-JTAG bridge and the cafe device it flashes both derive it from the same
+ MAC), and one dict slot would silently drop whichever lost the race."""
found = {}
- for f in glob.glob('/sys/bus/usb/devices/*-*/serial'):
- d = os.path.dirname(f)
- busport = os.path.basename(d)
+ # `unknown` matters BEFORE the blindness latch trips: one wedged device is the normal
+ # reason this tool is run, and its serial read stranding makes it absent from `devs`.
+ # Reported as fact, that is "probe MISSING" for hardware that is physically present.
+ devs, unknown = hil_util.usb_scan()
+ # ONCE per process: this is called from 0.5s poll loops across 4 worker threads and
+ # ~26 boards, so warning per call buried the table it exists to qualify under 600+
+ # identical lines. The memo in read_sysfs makes the condition sticky, so one line is
+ # as true as six hundred.
+ global _UNKNOWN_WARNED
+ if unknown and not _UNKNOWN_WARNED:
+ _UNKNOWN_WARNED = True
+ say('WARNING: at least one device did not answer a bounded read; rows below that '
+ 'say a probe or board is missing may be this scan losing sight of healthy '
+ 'hardware. Find the wedged device (usb-kernel-recover) and re-run.')
+ for dev in devs:
try:
- sn = open(f).read().strip().lower()
- vidpid = f'{open(d + "/idVendor").read().strip()}:{open(d + "/idProduct").read().strip()}'
- found[busport] = {'serial': sn, 'vidpid': vidpid, 'ino': os.stat(d + '/').st_ino}
+ found[dev['busport']] = {
+ 'serial': dev['serial'].lower(),
+ 'vidpid': f"{dev['vid']}:{dev['pid']}",
+ 'ino': os.stat(dev['dir'] + '/').st_ino}
except OSError:
continue
return found
@@ -112,7 +124,7 @@ def find_usb(uid: str, devs: dict | None = None):
def find_device(uid: str, pid: str | None):
"""Board-online check: TinyUSB device (idVendor cafe) with this uid, optionally
- PID-pinned. VID cafe keeps an Espressif USB-Serial-JTAG (303a) sharing the MAC
+ PID-pinned. VID cafe keeps an Espressif USB-Serial-JTAG (303a) that shares the MAC
serial from false-passing."""
for busport, dev in scan_usb().items():
if (dev['serial'] == uid.lower() and dev['vidpid'].startswith('cafe:')
@@ -134,22 +146,20 @@ def wait_device(uid: str, pid: str | None, old_ino, budget: float):
def lock_board(name: str):
- """Nonblocking flock per hil_lock.py protocol. Returns handle, or a str with
- the holder's info when the board is locked elsewhere. Board locks are ALWAYS
- respected: a held board is reported as locked and skipped — never waited on,
- and there is deliberately no bypass here."""
+ """Nonblocking flock per hil_lock.py protocol. Returns the handle, or a str with the
+ holder's info when the board is locked elsewhere. Board locks are ALWAYS respected: a
+ held board is reported and skipped, never waited on, and there is no bypass here."""
os.makedirs(hil_lock.BOARD_LOCK_DIR, exist_ok=True)
try:
fh = hil_lock.flock_nb(name)
except OSError:
- # NB: conflates a held flock with open() failures (EACCES/EROFS/ENOSPC) —
- # benign while everything on the rig runs as one uid; a cross-uid setup
- # would need flock_nb to distinguish the two
+ # NB: conflates a held flock with open() failures (EACCES/EROFS/ENOSPC) — benign
+ # while everything on the rig runs as one uid
info = hil_lock.read_record(name)
return json.dumps(info) if info else 'unknown holder'
if not hil_lock.write_record(fh, 'pool_check'):
- # an invisible lock (flock held, no record) is worse than no lock: status
- # can't show us and release can't recognize the protected holder — bail out
+ # an invisible lock (flock held, no record) is worse than no lock: status cannot
+ # show us and release cannot recognize the protected holder
hil_lock.clear_record(fh)
fh.close()
return 'ERROR: holder record write failed (lock dir unwritable?)'
@@ -165,24 +175,27 @@ def can_recover() -> bool:
if not USB_RECOVER.is_file():
return False
try:
- r = subprocess.run(['sudo', '-n', 'true'], capture_output=True)
- except OSError: # sudo not installed (bare dev PC/container): recovery off, not fatal
+ # run_cmd, not subprocess.run: run's post-timeout reap is an UNBOUNDED wait(), and
+ # our kill bounces off a setuid-root sudo with EPERM, leaving communicate() on a
+ # pipe that never closes. run_cmd killpgs, escalates through sudo, reaps bounded.
+ r = hil_util.run_cmd('sudo -n true', timeout=10, quiet=True)
+ except OSError: # sudo not installed
return False
return r.returncode == 0
def recover_probe(uid: str, busport: str) -> bool:
- """Soft-replug an enumerated-but-wedged probe: deauthorize+reauthorize (no VBUS
- cut, touches only this device). Success = the probe re-enumerated (new sysfs
- inode), not the helper's exit code (observed to flake while the toggle worked).
- J-Links respond with a full disconnect and can stay off the bus for >8 s."""
+ """Soft-replug an enumerated-but-wedged probe: deauthorize+reauthorize (no VBUS cut,
+ touches only this device). Success = the probe re-enumerated (new sysfs inode), not the
+ helper's exit code, which flakes while the toggle works. J-Links respond with a full
+ disconnect and can stay off the bus for >8 s."""
pre = find_usb(uid)
- try:
- # bounded: the sysfs authorized store can block in D state on a wedged
- # device, and this runs while the board's (release-protected) flock is held
- subprocess.run(['sudo', '-n', str(USB_RECOVER), 'authorized', busport],
- capture_output=True, text=True, timeout=30)
- except subprocess.TimeoutExpired:
+ # Bounded through run_cmd (same reason as can_recover): the sysfs authorized store can
+ # block in D state on a wedged device, and this runs while the board's release-
+ # PROTECTED flock is held -- a hang here would lock the board until the host reboots.
+ cmd = ' '.join(shlex.quote(a) for a in
+ ['sudo', '-n', str(USB_RECOVER), 'authorized', busport])
+ if hil_util.run_cmd(cmd, timeout=30, quiet=True).returncode == 124:
return False
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
@@ -271,31 +284,28 @@ def get_expected_pid(example: str) -> str | None:
def call_flasher(fn, *fn_args) -> tuple[int, str]:
- """Run a hil_flash flash_*/reset_* backend, normalizing raises to a failure:
- several backends raise instead of returning nonzero (get_serial_dev
- RuntimeError when a bridge's /dev/serial/by-id node vanishes, config.env
- FileNotFoundError, .jlink script OSError) and an exception must not skip the
- caller's retry/recovery ladder. Returns (returncode, error line)."""
+ """Run a hil_flash flash_*/reset_* backend, normalizing raises to a failure: several
+ backends raise instead of returning nonzero (get_serial_dev when a bridge's
+ /dev/serial/by-id node vanishes, a missing config.env, a .jlink script OSError), and an
+ exception must not skip the caller's retry/recovery ladder. Returns (rc, error line)."""
try:
ret = fn(*fn_args)
if ret.returncode == 0:
return 0, ''
- err = flash_error_line(hil_flash.cmd_stdout_text(ret.stdout))
+ err = flash_error_line(hil_util.cmd_stdout_text(ret.stdout))
return ret.returncode, err or f'rc={ret.returncode}'
except Exception as e:
return -1, repr(e)[:90]
def flash(board: dict, fw, allow_recovery: bool, probe_port: str, note: list) -> bool:
- """Flash the resolved firmware with one retry; on repeated failure soft-replug
- the probe and always make one final flash attempt afterward, regardless of
- whether the replug is confirmed — some probes (WCH-Link, ST-Link, CP210x,
- picoprobe) leave their sysfs kobject intact across an authorized toggle
- instead of dropping off the bus. Returns True on success.
+ """Flash the resolved firmware with one retry; on repeated failure soft-replug the
+ probe and always make one final attempt afterward, confirmed replug or not — some
+ probes (WCH-Link, ST-Link, CP210x, picoprobe) keep their sysfs kobject across an
+ authorized toggle instead of dropping off the bus. Returns True on success.
- `fw` comes from pick_example: a re-resolve here would use the global search
- policy and miss a firmware ensure_fw just built into cmake-build/ under an
- exclusive -B."""
+ `fw` comes from pick_example: a re-resolve here would use the global search policy and
+ miss a firmware ensure_fw just built into cmake-build/ under an exclusive -B."""
fn = getattr(hil_flash, f'flash_{board["flasher"]["name"].lower()}')
for attempt in range(3):
if attempt == 2:
@@ -303,9 +313,9 @@ def flash(board: dict, fw, allow_recovery: bool, probe_port: str, note: list) ->
return False
cur = find_usb(board['flasher']['uid'])
if cur is None:
- # probe gone from the bus: its old busport may now hold an UNRELATED
- # device (bus renumbering) and the helper only checks occupancy, so
- # toggling would deauthorize an innocent fixture — skip the toggle
+ # probe gone from the bus: its old busport may now hold an UNRELATED device
+ # (bus renumbering) and the helper only checks occupancy, so toggling would
+ # deauthorize an innocent fixture
note.append('probe vanished before toggle')
else:
say(f'{board["name"]:26} recovery: replugging probe {cur[0]} (authorized toggle)')
@@ -352,24 +362,21 @@ def check_host_serial(board: dict, do_reset: bool = True, want_hello: bool = Fal
the same probe can fail transiently and leave the target halted."""
import serial
try:
- port = hil_flash.get_serial_dev(board['flasher']['uid'], None, None, 0)
+ port = hil_util.get_serial_dev(board['flasher']['uid'], None, None, 0)
ser = serial.Serial(port, baudrate=115200, timeout=0.3, write_timeout=1)
except Exception as e:
say(f'{board["name"]:26} no flasher serial port: {e}')
return None
try:
- # flush BEFORE issuing the reset: pyserial's open-time flush is long past,
- # so this drops the pre-reset CDC backlog (which must not count as life)
- # while keeping the board's post-reset boot banner, which prints while the
- # reset tool is still tearing down and would be eaten by a post-reset flush
+ # flush BEFORE the reset: this drops the pre-reset CDC backlog (which must not
+ # count as life) while keeping the post-reset boot banner, which prints while the
+ # reset tool is still tearing down and a post-reset flush would eat
ser.reset_input_buffer()
if do_reset:
getattr(hil_flash, f'reset_{board["flasher"]["name"].lower()}')(board)
- # collect the WHOLE window and judge content, not the first chunk: the
- # probe's CDC bridge has its own FIFO, so stale pre-flash output (e.g.
- # board_test hellos) can arrive after our host-side flush and must not
- # decide the verdict alone. Early-exit once non-board_test output proves
- # a real example is talking.
+ # judge the WHOLE window, not the first chunk: the probe's CDC bridge has its own
+ # FIFO, so stale pre-flash output (e.g. board_test hellos) can arrive after our
+ # host-side flush and must not decide the verdict alone.
data = b''
deadline = time.monotonic() + SERIAL_WAIT
while time.monotonic() < deadline:
@@ -380,9 +387,9 @@ def check_host_serial(board: dict, do_reset: bool = True, want_hello: bool = Fal
pass
except serial.SerialException:
return None # port dropped mid-poll (bridge re-enumerating)
- # early-exit on the caller's positive signal: fresh board_test hello
- # (park verification) vs any non-board_test output (example liveness);
- # stale bridge-FIFO backlog of the OTHER kind must not end the window
+ # early-exit on the caller's positive signal (board_test hello for park
+ # verification, any non-board_test output for example liveness): stale
+ # bridge-FIFO backlog of the OTHER kind must not end the window
if want_hello:
if b'Hello from TinyUSB' in data:
return data
@@ -408,16 +415,13 @@ def boardtest_output(data: bytes) -> bool:
def build_example(board: dict, variant: str, example: str) -> int:
"""Build one example for this board: tools/build.py (same invocation shape as
- hil_test.build_board: -T target, -D per build.args, variant defines/flags,
- --build-name), or idf.py directly for espressif (tools/build.py's esp branch
- ignores -T and builds everything; variant flags travel as -DCFLAGS_CLI, the
- same channel tools/build.py uses). Bounded and process-group-killed via
- run_cmd; 600 s: a first configure+build of an SDK-heavy family (pico, nrf,
- esp) exceeds the old 300. Builds normally run pre-lock (pick_example / the
- pre-park ensure), so a board flock is not held here except on rare recovery
- paths. Per-build compile parallelism is capped at cpu/-j so -j concurrent
- builds cannot swamp sibling workers' verification windows. Returns the
- build's returncode (127 = ESP-IDF env missing)."""
+ hil_test.build_board), or idf.py directly for espressif (tools/build.py's esp branch
+ ignores -T and builds everything; variant flags travel as -DCFLAGS_CLI, the channel
+ tools/build.py uses). Bounded and process-group-killed via run_cmd; 600 s covers a
+ first configure+build of an SDK-heavy family (pico, nrf, esp). Builds normally run
+ pre-lock, so a board flock is not held here except on rare recovery paths. Per-build
+ compile parallelism is capped at cpu/-j so -j concurrent builds cannot swamp sibling
+ workers' verification windows. Returns the returncode (127 = ESP-IDF env missing)."""
name = board['name']
variants = board.get('variant') or [{'name': name}]
vcfg = next((v for v in variants if v['name'] == variant), variants[0])
@@ -436,9 +440,9 @@ def build_example(board: dict, variant: str, example: str) -> int:
# SOURCE tree (idf.py -B relocates only the build dir), so concurrent esp
# builds of one example for different targets corrupt each other's solve
with _esp_lock, _build_sem:
- return hil_flash.run_cmd(shlex.join(cmd), cwd=str(hil_flash.TINYUSB_ROOT),
- timeout=600).returncode
- cmd = [sys.executable, str(hil_flash.TINYUSB_ROOT / 'tools' / 'build.py'),
+ return hil_util.run_cmd(shlex.join(cmd), cwd=str(hil_util.TINYUSB_ROOT),
+ timeout=600).returncode
+ cmd = [sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'build.py'),
'-b', name, '-T', Path(example).name,
'-j', str(max(1, (os.cpu_count() or _jobs) // _jobs))]
for d in board.get('build', {}).get('args', []):
@@ -450,8 +454,8 @@ def build_example(board: dict, variant: str, example: str) -> int:
for tok in vcfg.get('flags', '').split():
cmd += [f'--cflag={tok}']
with _build_sem:
- return hil_flash.run_cmd(shlex.join(cmd), cwd=str(hil_flash.TINYUSB_ROOT),
- timeout=600).returncode
+ return hil_util.run_cmd(shlex.join(cmd), cwd=str(hil_util.TINYUSB_ROOT),
+ timeout=600).returncode
_deps_lock = threading.Lock() # one get_deps at a time (it also drains _build_sem)
@@ -463,15 +467,13 @@ _builds: dict = {} # (variant, example) -> (fw|None, reason): one at
def ensure_fw(board: dict, variant: str, example: str, note: list):
- """Firmware for `example`, building it when absent — never skip a board for
- lack of a build (--no-build opts out). One retry with deps fetched and the
- CMake caches dropped when the first build fails (fresh checkouts lack the
- family deps; a cache configured in a broken env poisons every later attempt).
- Returns the firmware path, or None with the failure noted. Call BEFORE
- taking the board lock: builds are long. One build attempt per
- (variant, example) per run, success or failure — memoized in _builds, so a
- repeat call (park, under the held flock) resolves instantly even when an
- exclusive -B hides the fresh cmake-build/ artifact from the global search."""
+ """Firmware for `example`, building it when absent — never skip a board for lack of a
+ build (--no-build opts out). One retry with deps fetched and the CMake caches dropped
+ when the first build fails (fresh checkouts lack the family deps; a cache configured
+ in a broken env poisons every later attempt). Returns the firmware path, or None with
+ the failure noted. Call BEFORE taking the board lock: builds are long. One attempt per
+ (variant, example) per run, memoized in _builds, so a repeat call (park, under the
+ held flock) resolves instantly even when an exclusive -B hides the fresh artifact."""
fw = hil_flash.find_firmware(variant, example, flasher=board['flasher']['name'])
if fw:
return fw
@@ -492,24 +494,22 @@ def ensure_fw(board: dict, variant: str, example: str, note: list):
note.append(f'build timeout: {base}')
return None
if rc != 0:
- # retry once with deps fetched and the CMake caches dropped (cache only —
- # a tree wipe would destroy every other example's firmware). get_deps
- # git-resets already-present shared deps (lib/fatfs's ffconf.h dance), so
- # it must exclude every in-flight build, not just other get_deps calls:
- # it drains ALL build slots before running.
+ # retry once with deps fetched and the CMake caches dropped (cache only — a tree
+ # wipe would destroy every other example's firmware). get_deps git-resets shared
+ # deps that are already present, so it drains ALL build slots first.
with _deps_lock:
for _ in range(_jobs):
_build_sem.acquire()
try:
- r = hil_flash.run_cmd(shlex.join([sys.executable, str(hil_flash.TINYUSB_ROOT / 'tools' / 'get_deps.py'),
- '-b', board['name']]),
- cwd=str(hil_flash.TINYUSB_ROOT), timeout=600)
+ r = hil_util.run_cmd(shlex.join([sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'get_deps.py'),
+ '-b', board['name']]),
+ cwd=str(hil_util.TINYUSB_ROOT), timeout=600)
finally:
for _ in range(_jobs):
_build_sem.release()
if r.returncode != 0:
note.append('get_deps failed')
- bd = hil_flash.TINYUSB_ROOT / 'cmake-build' / f'cmake-build-{variant}'
+ bd = hil_util.TINYUSB_ROOT / 'cmake-build' / f'cmake-build-{variant}'
# esp configures one level deeper (<variant>/<example>/): wipe both layouts
for d in (bd, bd / example):
shutil.rmtree(d / 'CMakeFiles', ignore_errors=True)
@@ -519,9 +519,8 @@ def ensure_fw(board: dict, variant: str, example: str, note: list):
_builds[key] = (None, 'fail')
note.append(f'build failed: {base}')
return None
- # tools/build.py and the idf.py invocation above always write to cmake-build/:
- # look there too even when an explicit -B narrowed the global search — this is
- # OUR fresh build, not a stale-candidate fallback
+ # both build paths write to cmake-build/, so look there even when an explicit -B
+ # narrowed the global search — this is OUR fresh build, not a stale fallback
fw = hil_flash.find_firmware(variant, example,
roots=[hil_flash.build_dir, 'cmake-build'],
flasher=board['flasher']['name'])
@@ -673,26 +672,24 @@ def check_board(board: dict, args, allow_recovery: bool, seen: dict) -> dict:
else 'probe never seen by pool_check')
say(f'{name:26} probe MISSING ({board["flasher"]["name"]} {board["flasher"]["uid"]})')
- # existing firmware only here; a missing build is built on the spot further
- # down (after a lock peek), except in scan/no-build modes — and never for a
- # missing probe (nothing could be flashed anyway)
+ # existing firmware only; a missing build is built further down (after a lock peek),
+ # except in scan/no-build modes and never for a missing probe
example, kind, variant, fw = pick_example(board, note, build_missing=False)
if kind == 'host':
note.append('host-only board')
if args.scan_only:
hit = find_device(board['uid'], None)
- # report the BOARD's usb state, not just the probe's: the enumerated device
- # (with busport), off-bus (normal when parked in board_test), or n/a for
- # host-only boards whose uid never enumerates
+ # report the BOARD's usb state too: enumerated (with busport), off-bus (normal
+ # when parked in board_test), or n/a for host-only boards
if hit:
row['device'] = f'✅ {hit[1]} @{hit[0]}'
elif kind == 'host':
row['device'] = '– n/a (host-only)'
else:
row['device'] = '⚫ off bus (parked?)'
- # scan verifies probe presence only: that check DID run, so probe present
- # is ok; a missing probe means no firmware could be delivered → flash-failed
+ # scan verifies probe presence only, so probe present is ok; a missing probe means
+ # no firmware could be delivered → flash-failed
row['status'] = 'ok' if probe else 'flash-failed'
if probe:
say(f'{name:26} probe ✅ {probe[0]}' + (f' device {hit[1]}' if hit else ''))
@@ -710,9 +707,9 @@ def check_board(board: dict, args, allow_recovery: bool, seen: dict) -> dict:
and hil_flash.find_firmware(bt_variant, 'device/board_test',
flasher=board['flasher']['name']) is None)
if need_example or need_bt:
- # builds are long and run BEFORE locking (park must never hold the flock
- # through a build); peek the lock first so minutes of building are not
- # wasted on — or a rebuilt tree swapped under — a board CI holds right now
+ # builds are long and run BEFORE locking (park must never hold the flock through
+ # one); peek first so minutes of building are not wasted on — or a rebuilt tree
+ # swapped under — a board CI holds right now
peek = lock_board(name)
if isinstance(peek, str):
if peek.startswith('ERROR:'): # environment failure, not a held lock
@@ -728,8 +725,8 @@ def check_board(board: dict, args, allow_recovery: bool, seen: dict) -> dict:
if need_example:
example, kind, variant, fw = pick_example(board, note, build_missing=True)
if need_bt and (example is not None or kind == 'host'):
- # skip the park-image build when the example build already failed on a
- # device board: the row returns before any flash/park could use it
+ # skip the park build when the example build already failed on a device board:
+ # the row returns before any flash/park could use it
ensure_board_test(board, bt_variant, note)
if example is None:
@@ -740,9 +737,8 @@ def check_board(board: dict, args, allow_recovery: bool, seen: dict) -> dict:
row['status'] = 'flash-failed'
say(f'{name:26} probe ✅ {probe[0]} (no firmware to flash)')
return row
- # host-only board: aliveness is still checkable without flashing — reset and
- # listen to whatever firmware is on it (the parked board_test echoes and
- # prints a periodic hello on the flasher UART)
+ # host-only board: aliveness is still checkable without flashing — reset and listen
+ # to whatever is on it (parked board_test echoes and hellos on the flasher UART)
lk = lock_board(name)
if isinstance(lk, str):
@@ -783,9 +779,8 @@ def check_board(board: dict, args, allow_recovery: bool, seen: dict) -> dict:
say(f'{name:26} {row["flash"]} {row["device"]}')
return row
finally:
- # teardown for EVERY path that attempted a flash (a failed programmer op
- # can still have erased/half-written the target): re-park while the
- # board lock is still held
+ # teardown for EVERY path that attempted a flash (a failed programmer op can
+ # still have erased/half-written the target), while the lock is still held
if not args.no_park:
park_board(board, kind, row, note)
finally:
@@ -801,8 +796,8 @@ def park_board(board: dict, kind: str, row: dict, note: list) -> None:
'failed' verify verdict — that is the more diagnostic signal), with one
exception: an espressif board without the ESP-IDF env cannot build
board_test — noted, not a board fault."""
- # capture BEFORE the park flash: uid-disappearance only verifies the park if
- # the device was on the bus to begin with (a fast park drops it immediately)
+ # capture BEFORE the park flash: uid-disappearance only verifies the park if the
+ # device was on the bus to begin with
on_bus_before = kind != 'host' and find_device(board['uid'], None) is not None
variant = resolve_variant(board, 'device/board_test', note)
fw = ensure_board_test(board, variant, note)
@@ -826,9 +821,9 @@ def park_board(board: dict, kind: str, row: dict, note: list) -> None:
row['status'] = 'flash-failed'
return
if kind == 'host':
- # no second reset (the park flash's own reset already started board_test);
- # POSITIVE marker: its hello must appear — stale example output may still
- # drain from the probe bridge's FIFO alongside it and is not disqualifying
+ # no second reset (the park flash's own reset started board_test); POSITIVE
+ # marker: its hello must appear, and stale bridge-FIFO output alongside it is not
+ # disqualifying
data = check_host_serial(board, do_reset=False, want_hello=True)
if not (data and b'Hello from TinyUSB' in data):
note.append('park unverified: no board_test output')
@@ -836,8 +831,8 @@ def park_board(board: dict, kind: str, row: dict, note: list) -> None:
row['status'] = 'flash-failed'
return
if not on_bus_before:
- # board never enumerated this run: uid-disappearance can't distinguish a
- # verified park from a silent no-op — say so instead of passing vacuously
+ # never enumerated this run: uid-disappearance cannot tell a verified park from a
+ # silent no-op — say so instead of passing vacuously
note.append('park unverified (device already off bus)')
return
deadline = time.monotonic() + 6
@@ -898,9 +893,8 @@ def controller_summary() -> list[str]:
def main() -> None:
- # toolchain/flasher CLIs live in the user bin dirs (arm-none-eabi-gcc + esptool
- # in ~/.local/bin, STM32_Programmer_CLI in ~/bin) which non-login shells may
- # lack — same PATH shim hil_ci.sh applies on the remote side
+ # toolchain/flasher CLIs live in the user bin dirs, which non-login shells may lack --
+ # the same PATH shim hil_ci.sh applies on the remote side
for d in (Path.home() / 'bin', Path.home() / '.local' / 'bin'):
if d.is_dir() and str(d) not in os.environ.get('PATH', '').split(os.pathsep):
os.environ['PATH'] = f'{d}{os.pathsep}{os.environ.get("PATH", "")}'
@@ -917,8 +911,8 @@ def main() -> None:
help='do not build missing firmware (default: build the light example on the spot)')
parser.add_argument('--no-park', action='store_true',
help='leave the light example running (default: park with board_test)')
- # no cross-process flash budget with a concurrent hil_test.py run yet (would need
- # a file-lock budget in hil_lock; hil_test uses in-process semaphores) — keep modest
+ # no cross-process flash budget against a concurrent hil_test.py run (its semaphores
+ # are in-process), so keep this modest
parser.add_argument('-j', '--jobs', type=int, default=4)
parser.add_argument('-v', '--verbose', action='store_true')
args = parser.parse_args()
@@ -946,12 +940,11 @@ def main() -> None:
boards = [b for b in boards if b['name'] in args.board]
hil_flash.build_dir = args.build_dir or 'examples'
- hil_flash.verbose = args.verbose
+ hil_util.verbose = args.verbose
if args.build_dir is None:
- # default mode: search both standard layouts (cmake-build/ from tools/build.py
- # + ESP-IDF, examples/ from manual builds). An EXPLICIT -B is exclusive — the
- # caller named an artifact tree, so a miss must report, not silently flash an
- # older build from elsewhere. hil_test's -B is likewise untouched by this.
+ # default mode: search both standard layouts (cmake-build/ from tools/build.py and
+ # ESP-IDF, examples/ from manual builds). An EXPLICIT -B stays exclusive: the caller
+ # named an artifact tree, so a miss must report rather than flash an older build.
hil_flash.EXTRA_BUILD_DIRS = ['cmake-build', 'examples']
allow_recovery = not args.scan_only and can_recover()
seen = {}
@@ -971,7 +964,7 @@ def main() -> None:
rows = [check_board_safe(b, args, allow_recovery, seen) for b in boards]
else:
with io.StringIO() as spool, ThreadPoolExecutor(max_workers=args.jobs) as pool:
- sys.stdout = spool # silence hil_flash's COMMAND FAILED dumps; say() uses __stdout__
+ sys.stdout = spool # silence hil_util.run_cmd's COMMAND FAILED dumps; say() uses __stdout__
try:
rows = list(pool.map(lambda b: check_board_safe(b, args, allow_recovery, seen), boards))
finally:
@@ -1008,6 +1001,17 @@ def main() -> None:
counts[r.get('status', 'failed')] += 1
print(f'\n{counts["ok"]} ok · {counts["flash-failed"]} flash-failed · {counts["failed"]} failed '
f'· {counts["locked"]} locked · in {time.monotonic() - t0:.0f}s')
+ if hil_util.sysfs_blind():
+ # Without this the table is the worst kind of wrong: once the process latches
+ # blind, every read answers SYSFS_UNKNOWN, scan_usb() returns {}, and EVERY board
+ # prints "probe MISSING"/"off bus" -- a clean-looking report declaring the whole
+ # fleet dead, produced during exactly the incident this tool is run to diagnose,
+ # and it sends the operator to power-cycle a rig where one device is wedged.
+ print('WARNING: this scan lost sight of the bus'
+ f'{hil_util.sysfs_blind_note()}. Rows above that say a probe or board is '
+ f'missing may be this tool losing sight of healthy hardware, not absent '
+ f'hardware. Find the wedged device (see the usb-kernel-recover skill) and '
+ f're-run before acting on the table.')
sys.exit(min(counts['flash-failed'] + counts['failed'], 125))
diff --git a/test/hil/hil_select.py b/test/hil/helper/hil_select.py
index 3ac3f1fdb..f0d4f0b9f 100755
--- a/test/hil/hil_select.py
+++ b/test/hil/helper/hil_select.py
@@ -2,7 +2,8 @@
# SPDX-License-Identifier: MIT
"""PR-diff -> HIL selection: which rig boards and which tests a change can affect.
-Stdlib-only (runs on bare CI runners; never imports hil_test/hil_flash/hil_lock).
+Stdlib-only (runs on bare CI runners; imports hil_util for the example rosters,
+never hil_test/pyserial — test_hil_util.BottomLayer enforces the stdlib closure).
Fail-open: any file no rule classifies forces the full matrix. See
docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md.
@@ -20,7 +21,8 @@ import re
import subprocess
import sys
-from hil_examples import device_tests, dual_tests, host_test
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # helper/ scripts import via the test/hil root
+from helper.hil_util import device_tests, dual_tests, host_test
ALL_TESTS = {'device': device_tests, 'dual': dual_tests, 'host': host_test}
@@ -31,7 +33,7 @@ _NONCODE_RE = re.compile(
r'^(docs/|\.claude/|.*\.(md|rst)$|LICENSE)')
_FULL_RE = re.compile(
r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
- r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|'
+ r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|'
r'tools/build\.py$|tools/get_deps\.py$|tools/cmake/|hw/mcu/|lib/|'
r'hw/bsp/(family_support\.cmake|board_api\.h|board\.c|ansi_escape\.h)$|'
r'examples/build_system/|examples/CMakeLists\.txt$|'
@@ -498,7 +500,9 @@ def main():
ap.add_argument('configs', nargs='+', help='rig roster JSON file(s)')
a = ap.parse_args()
- repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ # test/hil/helper/ -> repo root is FOUR levels up; three left this at <repo>/test
+ # after the helper/ move and every repo-relative glob silently matched nothing
+ repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
rosters = []
for c in a.configs:
with open(c) as f:
diff --git a/test/hil/helper/hil_util.py b/test/hil/helper/hil_util.py
new file mode 100644
index 000000000..54984d20f
--- /dev/null
+++ b/test/hil/helper/hil_util.py
@@ -0,0 +1,585 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Bottom layer of the HIL harness: the bounded command runner plus the shared helpers and
+# data every other module needs. Stays stdlib-only and imports nothing local -- everything
+# else imports this, including the unit tests on GitHub's bare runner; never import them
+# from here. Callers set the module global `verbose`.
+
+from __future__ import annotations
+
+import glob
+import os
+import signal
+import subprocess
+import threading
+import sys
+from pathlib import Path
+from typing import Any
+
+
+# -------------------------------------------------------------
+# HIL example test lists, shared by hil_test.py (runner) and hil_select.py (PR-diff
+# selector). Run order is shuffled per board (see test_board); every example carries a
+# unique hardcoded idProduct (see its usb_descriptors.c).
+# -------------------------------------------------------------
+
+# device tests
+device_tests = [
+ 'device/cdc_dual_ports',
+ 'device/cdc_msc',
+ 'device/dfu',
+ 'device/cdc_msc_throughput',
+ 'device/audio_test_freertos',
+ 'device/dfu_runtime',
+ 'device/cdc_msc_freertos',
+ 'device/hid_boot_interface',
+ 'device/msc_dual_lun',
+ 'device/hid_generic_inout',
+ 'device/printer_to_cdc',
+ 'device/midi_test',
+ 'device/mtp',
+ 'device/usbtest', # cafe:4010, unique PID; runs the Linux testusb tier-4 battery via usbtest.py
+ # 'device/net_lwip_webserver', # disabled for PR #3605: USB net iface enum is flaky on the CI HIL host
+]
+
+dual_tests = [
+ 'dual/host_info_to_device_cdc',
+]
+
+host_test = [
+ 'host/cdc_msc_hid',
+ 'host/msc_file_explorer',
+ 'host/msc_file_explorer_freertos',
+ 'host/device_info',
+]
+
+verbose = False
+
+def pos_int_env(name: str, default: int) -> int:
+ # One parsing policy for every HIL_* knob: a bare int() crashes every run at import
+ # on a malformed value, and 0/negative silently removes the bound the knob enforces.
+ try:
+ v = int(os.getenv(name, str(default)))
+ except ValueError:
+ print(f'warning: {name} is not an integer; using {default}',
+ file=sys.stderr, flush=True)
+ return default
+ if v <= 0:
+ print(f'warning: {name}={v} is not usable; using {default}',
+ file=sys.stderr, flush=True)
+ return default
+ return v
+
+
+def pos_float_env(name: str, default: float) -> float:
+ try:
+ v = float(os.getenv(name, str(default)))
+ except ValueError:
+ print(f'warning: {name} is not a number; using {default}',
+ file=sys.stderr, flush=True)
+ return default
+ # float() accepts 'inf'/'nan': an infinite serial timeout is an unbounded read, the
+ # very thing these knobs exist to prevent, and nan fails every comparison silently
+ if not (v > 0 and v < float('inf')):
+ print(f'warning: {name}={v} is not usable; using {default}',
+ file=sys.stderr, flush=True)
+ return default
+ return v
+
+
+CMD_TIMEOUT = pos_int_env('HIL_CMD_TIMEOUT', 180)
+
+TINYUSB_ROOT = Path(__file__).resolve().parents[3] # test/hil/helper/ -> repo root
+
+
+def cmd_stdout_text(out: Any) -> str:
+ if out is None:
+ return ''
+ if isinstance(out, bytes):
+ return out.decode('utf-8', errors='ignore')
+ return str(out)
+
+
+def _banner_body(out: Any, err: Any) -> str:
+ # split_stderr callers keep the diagnostic in stderr — a banner of stdout alone
+ # would be blank exactly when something went wrong
+ body = cmd_stdout_text(out)
+ err_text = cmd_stdout_text(err)
+ if err_text:
+ body = f'{body}\n{err_text}' if body else err_text
+ return body
+
+
+# Shared with compact_output's stripper in hil_test: duplicated literals let the two
+# layers drift and reintroduce literal marker noise mid-row in the GitHub log.
+GROUP_MARK, ENDGROUP_MARK = '::group::', '::endgroup::'
+
+
+def strip_workflow_markers(line: str) -> str:
+ # run_cmd only ever emits markers at line start; mid-line is not a real case.
+ return line.removeprefix(GROUP_MARK).removeprefix(ENDGROUP_MARK)
+
+
+def _ci_log_groups() -> bool:
+ # GitHub folds ::group::/::endgroup:: only at line start of the JOB's real stdout; a
+ # pool worker's capture is compacted into one row line, where they render literally.
+ return bool(os.getenv('CI')) and sys.stdout is sys.__stdout__
+
+
+def _print_banner(title: str, out: Any, err: Any) -> None:
+ print()
+ if _ci_log_groups():
+ print(f'{GROUP_MARK}{title}')
+ print(_banner_body(out, err))
+ print(ENDGROUP_MARK)
+ else:
+ print(title)
+ print(_banner_body(out, err))
+
+
+SYSFS_READ_GRACE = 2.0 # bound on one attribute read of a possibly-wedged device
+SYSFS_STUCK_MAX = 4 # stranded readers tolerated before read_sysfs goes blind
+_sysfs_stuck = 0 # each costs a thread + an fd for the life of the process
+_sysfs_stuck_lock = threading.Lock()
+_sysfs_blind_logged = False
+
+
+class _SysfsUnknown:
+ """Sentinel: the read did not answer. NOT "the attribute is absent" -- reading it as
+ absence turns a healthy board into a firmware regression in the report."""
+ __slots__ = ()
+
+ def __bool__(self) -> bool:
+ return False
+
+ def __repr__(self) -> str:
+ return 'SYSFS_UNKNOWN'
+
+
+SYSFS_UNKNOWN = _SysfsUnknown()
+
+
+def sysfs_blind() -> bool:
+ """True once this process has stranded SYSFS_STUCK_MAX readers: every later read
+ answers SYSFS_UNKNOWN, so nothing it reports about a device is a fact any more."""
+ return _sysfs_stuck >= SYSFS_STUCK_MAX
+
+
+def sysfs_blind_note() -> str:
+ """Suffix for a failure message, so a blind worker's verdict never reads as hardware."""
+ return (f' (this worker is blind: {SYSFS_STUCK_MAX} sysfs reads stranded on a wedged '
+ f'device, so the check could not see the bus)') if sysfs_blind() else ''
+
+
+def read_sysfs(path: str, grace: float = SYSFS_READ_GRACE) -> str | None | _SysfsUnknown:
+ """Read a sysfs attribute with a WALL-CLOCK bound.
+
+ The value, None when the attribute is genuinely unreadable (OSError), or SYSFS_UNKNOWN
+ when the read did not answer -- it timed out, or this process is already blind. Callers
+ MUST keep those apart: absence is a fact, unknown is not.
+
+ usb_string_attr (serial/product/manufacturer) is served under the device lock a wedged
+ usbfs ioctl holds, so a plain open().read() blocks for as long as the wedge lasts, on
+ exactly the board an incident is about. The reader sleeps INTERRUPTIBLY (every read
+ takes usb_lock_device_interruptible, v6.12.96 sysfs.c:124-139 -- uninterruptible is the
+ ioctl holder, not us), so it dies with a SIGKILLed worker; what it costs meanwhile is a
+ thread and an fd for this process's life, because on sysfs the open() SUCCEEDS and only
+ the read blocks. Measured: 20 blocking reads leave 20 live threads.
+
+ Hence the cap: callers rescan (hil_lock's controller_of re-reads every unresolved
+ device on EVERY permit), and hitting RLIMIT_NOFILE or the thread ceiling raises inside
+ the worker and loses every board's result -- worse than the hang this prevents.
+ """
+ if sysfs_blind():
+ return SYSFS_UNKNOWN
+ # Known-stranded? Re-reading costs another permanent thread+fd and a blindness credit
+ # to learn what we already know. Lives HERE, not at the call sites: a call-site memo
+ # has to be remembered by every new scanner, and twice it was not.
+ was = _sysfs_stranded.get(path, _STRAND_MISS)
+ if was is not _STRAND_MISS:
+ if was is None:
+ return SYSFS_UNKNOWN # stranded, inode unknown: never re-read it
+ try:
+ if os.stat(path).st_ino == was:
+ return SYSFS_UNKNOWN # same node, still wedged
+ except OSError:
+ pass # gone: fall through, the read reports it
+ _sysfs_stranded.pop(path, None) # replaced or gone -> re-read it
+ out: dict = {}
+
+ def _read():
+ try:
+ with open(path) as f:
+ out['v'] = f.read().strip()
+ except (OSError, ValueError):
+ pass # no such attribute, or not text: unreadable, and that IS a fact
+
+ t = threading.Thread(target=_read, daemon=True)
+ t.start()
+ t.join(grace)
+ # `out` FIRST, not is_alive() alone: a reader can deposit its value and still be alive
+ # for a moment afterwards, and counting that as a strand memoises a healthy attribute as
+ # unreadable and spends one of four blindness credits. bounded_open has always checked
+ # its box for the same reason.
+ if t.is_alive() and 'v' not in out:
+ # Count the PATH once, not once per reader. hil_pool_check runs -j4 by default,
+ # which equals SYSFS_STUCK_MAX, so four threads hitting ONE wedged device used to
+ # spend the entire blindness budget between them -- latching blind on the single
+ # wedge the tool was run to find. The strand is real for each thread, but the
+ # DEVICE is what the cap is about.
+ # Under the SAME lock as the counter: check-then-act here is a race, and
+ # hil_pool_check runs a ThreadPoolExecutor of exactly SYSFS_STUCK_MAX workers in
+ # ONE process, so four threads on one wedged path could each see `first` before any
+ # of them recorded it -- spending the whole blindness budget on a single device,
+ # which is what this memo exists to prevent. note_sysfs_strand takes the lock
+ # itself, so call it after releasing.
+ with _sysfs_stuck_lock:
+ first = path not in _sysfs_stranded
+ if first:
+ try:
+ # stat, never the thread's own open(): stat does not call ->show(), so
+ # it cannot block on the device lock the reader is stuck behind
+ _sysfs_stranded[path] = os.stat(path).st_ino
+ except OSError:
+ _sysfs_stranded[path] = None # unstattable, but still known-stranded
+ if first:
+ note_sysfs_strand()
+ return SYSFS_UNKNOWN
+ return out.get('v')
+
+
+def note_sysfs_strand() -> None:
+ """Record ONE stranded sysfs reader. Shared by read_sysfs and bounded_open so both
+ account against a single counter -- the report caveat keys off it."""
+ global _sysfs_stuck, _sysfs_blind_logged
+ with _sysfs_stuck_lock:
+ _sysfs_stuck += 1
+ announce = sysfs_blind() and not _sysfs_blind_logged
+ _sysfs_blind_logged = _sysfs_blind_logged or announce
+ if announce:
+ # once per process, on stderr: a worker's stdout is compacted into one report
+ # row, where this would be lost among the test output
+ print(f'warning: {SYSFS_STUCK_MAX} sysfs reads stranded on a wedged device; '
+ f'this process is now blind and answers SYSFS_UNKNOWN for every '
+ f'attribute -- its verdicts about device presence are not evidence',
+ file=sys.stderr, flush=True)
+
+
+# path -> the inode it had when its read stranded. A stranded attribute stays
+# stranded until the DEVICE is replaced, and a re-enumeration destroys the kernfs
+# node and makes a new one -- so a changed inode is the all-clear. Keyed by path
+# alone it would outlive the wedge: a busport does not change when a board comes
+# back on the same port, so the HUNG reflash this branch performs would recover a
+# board the harness could then never see again.
+_sysfs_stranded: dict = {}
+# A stranded path whose inode could not be read is stored as None, so a plain .get() cannot
+# tell 'known stranded, inode unknown' from 'never seen' -- and treating the first as the
+# second re-reads it, stranding another permanent thread and fd every call. Distinct miss
+# sentinel, so None keeps its own meaning.
+_STRAND_MISS = object()
+
+
+def usb_scan(vid_pid=None, serial=None, vid=None) -> tuple[list, bool]:
+ """Enumerated USB devices matching the filters, and whether anything is unknown.
+
+ Returns ([{busport, dir, vid, pid, serial}], unknown). `unknown` True means a bounded
+ read did not answer, so absence is NOT proven -- the same contract as read_sysfs.
+
+ Three rules, one implementation for every caller:
+
+ * Root hubs excluded (glob `*-*`): no DUT is one, and scans including them measured
+ seconds slower (observation, no mechanism -- the "autosuspend wake" explanation was
+ wrong; usb_string_attr reads a cached string, sysfs.c:124-139).
+ * idVendor/idProduct first: lock-free `sysfs_emit` from udev->descriptor
+ (sysfs.c:688-705), so they rule out nearly every device for free.
+ * `serial` last and bounded: it is served under the lock a wedged ioctl holds, and a
+ path that already stranded is never re-read (each strand costs a thread and an fd
+ for this process's life).
+ """
+ out = []
+ unknown = False
+ for d in glob.glob('/sys/bus/usb/devices/*-*'):
+ # Interfaces are '<busport>:<cfg>.<ifnum>' (e.g. 2-4:1.0) -- they CONTAIN the
+ # colon, they do not end with it, so the original endswith() never fired and every
+ # scan opened idVendor/idProduct on all of them (measured: 31 of 44 matches).
+ if ':' in os.path.basename(d):
+ continue
+ try:
+ with open(os.path.join(d, 'idVendor')) as f:
+ dev_vid = f.read().strip()
+ with open(os.path.join(d, 'idProduct')) as f:
+ dev_pid = f.read().strip()
+ except OSError:
+ continue # vanished mid-walk, or not a device dir: a fact, not unknown
+ if vid_pid is not None and (dev_vid, dev_pid) != tuple(vid_pid):
+ continue # ruled out for free, without touching the locked attribute
+ if vid is not None and dev_vid != vid:
+ continue # same, for callers that know the VID but not the PID
+ sn = read_sysfs(os.path.join(d, 'serial'))
+ if sn is SYSFS_UNKNOWN:
+ unknown = True # read_sysfs memoises it; a repeat scan costs nothing
+ continue
+ if sn is None:
+ continue # no serial attribute: a fact
+ if serial is not None and sn.lower() != serial.lower():
+ continue
+ out.append({'busport': os.path.basename(d), 'dir': d,
+ 'vid': dev_vid, 'pid': dev_pid, 'serial': sn})
+ return out, unknown
+
+
+def bounded_open(path: str, flags: int, timeout: float = SYSFS_READ_GRACE):
+ """os.open() with a wall-clock bound.
+
+ The fd, None when the open genuinely FAILED (OSError: EBUSY, ENOENT, EACCES), or
+ SYSFS_UNKNOWN when it did not answer -- the same three-valued contract as read_sysfs,
+ and for the same reason: folding a fact into an unknown made an ordinary EBUSY read as
+ a wedged device and sent the operator hunting hardware that is healthy.
+
+ An open CAN block on a wedged device -- not on O_NONBLOCK, which usblp_open never
+ consults, but on usb_autopm_get_interface(), a runtime-PM resume that does I/O
+ (v6.12.96 drivers/usb/class/usblp.c). It holds usblp_mutex while it waits, and that
+ mutex is driver-GLOBAL, so one wedged printer blocks opens of every usblp node.
+
+ Unlike read_sysfs the stranded thread cleans up after itself: if we have given up it
+ closes the fd it eventually got, so only the thread leaks. Both sides take `handoff`
+ -- "store or close" and "abandon and drain" are a check-then-act pair that can
+ interleave into an fd stored after the box was drained, which would leak it into a
+ node that allows a SINGLE opener (usblp_open returns -EBUSY when usblp->used).
+ """
+ # Same short-circuit as read_sysfs: once blind, another stranded thread buys nothing
+ # and the cap exists precisely to stop them accumulating.
+ if sysfs_blind():
+ return SYSFS_UNKNOWN
+ # Known-stranded? Re-opening costs another thread, another fd and another blindness
+ # credit to learn what we already know -- and the printer test re-opens ONE lp node on
+ # every retry. Same memo and same inode check as read_sysfs.
+ was = _sysfs_stranded.get(path, _STRAND_MISS)
+ if was is not _STRAND_MISS:
+ if was is None:
+ return SYSFS_UNKNOWN # stranded, inode unknown: never re-read it
+ try:
+ if os.stat(path).st_ino == was:
+ return SYSFS_UNKNOWN
+ except OSError:
+ pass
+ _sysfs_stranded.pop(path, None)
+ box: dict = {}
+ done, abandoned = threading.Event(), threading.Event()
+ handoff = threading.Lock()
+
+ def _open():
+ try:
+ fd = os.open(path, flags)
+ except OSError:
+ done.set()
+ return
+ with handoff:
+ stored = not abandoned.is_set()
+ if stored:
+ box['fd'] = fd
+ if not stored:
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ done.set()
+
+ threading.Thread(target=_open, daemon=True).start()
+ if not done.wait(timeout):
+ with handoff:
+ abandoned.set()
+ fd = box.pop('fd', None) # completed in the gap between timeout and flag
+ if fd is not None:
+ # It DID open, just after our deadline -- the thread finished, so nothing is
+ # stranded. Report unknown (we already gave up on it) but do not spend a
+ # blindness credit, and do not call a merely-slow node wedged.
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ return SYSFS_UNKNOWN
+ # counted like a stranded read_sysfs: the thread and (eventually) its fd are gone
+ # for the life of the process, and the cap exists to stop that reaching the
+ # thread/fd ceiling -- an exception there escapes the worker and loses every board.
+ # Memoised by inode so a retry of the same node does not pay again.
+ # same lock as read_sysfs, same reason
+ with _sysfs_stuck_lock:
+ first = path not in _sysfs_stranded
+ if first:
+ try:
+ _sysfs_stranded[path] = os.stat(path).st_ino
+ except OSError:
+ _sysfs_stranded[path] = None
+ if first:
+ note_sysfs_strand()
+ return SYSFS_UNKNOWN
+ return box.get('fd')
+
+
+def _close_pipes(p: subprocess.Popen) -> None:
+ """Close OUR ends of an abandoned child's pipes. Never raises."""
+ for pipe in (p.stdout, p.stderr, p.stdin):
+ try:
+ if pipe is not None:
+ pipe.close()
+ except OSError:
+ pass
+
+
+def run_alongside(argv: list, work, timeout: int) -> subprocess.CompletedProcess:
+ """Run `argv` alongside `work()`, which runs in THIS thread, then reap it -- bounded.
+
+ The read-while-we-write shape run_cmd cannot express: the caller needs the child
+ RUNNING while it does something else. Everything else about the contract is run_cmd's
+ -- own session, killpg, bounded reap, our pipe ends closed, rc 124 on the kill.
+
+ A PROCESS, not a thread: an abandoned thread keeps the fd, and usblp_open returns
+ -EBUSY while usblp->used (v6.12.96 usblp.c), so every later open in this long-lived
+ worker would read as a wedged device. A killed process takes its fd with it.
+
+ stdout is captured as BYTES and kept CLEAN -- a caller byte-compares it against the
+ payload it sent, so a single stderr byte (a PYTHONWARNINGS chirp, a sitecustomize
+ print, a .pth deprecation from a venv) would read as USB data corruption. stderr gets
+ its own pipe; communicate() drains both, so the split cannot deadlock.
+ `work` runs even if the child dies immediately -- the caller's own asserts decide.
+ """
+ p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ start_new_session=True)
+
+ def _reap() -> subprocess.CompletedProcess:
+ try:
+ out, err = p.communicate(timeout=timeout)
+ return subprocess.CompletedProcess(argv, p.returncode, out, err)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(p.pid, signal.SIGKILL)
+ except OSError:
+ p.kill()
+ try:
+ out, err = p.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ # Outlasted SIGKILL: uninterruptible, still holding whatever it opened.
+ # Abandoned like any other stray -- but as a real child in its own
+ # session, so the containment sweep FINDS it (child_procs walks the ppid
+ # tree) and the report names it. That is the whole difference from a
+ # blocked thread, which no sweep can see and no signal can reach.
+ out, err = b'', b''
+ _close_pipes(p) # our own fds must not leak either
+ return subprocess.CompletedProcess(argv, 124, out, err)
+
+ try:
+ work()
+ except BaseException:
+ # Reap first so the child never outlives us, then let the caller's error through.
+ # A `return` inside a `finally` would SWALLOW it -- an assert in `work` would
+ # vanish and the caller would compare data it never finished sending.
+ _reap()
+ raise
+ return _reap()
+
+
+def run_cmd(cmd: str, cwd: str | None = None, timeout: int | None = None,
+ binary: bool = False, split_stderr: bool = False,
+ quiet: bool = False) -> subprocess.CompletedProcess:
+ if timeout is None:
+ timeout = CMD_TIMEOUT
+ # binary: raw bytes (text mode's errors='replace' mangles non-UTF-8 file content).
+ # split_stderr: keep stderr out of stdout, for callers that parse stdout. quiet: no
+ # COMMAND FAILED banner, for retry loops that report failures themselves (timeouts
+ # still print: a killed child is always noteworthy).
+ popen_kwargs = {
+ 'cwd': cwd,
+ 'shell': True,
+ 'stdout': subprocess.PIPE,
+ 'stderr': subprocess.PIPE if split_stderr else subprocess.STDOUT,
+ }
+ if not binary:
+ popen_kwargs.update({'text': True, 'encoding': 'utf-8', 'errors': 'replace'})
+ if os.name != 'nt':
+ # C-level setsid, same process-group semantics as preexec_fn=os.setsid but
+ # safe when called from threads (pool_check runs flashes from a thread pool)
+ popen_kwargs['start_new_session'] = True
+
+ p = subprocess.Popen(cmd, **popen_kwargs)
+ try:
+ out, err = p.communicate(timeout=timeout)
+ r = subprocess.CompletedProcess(args=cmd, returncode=p.returncode, stdout=out, stderr=err)
+ except subprocess.TimeoutExpired as ex:
+ if os.name != 'nt':
+ try:
+ os.killpg(p.pid, signal.SIGKILL)
+ except OSError:
+ # ProcessLookupError: already gone. PermissionError: an all-root group
+ # refuses the group kill -- letting either escape would skip the bounded
+ # reap, the pipe close and the rc-124 return this handler exists for.
+ pass
+ else:
+ p.kill()
+ try:
+ out, err = p.communicate(timeout=10)
+ except subprocess.TimeoutExpired:
+ # Something in the group outlived SIGKILL: D state (truly unkillable), or
+ # root-owned because sudo FORKS rather than execs, so the wrapper dies and its
+ # root child does not. Abandon it and let the report name it; the harness never
+ # sudo-kills its way out. Our ends of its pipes must not leak, though: a pool
+ # worker lives for the whole run, so every wedged command would cost it two fds.
+ out, err = None, None
+ _close_pipes(p)
+ # prefer the post-kill buffers (supersets of the exception's), falling back to ex.*
+ # when the child was unkillable. TimeoutExpired carries BYTES even for a text-mode
+ # Popen, so the fallbacks must be decoded or a text-mode caller gets bytes exactly
+ # when the child wedged in D state.
+ def _typed(v):
+ if not binary and isinstance(v, bytes):
+ return v.decode('utf-8', errors='replace')
+ return v
+
+ timeout_out = _typed(out or ex.stdout) or (b'' if binary else '')
+ # ...and never None: with split_stderr the SUCCESS path always yields a str/bytes,
+ # so a caller that does `r.stderr.strip()` works everywhere except the timeout --
+ # the one path it was written for. Without split_stderr stderr stays None, as on
+ # the success path (it was merged into stdout).
+ timeout_err = _typed(err if err is not None else ex.stderr)
+ if split_stderr and timeout_err is None:
+ timeout_err = b'' if binary else ''
+ _print_banner(f'COMMAND TIMEOUT ({timeout}s): {cmd}', timeout_out, timeout_err)
+ return subprocess.CompletedProcess(args=cmd, returncode=124, stdout=timeout_out, stderr=timeout_err)
+ except BaseException:
+ # BaseException, not Exception (as in CPython's own subprocess.run):
+ # KeyboardInterrupt is the case that matters, and start_new_session put the child in
+ # its OWN group, so it never got the terminal's SIGINT -- without this, Ctrl-C
+ # leaves the flasher or testusb holding the probe and its usbfs node. Kill and
+ # close, never wait: this path must not add a hang of its own.
+ if os.name != 'nt':
+ try:
+ os.killpg(p.pid, signal.SIGKILL)
+ except OSError:
+ pass
+ else:
+ p.kill()
+ _close_pipes(p)
+ raise
+
+ if r.returncode != 0 and not quiet:
+ _print_banner(f'COMMAND FAILED: {cmd}', r.stdout, r.stderr)
+ elif verbose:
+ print(cmd)
+ print(cmd_stdout_text(r.stdout))
+ return r
+
+
+# get usb serial by id
+def get_serial_dev(id, vendor_str, product_str, ifnum):
+ if vendor_str and product_str:
+ # known vendor and product
+ vendor_str = vendor_str.replace(' ', '_')
+ product_str = product_str.replace(' ', '_')
+ return f'/dev/serial/by-id/usb-{vendor_str}_{product_str}_{id}-if{ifnum:02d}'
+ else:
+ # just use id: mostly for cp210x/ftdi flasher
+ pattern = f'/dev/serial/by-id/usb-*_{id}-if*'
+ port_list = glob.glob(pattern)
+ if len(port_list) == 0:
+ raise RuntimeError(f'No serial device found for {pattern}')
+ return port_list[0]
diff --git a/test/hil/hfp.json b/test/hil/hfp.json
index 735d5a402..2babcaaf3 100644
--- a/test/hil/hfp.json
+++ b/test/hil/hfp.json
@@ -7,9 +7,8 @@
"device": true, "host": false, "dual": false
},
"flasher": {
- "name": "jlink",
- "uid": "774470029",
- "args": "-device STM32L412KB"
+ "name": "stlink",
+ "uid": "0673FF575051717867034946"
}
},
{
diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh
index ef93bcb49..c7dfa95df 100644
--- a/test/hil/hil_ci.sh
+++ b/test/hil/hil_ci.sh
@@ -20,6 +20,30 @@ CONFIG=${CONFIG:-$ROOT_DIR/test/hil/tinyusb.json}
exit 1
}
+# REMOTE_DIR reaches the rig as `rm -rf` input, an scp remote path and an rsync remote
+# path -- the remote shell re-splits and expands all three, so no amount of LOCAL quoting
+# protects them (and %q would escape the ~ that REMOTE_DIR=~/dir needs). Screen it once.
+# The tilde is the whole hazard: the REMOTE shell expands it, so `~/` alone -- one typo
+# away from the documented ~/dir override -- means `rm -rf` on that account's HOME. Hence
+# `/` or `~/` followed by at least one named component, ending in a name character.
+[[ $REMOTE_DIR =~ ^(/|~/)[A-Za-z0-9_.~/-]*[A-Za-z0-9_-]$ && $REMOTE_DIR != *..*
+ && $REMOTE_DIR != *//* ]] || {
+ echo "error: REMOTE_DIR must be /path or ~/path of [A-Za-z0-9_.~/-], no '..', no" \
+ "trailing slash -- it is an rm -rf target on $REMOTE: $REMOTE_DIR" >&2
+ exit 1
+}
+
+# --build would run tools/build.py ON THE RIG, and this script stages binaries, not the
+# build tree -- it is not copied, so the run dies there with a confusing missing-file
+# error. Building is the local half of this workflow by design.
+for a in "$@"; do
+ [ "$a" = "--build" ] || continue
+ echo "error: --build builds on the REMOTE, but this script copies prebuilt binaries" >&2
+ echo " (tools/build.py is not staged). Build locally first, then re-run:" >&2
+ echo " cd examples && cmake --preset <board> && cmake --build --preset <board>" >&2
+ exit 1
+done
+
# Parse -b BOARD from arguments to know which build to copy
BOARD=""
ARGS=()
@@ -38,29 +62,35 @@ while [[ $# -gt 0 ]]; do
esac
done
-# Setup remote directory. Use `bash -s` + heredoc so REMOTE_DIR (user-overridable)
-# is passed as a positional parameter and never reinterpreted by the remote shell.
+# Setup remote directory. `bash -s` + heredoc so REMOTE_DIR arrives as a positional
+# parameter, keeping the `rm -rf` target out of the command string the heredoc runs.
echo "==> Setting up remote $REMOTE:$REMOTE_DIR"
ssh "$REMOTE" bash -s -- "$REMOTE_DIR" <<'REMOTE'
set -e
+# Second gate, on the side that knows what ~ expanded to: only here is $HOME a value
+# rather than a guess, and this is the line that actually runs rm -rf.
+case "$1" in
+ ''|/|"$HOME"|"$HOME"/) echo "refusing to rm -rf '$1'" >&2; exit 1 ;;
+esac
rm -rf -- "$1"
-# .claude path: usbtest.py's HUNG recovery resolves usb_recover.sh relative to the
-# staged repo root — without it, recovery ENOENTs and the wedge is left in place
-mkdir -p -- "$1/test/hil" "$1/examples" "$1/.claude/skills/usb-kernel-recover/scripts"
+mkdir -p -- "$1/test/hil/helper" "$1/examples"
REMOTE
# Copy HIL test script and config
echo "==> Copying test scripts"
scp -q "$ROOT_DIR/test/hil/hil_test.py" \
"$ROOT_DIR/test/hil/hil_flash.py" \
- "$ROOT_DIR/test/hil/hil_lock.py" \
"$ROOT_DIR/test/hil/usbtest.py" \
- "$ROOT_DIR/test/hil/hil_examples.py" \
"$ROOT_DIR/test/hil/pymtp.py" \
+ "$ROOT_DIR/test/hil/mtp_test.py" \
"$CONFIG" \
"$REMOTE:$REMOTE_DIR/test/hil/"
-scp -q "$ROOT_DIR/.claude/skills/usb-kernel-recover/scripts/usb_recover.sh" \
- "$REMOTE:$REMOTE_DIR/.claude/skills/usb-kernel-recover/scripts/"
+scp -q "$ROOT_DIR/test/hil/helper/__init__.py" \
+ "$ROOT_DIR/test/hil/helper/hil_util.py" \
+ "$ROOT_DIR/test/hil/helper/hil_health.py" \
+ "$ROOT_DIR/test/hil/helper/hil_lock.py" \
+ "$ROOT_DIR/test/hil/helper/hil_select.py" \
+ "$REMOTE:$REMOTE_DIR/test/hil/helper/"
# Copy only firmware binaries (elf/bin/hex) plus esptool metadata
# (config.env + flash_args needed by the esptool flasher), preserving structure
@@ -90,16 +120,25 @@ if [ -n "$BOARD" ]; then
add_build_dir "$d"
done
shopt -u nullglob
- while IFS= read -r v; do
- add_build_dir "$ROOT_DIR/examples/cmake-build-$v"
- done < <(python3 -c '
+ # to a file, not a process substitution: `set -e`/pipefail cannot see the exit
+ # status of the latter, so a malformed roster silently yielded zero variant dirs
+ VARIANTS_FILE=$(mktemp)
+ python3 -c '
import json, sys
cfg = json.load(open(sys.argv[1]))
for b in cfg.get("boards", []):
if b["name"] == sys.argv[2]:
for v in b.get("variant") or []:
print(v["name"])
-' "$CONFIG" "$BOARD")
+' "$CONFIG" "$BOARD" > "$VARIANTS_FILE" || {
+ echo "Error: could not read variants for $BOARD from $CONFIG"
+ rm -f "$VARIANTS_FILE"
+ exit 1
+ }
+ while IFS= read -r v; do
+ add_build_dir "$ROOT_DIR/examples/cmake-build-$v"
+ done < "$VARIANTS_FILE"
+ rm -f "$VARIANTS_FILE"
if [ ${#BUILD_DIRS[@]} -eq 0 ]; then
echo "Error: no build directory found for $BOARD under $ROOT_DIR/examples/"
echo "Build first with: cd examples && cmake --preset $BOARD && cmake --build --preset $BOARD"
@@ -119,12 +158,24 @@ else
done
fi
-# Run test. Use `bash -s` so REMOTE_DIR + ARGS reach the remote shell as positional
-# parameters; quoting and metacharacters in args are preserved.
-CONFIG_BASENAME="$(basename "$CONFIG")"
+# Run test via `bash -s`, so REMOTE_DIR and the args arrive as positional parameters.
+# %q the ARGS -- ssh joins its argv into ONE string that the remote shell re-splits, so
+# `-t 'host/cdc msc'` would arrive as two arguments and hil_test.py would see a stray
+# word where it expects the config path. REMOTE_DIR is deliberately NOT quoted here: it
+# is screened above precisely so it can keep its ~ expansion.
+ARGS_Q=()
+for a in ${ARGS[@]+"${ARGS[@]}"}; do ARGS_Q+=("$(printf '%q' "$a")"); done
+# same re-split, same fix: CONFIG is a user-supplied path and its basename lands in the
+# command string too
+CONFIG_Q="$(printf '%q' "test/hil/$(basename "$CONFIG")")"
echo "==> Running HIL test on $REMOTE"
rc=0
-ssh "$REMOTE" bash -s -- "$REMOTE_DIR" "${ARGS[@]}" "test/hil/$CONFIG_BASENAME" <<'REMOTE' || rc=$?
+# --retry 1 FIRST, before the user's args: this targets the same shared rig CI uses, and
+# the pool guard is a flat constant that does not scale with max_retry -- argparse's
+# default of 3 lets a few flaky boards re-pay 510s each until the 3600s guard fires,
+# abandoning the pool and holding board flocks against concurrent CI. Placed first, not
+# appended, so argparse's last-wins means `hil_ci.sh -r 3` still gets 3.
+ssh "$REMOTE" bash -s -- "$REMOTE_DIR" --retry 1 ${ARGS_Q[@]+"${ARGS_Q[@]}"} "$CONFIG_Q" <<'REMOTE' || rc=$?
cd -- "$1"
shift
# Flasher CLIs live in the user bin dirs on ci.lan (esptool/idf in ~/.local/bin,
diff --git a/test/hil/hil_examples.py b/test/hil/hil_examples.py
deleted file mode 100644
index 4c8b6918b..000000000
--- a/test/hil/hil_examples.py
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MIT
-# HIL example test lists, shared by hil_test.py (runner) and hil_select.py
-# (PR-diff selector). Stdlib-only: hil_select runs on bare CI runners.
-
-# The per-board run order is shuffled (see test_board).
-# Every example carries a unique hardcoded idProduct (see its usb_descriptors.c)
-
-# device tests
-device_tests = [
- 'device/cdc_dual_ports',
- 'device/cdc_msc',
- 'device/dfu',
- 'device/cdc_msc_throughput',
- 'device/audio_test_freertos',
- 'device/dfu_runtime',
- 'device/cdc_msc_freertos',
- 'device/hid_boot_interface',
- 'device/msc_dual_lun',
- 'device/hid_generic_inout',
- 'device/printer_to_cdc',
- 'device/midi_test',
- 'device/mtp',
- 'device/usbtest', # cafe:4010, unique PID; runs the Linux testusb tier-4 battery via usbtest.py
- # 'device/net_lwip_webserver', # disabled for PR #3605: USB net iface enum is flaky on the CI HIL host
-]
-
-dual_tests = [
- 'dual/host_info_to_device_cdc',
-]
-
-host_test = [
- 'host/cdc_msc_hid',
- 'host/msc_file_explorer',
- 'host/msc_file_explorer_freertos',
- 'host/device_info',
-]
diff --git a/test/hil/hil_flash.py b/test/hil/hil_flash.py
index da81fcc97..f4bed45a6 100755
--- a/test/hil/hil_flash.py
+++ b/test/hil/hil_flash.py
@@ -1,138 +1,48 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
-# Firmware flashing for the TinyUSB HIL rig: run_cmd, one flash_*/reset_* pair per
-# flasher type (dispatched by config name via getattr), find_firmware, and the
-# fixture serial-port resolver get_serial_dev (here, not hil_test: flash_esptool
-# needs it and helpers must not import hil_test).
-# Callers set module globals `build_dir` and `verbose` (hil_test.main from argparse,
-# pool_check directly) exactly as they set hil_test's globals today.
-#
-# from __future__ import annotations (below): some moved function signatures use
-# type hints (Any, Board) not defined in this module; postponed evaluation (PEP
-# 563) keeps those as unevaluated strings so the verbatim-moved defs still load.
+# Firmware flashing for the TinyUSB HIL rig: one flash_*/reset_* pair per flasher type
+# (dispatched by config name via getattr) plus find_firmware. The bounded runner run_cmd
+# lives in hil_util (never import hil_test here). Callers set the module global
+# `build_dir`. `from __future__ import annotations` keeps the Board hints below
+# unevaluated: the type is not defined in this module.
from __future__ import annotations
-import glob
import json
-import os
-import signal
+import re
import subprocess
from pathlib import Path
-verbose = False
-build_dir = 'cmake-build'
+import os
+import sys
-CMD_TIMEOUT = int(os.getenv('HIL_CMD_TIMEOUT', '180'))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it
+from helper import hil_util
+
+build_dir = 'cmake-build'
# flasher names (dispatch key, board['flasher']['name'].lower()) whose reset_* is a no-op
RESET_NOOP = {'esptool', 'lm4flash'}
# extra parents find_firmware ALSO searches after build_dir. Empty by default so
-# hil_test's -B stays authoritative (a board missing there must report "Skip (no
-# binary)", never silently flash a stale binary from another tree); pool_check
-# opts in to cover both standard layouts.
+# hil_test's -B stays authoritative: a board missing there must report "Skip (no
+# binary)", never silently flash a stale binary from another tree.
EXTRA_BUILD_DIRS: list = []
-
-def cmd_stdout_text(out: Any) -> str:
- if out is None:
- return ''
- if isinstance(out, bytes):
- return out.decode('utf-8', errors='ignore')
- return str(out)
-
-
-# -------------------------------------------------------------
-# Path
-# -------------------------------------------------------------
-TINYUSB_ROOT = Path(__file__).resolve().parents[2]
-
-# get usb serial by id
-def get_serial_dev(id, vendor_str, product_str, ifnum):
- if vendor_str and product_str:
- # known vendor and product
- vendor_str = vendor_str.replace(' ', '_')
- product_str = product_str.replace(' ', '_')
- return f'/dev/serial/by-id/usb-{vendor_str}_{product_str}_{id}-if{ifnum:02d}'
- else:
- # just use id: mostly for cp210x/ftdi flasher
- pattern = f'/dev/serial/by-id/usb-*_{id}-if*'
- port_list = glob.glob(pattern)
- if len(port_list) == 0:
- raise RuntimeError(f'No serial device found for {pattern}')
- return port_list[0]
+_VID_PID_WARNED: set = set() # one warning per probe, not per command
# -------------------------------------------------------------
# Flashing firmware
# -------------------------------------------------------------
-def run_cmd(cmd: str, cwd: str | None = None, timeout: int = CMD_TIMEOUT) -> subprocess.CompletedProcess:
- popen_kwargs = {
- 'cwd': cwd,
- 'shell': True,
- 'stdout': subprocess.PIPE,
- 'stderr': subprocess.STDOUT,
- 'text': True,
- 'encoding': 'utf-8',
- 'errors': 'replace',
- }
- if os.name != 'nt':
- # C-level setsid, same process-group semantics as preexec_fn=os.setsid but
- # safe when called from threads (pool_check runs flashes from a thread pool)
- popen_kwargs['start_new_session'] = True
-
- p = subprocess.Popen(cmd, **popen_kwargs)
- try:
- out, _ = p.communicate(timeout=timeout)
- r = subprocess.CompletedProcess(args=cmd, returncode=p.returncode, stdout=out)
- except subprocess.TimeoutExpired as ex:
- if os.name != 'nt':
- try:
- os.killpg(p.pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- else:
- p.kill()
- try:
- out, _ = p.communicate(timeout=10)
- except subprocess.TimeoutExpired: # unkillable (e.g. D-state on wedged USB)
- out = None
- timeout_out = ex.stdout or out or b''
- title = f'COMMAND TIMEOUT ({timeout}s): {cmd}'
- print()
- if os.getenv('CI'):
- print(f"::group::{title}")
- print(cmd_stdout_text(timeout_out))
- print(f"::endgroup::")
- else:
- print(title)
- print(cmd_stdout_text(timeout_out))
- return subprocess.CompletedProcess(args=cmd, returncode=124, stdout=timeout_out)
-
- if r.returncode != 0:
- title = f'COMMAND FAILED: {cmd}'
- print()
- if os.getenv('CI'):
- print(f"::group::{title}")
- print(cmd_stdout_text(r.stdout))
- print(f"::endgroup::")
- else:
- print(title)
- print(cmd_stdout_text(r.stdout))
- elif verbose:
- print(cmd)
- print(cmd_stdout_text(r.stdout))
- return r
-
-
-def flash_jlink(board: Board, firmware: str) -> subprocess.CompletedProcess:
+def flash_jlink(board: Board, firmware: str, timeout=None) -> subprocess.CompletedProcess:
flasher = board['flasher']
script = ['halt', 'r', f'loadfile {firmware}', 'r', 'go', 'exit']
f_jlink = Path(f'{board["name"]}_{Path(firmware).name}.jlink')
with f_jlink.open('w') as f:
f.writelines(f'{s}\n' for s in script)
- ret = run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}')
+ ret = hil_util.run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}',
+ timeout=timeout)
f_jlink.unlink(missing_ok=True)
return ret
@@ -144,89 +54,208 @@ def reset_jlink(board: Board) -> subprocess.CompletedProcess:
if not f_jlink.exists():
with f_jlink.open('w') as f:
f.writelines(f'{s}\n' for s in script)
- ret = run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}')
+ ret = hil_util.run_cmd(f'JLinkExe -USB {flasher["uid"]} {flasher["args"]} -if swd -JTAGConf -1,-1 -speed auto -NoGui 1 -ExitOnError 1 -CommandFile {f_jlink}')
return ret
-def flash_stlink(board, firmware):
+def flash_stlink(board, firmware, timeout=None):
+ # --verify catches the partial/corrupt write that exits 0 and sends the test phase
+ # off to exercise bad firmware. Opt-IN here ("verify": true), unlike flash_openocd's
+ # opt-out: a default-on read-back silently changes every roster entry that lacks the
+ # key, including boards on rigs this was never validated against.
flasher = board['flasher']
- return run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --write {firmware} --go')
+ verify = ' --verify' if flasher.get('verify', False) else ''
+ return hil_util.run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --write {firmware}{verify} --go',
+ timeout=timeout)
def reset_stlink(board):
flasher = board['flasher']
- return run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --rst --go')
+ return hil_util.run_cmd(f'STM32_Programmer_CLI --connect port=swd sn={flasher["uid"]} --rst --go')
def _openocd_cmd_base(flasher):
+ # Optional roster field vid_pid, openocd-verbatim (e.g. "0x1a86 0x8010"), pins probe
+ # discovery to the probe's IDs so openocd never opens foreign usbfs nodes to read
+ # strings -- a wedged node makes that open hang unkillably (the 2026-08-10 convoy).
+ # BEFORE args, because the rescue cfgs run `init` internally and reject (or never see)
+ # a config command that follows it.
+ vid_pid = ''
+ if 'vid_pid' in flasher:
+ # Validated HERE too, not just in convoy_safe: openocd only warns ("incomplete
+ # vid_pid configuration directive") and exits 0 on a malformed value, so the pin
+ # silently does not apply and discovery goes back to opening every usbfs node --
+ # the convoy this field exists to stop. The same key name carries a DIFFERENT
+ # syntax under tests.dev_attached ('1a86_55d4'), so the typo is one copy away.
+ if valid_vid_pid(flasher['vid_pid']):
+ vid_pid = f'-c "adapter usb vid_pid {flasher["vid_pid"]}" '
+ else:
+ # stderr + once-per-probe, like the missing-pin branch below: stdout here is
+ # captured by test_example's redirect_stdout (shown only when the test FAILS)
+ # and by hil_pool_check's StringIO spool, so on a PASSING run the operator
+ # would never learn the pin was silently dropped.
+ uid = flasher.get('uid', '?')
+ if uid not in _VID_PID_WARNED:
+ _VID_PID_WARNED.add(uid)
+ print(f'warning: {uid} has a malformed vid_pid {flasher["vid_pid"]!r} '
+ f'(want "0xVVVV 0xPPPP"); probe pin DROPPED, so discovery will open '
+ f'foreign usbfs nodes', file=sys.stderr, flush=True)
+ elif flasher.get('uid') not in _VID_PID_WARNED:
+ # stderr, once per probe: test_example captures stdout, so a passing run would
+ # swallow this and the operator would never learn discovery still opens every
+ # usbfs node
+ _VID_PID_WARNED.add(flasher.get('uid'))
+ print(f'warning: openocd flasher {flasher.get("uid", "?")} has no vid_pid pin; '
+ f'probe discovery will open every usbfs node (hangs on a wedged one)',
+ file=sys.stderr, flush=True)
return (f'openocd -c "tcl_port disabled" -c "gdb_port disabled" -c "telnet_port disabled" '
- f'-c "adapter serial {flasher["uid"]}" {flasher["args"]}')
+ f'-c "adapter serial {flasher["uid"]}" {vid_pid}{flasher["args"]}')
-# `verify` is on by default and opted out per board with "verify": false in the roster.
-# WCH targets must opt out: flash read-back over the WCH-Link sdi transport returns a
-# repeated word instead of memory contents, so verification always reports a mismatch and
-# fails the flash (measured on ch32v103r and ch32v307v, 2026-07-30). Do NOT drop verify
-# fleet-wide to accommodate them — every other openocd board can read back, and without it
-# a partial or corrupt write exits 0 and the test phase runs bad firmware.
-def flash_openocd(board, firmware):
+# `verify` is on by default, opted out per board with "verify": false. WCH targets must
+# opt out: read-back over the WCH-Link sdi transport returns a repeated word instead of
+# memory contents, so verification always mismatches (measured on ch32v103r and ch32v307v,
+# 2026-07-30). Do NOT drop verify fleet-wide for them — every other openocd board reads
+# back, and without it a partial or corrupt write exits 0 and the tests run bad firmware.
+def flash_openocd(board, firmware, timeout=None):
flasher = board['flasher']
verify = ' verify' if flasher.get('verify', True) else ''
- ret = run_cmd(f'{_openocd_cmd_base(flasher)} -c "program {firmware}{verify} reset exit"')
+ ret = hil_util.run_cmd(f'{_openocd_cmd_base(flasher)} -c "program {firmware}{verify} reset exit"',
+ timeout=timeout)
return ret
-def reset_openocd(board):
+def reset_openocd(board, timeout=None):
+ # timeout: usbtest's post-hang recovery bounds this (RECOVER_RESET_TIMEOUT); an
+ # unbounded reset there would outlive the caller's outer kill and orphan openocd on
+ # the probe, which is the stray the recovery exists to avoid.
flasher = board['flasher']
- ret = run_cmd(f'{_openocd_cmd_base(flasher)} -c "init; reset run; exit"')
+ ret = hil_util.run_cmd(f'{_openocd_cmd_base(flasher)} -c "init; reset run; exit"',
+ timeout=timeout)
return ret
# OpenOCD's messages for "the target's debug port did not answer". The probe is fine when
-# these appear (the log still shows "CMSIS-DAP: Interface ready"); the chip's debug clock
-# is gone, which no reset the probe can drive would fix -- the CMSIS-DAP Debug Probe has no
-# nRESET line at all. Which message you get depends on the DAP topology, NOT on the board:
-# rp2040.cfg creates three multidrop DAPs (cores 0/1 and the Rescue DP at instance 0xf) so
-# it fails in swd_multidrop_select, while rp2350.cfg creates a single plain ADIv6 DAP that
-# fails earlier in swd_connect. A dead RP2040 can also produce the second one if the very
-# first DP read never gets through, so both are accepted for both chips -- it is the target
-# cfg in the roster args, below, that picks how to rescue.
+# these appear ("CMSIS-DAP: Interface ready" is still logged); the chip's debug clock is
+# gone, which no probe-driven reset fixes -- the CMSIS-DAP probe has no nRESET line. Which
+# message appears depends on DAP topology, not the board, so both are accepted for both
+# chips; RESCUE_CFG below picks the rescue.
DAP_WEDGED = ('Failed to connect multidrop', 'Error connecting DP: cannot read IDR')
-# How each RP target reaches its Rescue DP, keyed by the target cfg named in flasher args.
-# (cfg substitution, extra args): rp2040.cfg drives the Rescue DP itself behind a RESCUE
-# flag and calls init/shutdown on its own; rp2350 has a separate cfg that pokes the rescue
-# bit via an AP register but never shuts down, so it would sit in the server loop until
-# CMD_TIMEOUT without an explicit one.
+# How each RP target reaches its Rescue DP, keyed by the target cfg named in flasher args:
+# (cfg substitution, pre args, post args). rp2040.cfg drives the Rescue DP behind a RESCUE
+# flag and init/shutdowns itself; rp2350-rescue.cfg never shuts down, so it needs an
+# explicit one or it sits in the server loop until CMD_TIMEOUT.
RESCUE_CFG = {
'target/rp2040.cfg': ('target/rp2040.cfg', '-c "set RESCUE 1" ', ''),
'target/rp2350.cfg': ('target/rp2350-rescue.cfg', '', ' -c "shutdown"'),
}
-def rescue_openocd(board, flash_out: str = '') -> bool:
+def rescue_openocd(board, flash_out: str = '', timeout=None) -> bool:
"""Power-on-reset a wedged RP2040/RP2350 through its Rescue DP, the one debug port not
- gated by the system clock (RP2040 datasheet 2.3.4.2): setting CDBGPWRUPREQ hard-resets
- the chip, and the bootrom halts it in a safe state ready to be flashed. This is the
- only way back for a target whose cores have stopped answering -- otherwise the board
- needs a physical replug, since the probe carries no reset line.
+ gated by the system clock (RP2040 datasheet 2.3.4.2): CDBGPWRUPREQ hard-resets the
+ chip and the bootrom halts it ready to be flashed. Without it the board needs a
+ physical replug -- the probe carries no reset line.
- No-op (returns False) unless this is an openocd RP board AND the flash output shows the
- wedge, so a flash that failed for any other reason still just retries. Returns True
- when a rescue was attempted; the caller should retry the flash afterwards."""
+ No-op (False) unless this is an openocd RP board AND the flash output shows the wedge,
+ so a flash that failed for any other reason still just retries. True when a rescue was
+ attempted; the caller should retry the flash afterwards."""
flasher = board['flasher']
if flasher['name'].lower() != 'openocd' or not any(m in flash_out for m in DAP_WEDGED):
return False
for cfg, (rescue_cfg, pre, post) in RESCUE_CFG.items():
if cfg in flasher['args']:
args = flasher['args'].replace(cfg, rescue_cfg)
- return run_cmd(f'{_openocd_cmd_base({**flasher, "args": pre + args})}{post}').returncode == 0
+ return hil_util.run_cmd(f'{_openocd_cmd_base({**flasher, "args": pre + args})}{post}',
+ timeout=timeout).returncode == 0
return False
-def flash_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess:
+# openocd's own syntax: one or more "0xVVVV 0xPPPP" pairs. Validated rather than merely
+# tested for truthiness -- `vid_pid` is a hand-edited roster field whose NAME is also used,
+# with a different syntax, by tests.dev_attached, and convoy_safe reads a non-empty value
+# as PROOF the flasher can deliver a recovery past a poisoned node. A typo there silently
+# promised a recovery that openocd would reject at startup.
+_VID_PID_RE = re.compile(r'^0x[0-9a-fA-F]{4}(\s+0x[0-9a-fA-F]{4})+$')
+
+
+def valid_vid_pid(value) -> bool:
+ return isinstance(value, str) and bool(_VID_PID_RE.match(value.strip()))
+
+
+def recover_flasher(board: dict) -> dict:
+ """The flasher that delivers RECOVERY for this board.
+
+ Optional roster key `flasher_recover`, else the primary. It exists because delivery and
+ normal flashing have different requirements: a board flashed by jlink/stlink/lm4flash
+ cannot reach its probe past a poisoned usbfs node, but the same probe driven by openocd
+ often can (see convoy_safe). Keeping it a separate key rather than a list means the
+ primary's shape never changes, so nothing that reads board['flasher'] has to care.
+ """
+ return board.get('flasher_recover') or board['flasher']
+
+
+def convoy_safe(flasher: dict) -> bool:
+ """Can this flasher DELIVER a recovery while a usbfs node on the rig is poisoned?
+
+ A post-HUNG reflash only helps if the flasher reaches its probe without opening the
+ wedged node. Two shapes qualify:
+
+ * openocd pinned with the roster's `vid_pid` -- the match is made from the cached
+ descriptor and the loop `continue`s BEFORE libusb_open, so a foreign node is never
+ opened. On 2026-08-12 it was the only flasher that still reached its probe.
+ * esptool -- delivery is `-p <ttyACM>`, a named port; it never enumerates usbfs.
+
+ Everything else enumerates by OPENING nodes, would block in D state on the poisoned
+ one, survive SIGKILL and become a second stray. JLinkExe cannot be pinned: selection
+ is serial-only (-USB/-SelectEmuBySN) and reading a serial requires the open (J-Link
+ Commander V9.66 exposes no VID/PID filter), so those boards can only become
+ convoy-safe by moving to openocd.
+
+ Verified against openocd 0ce743125 (the rig's build), because the INVERSE is what
+ bites: cmsis_dap_usb_bulk.c:107 skips on `id_filter && !id_match`, and `id_filter` is
+ only `vids[0] || pids[0]` -- so without the pin nothing is skipped and every device on
+ the bus is opened, which the code itself expects to mostly fail. Enumeration cannot
+ block: libusb reads the `descriptors` sysfs attribute, and descriptors_read (v6.12.101
+ drivers/usb/core/sysfs.c) is a memcpy from udev->rawdescriptors under no lock.
+
+ The pin gates the BULK backend, which is the one that runs: `auto` tries usb_bulk ->
+ hid -> tcp (cmsis_dap.c:62) and stops at the first that opens, so a CMSIS-DAP v2 probe
+ never reaches the rest. It does NOT cover the HID fallback that a v1 probe or a failed
+ bulk open takes -- cmsis_dap_usb_hid.c:91 calls hid_enumerate(0x0, 0x0), pin ignored,
+ and filters afterwards, while hidapi's hidraw backend reads `manufacturer` and
+ `product` for every HID device it lists (linux/hid.c:744), both usb_string_attr and so
+ served under the device lock. A wedged DUT running hid_generic_inout,
+ hid_boot_interface or hid_composite_freertos is a HID device and would stall that walk
+ -- interruptibly, so it hangs rather than joining the D-state convoy and run_cmd's
+ timeout ends it, but "never opens a foreign node" is true of the bulk path, not of
+ every path openocd can take.
+ """
+ name = (flasher.get('name') or '').lower()
+ if name == 'esptool':
+ return True
+ # EXACT, not startswith: rescue_openocd and usbtest's
+ # getattr(hil_flash, f'flash_{name}') both require the exact name, so an
+ # 'openocd_wch'-style entry would pass this gate, reserve USBTEST_RECOVERY_BUDGET,
+ # and then find no recovery path at all -- paying for a path that cannot fire, which
+ # is the precise cost this gate exists to avoid.
+ if name != 'openocd':
+ return False
+ if valid_vid_pid(flasher.get('vid_pid')):
+ return True
+ # openocd over the JLINK driver is safe WITHOUT a pin, and cannot use one: jlink.c
+ # never reads adapter_usb_get_vids/pids (selection is adapter serial / usb address /
+ # usb location), but libjaylink's discovery returns early unless idVendor == 0x1366 and
+ # the PID is in its table, and only THEN calls libusb_open (discovery_usb.c). So it
+ # never opens a foreign node -- which is exactly what JLinkExe, SEGGER's own tool,
+ # does do. Verified against openocd 0ce743125 and libjaylink master.
+ return 'interface/jlink.cfg' in (flasher.get('args') or '')
+
+
+def flash_esptool(board: Board, firmware: str, timeout=None) -> subprocess.CompletedProcess:
flasher = board['flasher']
- port = get_serial_dev(flasher["uid"], None, None, 0)
+ port = hil_util.get_serial_dev(flasher["uid"], None, None, 0)
fw_dir = Path(firmware).parent
with (fw_dir / 'config.env').open() as f:
idf_target = json.load(f)['IDF_TARGET']
@@ -234,32 +263,39 @@ def flash_esptool(board: Board, firmware: str) -> subprocess.CompletedProcess:
flash_args = f.read().strip().replace('\n', ' ')
command = (f'esptool --chip {idf_target} -p {port} {flasher["args"]} '
f'--before=default_reset --after=hard_reset write_flash {flash_args}')
- ret = run_cmd(command, cwd=str(fw_dir))
+ ret = hil_util.run_cmd(command, cwd=str(fw_dir), timeout=timeout)
return ret
def reset_esptool(board):
- flasher = board['flasher']
+ # NO-OP, and marked as one: esptool's reset would be `--after hard_reset`, which is not
+ # wired here. Returning rc 0 without resetting is why callers must never read the exit
+ # code as proof -- recovery_steps skips a primitive carrying `no_op`.
return subprocess.CompletedProcess(args=['dummy'], returncode=0)
-def flash_lm4flash(board, firmware):
+reset_esptool.no_op = True
+
+
+def flash_lm4flash(board, firmware, timeout=None):
# TI Tiva-C / Stellaris ICDI: lightweight lm4flash, resets and runs after write
flasher = board['flasher']
- ret = run_cmd(f'lm4flash -s {flasher["uid"]} {flasher["args"]} {firmware}')
+ ret = hil_util.run_cmd(f'lm4flash -s {flasher["uid"]} {flasher["args"]} {firmware}',
+ timeout=timeout)
return ret
def reset_lm4flash(board):
# lm4flash has no reset-only mode; it resets+runs on flash, so reset is a no-op
- flasher = board['flasher']
return subprocess.CompletedProcess(args=['dummy'], returncode=0)
-# The one place a flasher's firmware extension is decided: find_firmware resolves the
-# path with it and the flash_* functions pass that path through untouched. A flasher
-# added here without an entry falls back to .elf-or-.bin and can be handed the wrong
-# file — test_hil_select's TestRosterFlashersDispatch fails if a roster names one.
+reset_lm4flash.no_op = True
+
+
+# The one place a flasher's firmware extension is decided. A flasher with no entry falls
+# back to .elf-or-.bin and can be handed the wrong file — test_hil_select's
+# TestRosterFlashersDispatch fails if a roster names one.
FLASHER_SUFFIX = {
'esptool': '.bin',
'jlink': '.elf',
@@ -271,13 +307,12 @@ FLASHER_SUFFIX = {
def find_firmware(variant: str, example: str, roots: list | None = None, flasher: str | None = None):
"""Locate a built example's firmware under <build_dir>/cmake-build-<variant>/<example>/,
- then under EXTRA_BUILD_DIRS (empty unless the caller opts in — see its comment).
- `roots` overrides that search list entirely for one call (e.g. to find a build just
- produced by tools/build.py in its fixed cmake-build/ layout without widening the
- global policy). `flasher` is the roster flasher name: it selects which extension
- counts (see FLASHER_SUFFIX), so a build that produced only the other one is reported
- missing — a clean "Skip (no binary)" — instead of being handed to the flasher, which
- would fail opaquely on the absent file and burn every retry plus the board lock.
+ then under EXTRA_BUILD_DIRS. `roots` overrides that search list entirely for one call
+ (e.g. a build just produced by tools/build.py in its fixed cmake-build/ layout)
+ without widening the global policy. `flasher` is the roster flasher name and selects
+ which extension counts (FLASHER_SUFFIX), so a build that produced only the other one
+ is reported missing — a clean "Skip (no binary)" — instead of being handed to the
+ flasher, which would fail opaquely and burn every retry plus the board lock.
Accepts the single-config layout (firmware directly in the example dir) or Ninja
Multi-Config (a per-config subdir like RelWithDebInfo/).
Returns the full Path INCLUDING extension, or None if not built."""
@@ -286,7 +321,7 @@ def find_firmware(variant: str, example: str, roots: list | None = None, flasher
if not suffixes or suffixes == [None]:
suffixes = ['.elf', '.bin']
for bd in dict.fromkeys(roots if roots is not None else [build_dir, *EXTRA_BUILD_DIRS]):
- fw_dir = TINYUSB_ROOT / bd / f'cmake-build-{variant}' / example
+ fw_dir = hil_util.TINYUSB_ROOT / bd / f'cmake-build-{variant}' / example
if not fw_dir.is_dir():
continue
for cand in [fw_dir / base, fw_dir / 'RelWithDebInfo' / base,
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index e7f82bd7f..174251343 100755
--- a/test/hil/hil_test.py
+++ b/test/hil/hil_test.py
@@ -23,9 +23,10 @@
# THE SOFTWARE.
# Host setup (required: a missing tool fails its test rather than skipping it):
-# - System packages: sudo apt install mtools libmtp9 alsa-utils iperf
+# - System packages: sudo apt install mtools libmtp9 libmtp-runtime alsa-utils iperf
# mtools read_disk_file (device/cdc_msc, device/msc_dual_lun)
# libmtp9 pymtp ctypes load (device/mtp); Debian 13 uses libmtp9t64
+# libmtp-runtime mtp-probe and the completed-device /dev/libmtp-* marker
# alsa-utils arecord (device/audio_test_freertos)
# iperf throughput tests (device/net_lwip_*)
# openocd unified openocd from https://github.com/hathach/openocd (branch tinyusb) for wch, rp2040/rp2350, analog max32
@@ -44,7 +45,10 @@ import os
import random
import re
import select
+import signal
+import shlex
import sys
+import tempfile
import time
from contextlib import redirect_stdout
from pathlib import Path
@@ -52,30 +56,29 @@ from typing import TypedDict, NotRequired, cast
import serial
import subprocess
+import traceback
import json
import glob
import multiprocessing
from multiprocessing import TimeoutError as MpTimeoutError
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it
import hil_flash
-import hil_lock
-from hil_examples import device_tests, dual_tests, host_test
+from helper import hil_health, hil_lock, hil_util
+from helper.hil_util import device_tests, dual_tests, host_test
-# Raw Lock/Semaphore objects passed via Pool initargs are inheritable only under the fork
-# start method (spawn/forkserver pickle them and fail at Pool creation) — pin it so a
-# future interpreter default change cannot break the run at startup.
-_mp = multiprocessing.get_context('fork')
+# Raw Lock/Semaphore objects in Pool initargs are inheritable only under fork
+# (spawn/forkserver pickle them and fail at Pool creation), so pin it against an
+# interpreter default change. Windows has no fork: fall back so it still IMPORTS there.
+
+_mp = multiprocessing.get_context('fork') if os.name != 'nt' else multiprocessing.get_context()
Pool, Lock, Semaphore, Manager = _mp.Pool, _mp.Lock, _mp.Semaphore, _mp.Manager
-import hashlib
-import ctypes
-from pymtp import MTP
import string
-# Enumeration wait budget. The first attempt gets ENUM_TIMEOUT; retry attempts get the
-# shorter ENUM_TIMEOUT_RETRY - the board was just re-flashed again, and a device that is
-# going to enumerate shows up within a few seconds, so a failing test costs ~3-5x a
-# passing one instead of 10-30x. Per-attempt value is set by test_example(); each pool
-# worker is its own process, so a module global is safe.
+# Enumeration wait budget: first attempt ENUM_TIMEOUT, retries the shorter
+# ENUM_TIMEOUT_RETRY -- a device that will enumerate shows up within seconds, so a failing
+# test costs ~3-5x a passing one instead of 10-30x. Set per attempt by test_example(); a
+# module global is safe because each pool worker is its own process.
ENUM_TIMEOUT = 8
ENUM_TIMEOUT_RETRY = 4
_enum_timeout = ENUM_TIMEOUT
@@ -86,11 +89,11 @@ def enum_timeout() -> int:
return _enum_timeout
-def wait_until(predicate, step: float = 1.0):
+def wait_until(predicate, step: float = 1.0, timeout: float | None = None):
"""Poll predicate under the per-attempt enum budget. Deadline-based so a slow predicate
- body (subprocess, libmtp scan) counts against the budget. Returns the first truthy
- predicate value, or None on timeout."""
- deadline = time.monotonic() + enum_timeout()
+ body (subprocess, libmtp scan) counts against the budget. An explicit timeout overrides
+ that budget. Returns the first truthy predicate value, or None on timeout."""
+ deadline = time.monotonic() + (enum_timeout() if timeout is None else timeout)
while True:
r = predicate()
if r:
@@ -103,26 +106,36 @@ STATUS_OK = "\033[32mOK\033[0m"
STATUS_FAILED = "\033[31mFailed\033[0m"
STATUS_SKIPPED = "\033[33mSkipped\033[0m"
-# Plain (non-ANSI) cell symbols for the markdown matrix report (hil_report.md).
-# A missing binary is reported as skipped too.
+# Plain (non-ANSI) cell symbols for hil_report.md; a missing binary counts as skipped.
REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '⚪'}
class TestFail(AssertionError):
"""Fail a test but still surface a metric string in its report cell (e.g. usbtest's '❌ 29/30'
instead of a bare ❌). The cell metric is icon-prefixed so render/tally treat it as a failure."""
- def __init__(self, msg: str, metric: str | None = None):
+ def __init__(self, msg: str, metric: str | None = None, parsed: bool = False):
super().__init__(msg)
self.metric = metric
+ # parsed=True: a real per-case verdict, so a retry would only re-observe it
+ # (test_example skips the rest). A failure to RUN the tool stays retryable.
+ self.parsed = parsed
verbose = False
+# Set when a HUNG usbtest case could not be recovered: the DUT's usbfs node still has a
+# D-state holder, so every later flash on that board enumerates into it, blocks, survives
+# SIGKILL and becomes another stray. maxtasksperchild=1 gives each board its own worker,
+# so this global is board-scoped; test_board resets it anyway.
+board_wedged = ''
+max_retry = 1 # mirrors argparse's -r default (see main); defined HERE too so
+ # test_example is callable (and testable) without going through main()
PROFILE = os.environ.get('HIL_PROFILE') == '1' # timestamped logs + permit/flash timing + ctrl-map dump
test_only = []
board_test = {}
skip_flash = False
print_lock = None
shuffle_seed = None # per-run seed for the per-board test-order shuffle (HIL_SHUFFLE_SEED to replay)
+_current_fw = None # firmware test_example resolved for the RUNNING test (set before each test fn)
def init_worker(lock, seed, b_mutexes, f_sems, cmap, cmeta, hints_by_uid):
@@ -146,13 +159,21 @@ def log_line(msg: str) -> None:
def compact_output(raw: str) -> str:
if not raw:
return ''
- lines = [ln.strip() for ln in raw.replace('\r', '\n').split('\n') if ln.strip()]
+ # Defense in depth (the emitter already suppresses them, see _ci_log_groups): markers
+ # piped into this capture land mid-row, where GitHub renders them literally.
+ lines = []
+ for ln in raw.replace('\r', '\n').split('\n'):
+ ln = hil_util.strip_workflow_markers(ln.strip()).strip()
+ if ln:
+ lines.append(ln)
return ' | '.join(lines)
class FlasherCfg(TypedDict):
name: str
uid: str
- args: str
+ args: NotRequired[str] # stlink entries carry no args
+ vid_pid: NotRequired[str] # openocd probe pin, verbatim (e.g. "0x2e8a 0x000c")
+ verify: NotRequired[bool] # openocd read-back verify opt-out (WCH)
class AttachedDevCfg(TypedDict, total=False):
@@ -196,9 +217,34 @@ class Board(TypedDict):
class HilConfig(TypedDict):
boards: list[Board]
-POOL_TIMEOUT = int(os.getenv('HIL_POOL_TIMEOUT', '4200')) # usbtest batteries are serialized fleet-wide, lengthening the tail
-SERIAL_READ_TIMEOUT = float(os.getenv('HIL_SERIAL_READ_TIMEOUT', '5'))
-SERIAL_WRITE_TIMEOUT = float(os.getenv('HIL_SERIAL_WRITE_TIMEOUT', '10'))
+# Below the CI job ceilings so THIS guard fires first and still writes a report, well
+# above a healthy fleet run (~14 min measured), and deliberately generous: firing early
+# abandons boards that were still in flight (30 min fired on 5 of the last 8 HIL jobs),
+# while firing late costs minutes on an already-wedged run. The drain keeps whatever had
+# already finished either way.
+POOL_TIMEOUT = hil_util.pos_int_env('HIL_POOL_TIMEOUT', 3600)
+
+
+# Headroom on top of a battery's own budget so ONE HUNG recovery (case timeout, SIGKILL
+# wait, bounded reflash, settle) can finish. Only spent when cases actually time out.
+USBTEST_RECOVERY_BUDGET = hil_util.pos_int_env('HIL_USBTEST_RECOVERY_BUDGET', 250)
+# How long usbtest.py may keep starting new cases (--budget). The outer run_cmd timeout is
+# always this PLUS the recovery headroom, never a separate literal, or lowering one eats
+# the reserve the recovery needs. 0 is refused (usbtest.py reads it as "no limit"); the
+# margin over a healthy battery (~200s) keeps contention from becoming BUDGET entries.
+USBTEST_BATTERY_BUDGET = hil_util.pos_int_env('HIL_USBTEST_BATTERY_BUDGET', 260)
+
+# The battery checks its budget BEFORE dispatching a case, so it can overshoot by one
+# already-started case. Our outer kill must sit ABOVE that or we SIGKILL the battery just
+# as it goes to print its JSON, turning ~29 real per-case verdicts into "usbtest did not
+# run" and re-paying the whole battery on retry.
+# Worst case, from usbtest.py: --timeout 60 (the case) + 5s post-SIGKILL reap +
+# dmesg_tail(), which is bounded by HELPER_TIMEOUT=30 and runs on BOTH the FAIL and HUNG
+# timeout paths = 95s. 120 leaves a margin; 75 (my first estimate, taken before checking
+# dmesg_tail) was 20s SHORT and would have killed the battery mid-print.
+USBTEST_OVERSHOOT = 120
+SERIAL_READ_TIMEOUT = hil_util.pos_float_env('HIL_SERIAL_READ_TIMEOUT', 5)
+SERIAL_WRITE_TIMEOUT = hil_util.pos_float_env('HIL_SERIAL_WRITE_TIMEOUT', 10)
MSC_README_TXT = \
@@ -206,7 +252,6 @@ b"This is tinyusb's MassStorage Class demo.\r\n\r\n\
If you find any bugs or get any questions, feel free to file an\r\n\
issue at github.com/hathach/tinyusb"
-# get usb disk by id
def get_disk_dev(id, vendor_str, lun):
return f'/dev/disk/by-id/usb-{vendor_str}_Mass_Storage_{id}-0:{lun}'
@@ -234,8 +279,7 @@ def open_serial_dev(port: str):
while timeout > 0:
if os.path.exists(port):
try:
- # write_timeout: a wedged device otherwise blocks ser.write() forever,
- # hanging the worker until the pool/job timeout kills the whole run
+ # write_timeout: see serial_write_all
ser = serial.Serial(port, baudrate=115200, timeout=SERIAL_READ_TIMEOUT,
write_timeout=SERIAL_WRITE_TIMEOUT)
break
@@ -251,18 +295,43 @@ def open_serial_dev(port: str):
def serial_write_all(ser: serial.Serial, data: bytes):
- # write_timeout is a total deadline for the whole call (pyserial keeps partial progress
- # internally). A timeout means the device stopped draining — treat it as fatal: pyserial
- # loses the partial-write count on raise, so retrying would duplicate bytes on the wire.
+ # write_timeout is a deadline for the whole call. A timeout means the device stopped
+ # draining, and it is fatal: pyserial loses the partial-write count on raise, so
+ # retrying would duplicate bytes on the wire.
try:
ser.write(data)
except serial.SerialTimeoutException:
raise AssertionError(f'Serial write timeout after {SERIAL_WRITE_TIMEOUT:.1f}s')
+LP_OPEN_TIMEOUT = 5 # bound on opening the printer lp node; see test_device_printer_to_cdc
+# Runs under hil_util.run_alongside as `python3 -c`. Inline rather than a file so hil_ci.sh's
+# staging list does not need another entry to keep the rig working.
+LP_READER = (
+ 'import os, sys\n'
+ 'fd = os.open(sys.argv[1], os.O_RDONLY)\n'
+ # readiness marker: the parent must not send a byte before the node is open, or the
+ # bytes are lost. A blind sleep raced CPython start-up on a loaded rig.
+ 'open(sys.argv[3], "w").close()\n'
+ 'want = int(sys.argv[2])\n'
+ 'buf = b""\n'
+ 'while len(buf) < want:\n'
+ ' chunk = os.read(fd, min(64, want - len(buf)))\n'
+ ' if not chunk:\n'
+ ' break\n'
+ ' buf += chunk\n'
+ 'sys.stdout.buffer.write(buf)\n'
+)
+MTYPE_TIMEOUT = 30 # a README-sized read is <1 s; bounds a D-state hang on a wedged device
+
+
def read_disk_file(uid: str, lun: int, fname: str) -> bytes:
- # Reads a file from a FAT volume on a block device without mounting it.
- # Requires mtools: `apt install mtools` (no pip dependency).
+ # Reads a file from an unmounted FAT volume; needs mtools. run_cmd everywhere in this
+ # file rather than subprocess.run/check_output: its post-timeout reap is an unbounded
+ # communicate() with no killpg (CPython 3.13.5 subprocess.py:558-565 -- kill(), then
+ # communicate() with NO timeout), which never returns on a device wedged in D state,
+ # where the kill is queued and never delivered. binary
+ # keeps the bytes exact, split_stderr keeps mtype warnings out of them.
dev = get_disk_dev(uid, 'TinyUSB', lun)
last_err = None
@@ -270,38 +339,27 @@ def read_disk_file(uid: str, lun: int, fname: str) -> bytes:
nonlocal last_err
if not os.path.exists(dev):
return None
- try:
- data = subprocess.check_output(
- ['mtype', '-i', dev, f'::/{fname}'], stderr=subprocess.PIPE)
- assert data, f'Cannot read file {fname} from {dev}'
- return data
- except subprocess.CalledProcessError as e:
- last_err = e.stderr.decode(errors='replace').strip()
- return None
+ r = hil_util.run_cmd(f"mtype -i {shlex.quote(dev)} ::/{shlex.quote(fname)}",
+ timeout=MTYPE_TIMEOUT, binary=True, split_stderr=True, quiet=True)
+ if r.returncode == 0:
+ if r.stdout:
+ return r.stdout
+ # rc 0 with no data is an answer (empty file, zeroed sectors), not "not
+ # ready" — fail now instead of spinning the budget
+ raise AssertionError(f'Cannot read file {fname} from {dev}: mtype returned no data')
+ last_err = (r.stderr or b'').decode(errors='replace').strip() or f'mtype rc {r.returncode}'
+ return None
data = wait_until(try_read)
if data is None:
- raise AssertionError(f'mtype failed on {dev}: {last_err}' if last_err else f'Storage {dev} not existed')
+ raise AssertionError(f'Cannot read file {fname} from {dev}: {last_err}' if last_err
+ else f'Storage {dev} not existed')
return data
-def open_mtp_dev(uid):
- mtp = MTP()
-
- def try_open():
- # unmount gio/gvfs MTP mount which blocks libmtp from accessing the device
- subprocess.run(f"gio mount -u mtp://TinyUsb_TinyUsb_Device_{uid}/",
- shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- for raw in mtp.detect_devices():
- mtp.device = mtp.mtp.LIBMTP_Open_Raw_Device(ctypes.byref(raw))
- if mtp.device:
- sn = mtp.get_serialnumber().decode('utf-8')
- if sn == uid:
- return mtp
- mtp.disconnect()
- return None
-
- return wait_until(try_open)
+# ~5 KB of transfers plus libmtp setup takes seconds, not minutes; a larger value makes a
+# wedged MTP board cost that much on every retry, all charged to the pool guard.
+MTP_SESSION_MARGIN = 30 # transfer budget after enumeration; past it the session is killed
def get_printer_dev(id: str, vendor_str, product_str, ifnum: int):
@@ -310,10 +368,16 @@ def get_printer_dev(id: str, vendor_str, product_str, ifnum: int):
product_str = product_str.replace(' ', '_') if product_str else ''
for lp in glob.glob('/sys/class/usbmisc/lp*'):
try:
- sn = open(f'{lp}/device/../serial').read().strip()
+ # bounded: same device_lock() exposure as the sibling reads (see read_sysfs)
+ sn = hil_util.read_sysfs(f'{lp}/device/../serial')
+ # UNKNOWN is not None: the sentinel has no __eq__, so an unanswered read
+ # would fall through both tests and read as 'not this board' -- the exact
+ # absence/unknown conflation read_sysfs exists to prevent.
+ if sn is None or sn is hil_util.SYSFS_UNKNOWN:
+ continue
if sn == id:
return f'/dev/usb/{os.path.basename(lp)}'
- except (FileNotFoundError, PermissionError, ValueError):
+ except OSError: # read_sysfs swallows its own OSError/ValueError; glob can race
pass
return None
@@ -325,7 +389,8 @@ def open_printer_dev(id: str, vendor_str, product_str, ifnum: int) -> str:
return lp_dev if lp_dev and os.path.exists(lp_dev) else None
lp_dev = wait_until(try_find)
- assert lp_dev, f'Printer device not found for {id} if{ifnum:02d}'
+ assert lp_dev, (f'Printer device not found for {id} if{ifnum:02d}'
+ + hil_util.sysfs_blind_note())
return lp_dev
@@ -335,18 +400,16 @@ def open_printer_dev(id: str, vendor_str, product_str, ifnum: int) -> str:
def test_dual_host_info_to_device_cdc(board):
uid = board['uid']
declared_devs = [f'{d["vid_pid"]}_{d["serial"]}' for d in board['tests']['dev_attached']]
- port = hil_flash.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0)
+ port = hil_util.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0)
ser = open_serial_dev(port)
ser.timeout = 0.1
- # read until all expected devices are enumerated
data = b''
timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
data += new_data
- # check if all devices found
enum_dev_sn = []
for l in data.decode('utf-8', errors='ignore').splitlines():
vid_pid_sn = re.search(r'ID ([0-9a-fA-F]+):([0-9a-fA-F]+) SN (\w+)', l)
@@ -383,7 +446,7 @@ def test_host_device_info(board):
flasher = board['flasher']
declared_devs = [f'{d["vid_pid"]}_{d["serial"]}' for d in board['tests']['dev_attached']]
- port = hil_flash.get_serial_dev(flasher["uid"], None, None, 0)
+ port = hil_util.get_serial_dev(flasher["uid"], None, None, 0)
ser = open_serial_dev(port)
ser.timeout = 0.1
@@ -391,14 +454,12 @@ def test_host_device_info(board):
ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board)
assert ret.returncode == 0, 'Failed to reset device'
- # read until all expected devices are enumerated
data = b''
timeout = enum_timeout()
while timeout > 0:
new_data = ser.read(ser.in_waiting or 1)
if new_data:
data += new_data
- # check if all devices found
enum_dev_sn = []
for l in data.decode('utf-8', errors='ignore').splitlines():
vid_pid_sn = re.search(r'ID ([0-9a-fA-F]+):([0-9a-fA-F]+) SN (\w+)', l)
@@ -462,7 +523,7 @@ def test_host_cdc_msc_hid(board):
if not cdc_devs and not msc_devs:
return 'skipped'
- port = hil_flash.get_serial_dev(flasher["uid"], None, None, 0)
+ port = hil_util.get_serial_dev(flasher["uid"], None, None, 0)
ser = open_serial_dev(port)
ser.timeout = 0.1
@@ -470,7 +531,6 @@ def test_host_cdc_msc_hid(board):
ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board)
assert ret.returncode == 0, 'Failed to reset device'
- # Wait for all expected mount messages
data = b''
timeout = enum_timeout()
wait_cdc = len(cdc_devs) > 0
@@ -486,7 +546,6 @@ def test_host_cdc_msc_hid(board):
time.sleep(0.1)
timeout -= 0.1
- # Lookup serial chip name from vid_pid
vid_pid_name = {
'0403_6001': 'FTDI', '0403_6010': 'FTDI', '0403_6011': 'FTDI', '0403_6014': 'FTDI',
'10c4_ea60': 'CP210x', '10c4_ea70': 'CP210x',
@@ -497,7 +556,6 @@ def test_host_cdc_msc_hid(board):
lines = data.decode('utf-8', errors='ignore').splitlines()
- # Verify and print CDC mount
if cdc_devs:
assert b'CDC Interface is mounted' in data, 'CDC device not mounted on host'
dev = cdc_devs[0]
@@ -506,7 +564,6 @@ def test_host_cdc_msc_hid(board):
if 'CDC Interface is mounted' in l:
print(f'\r\n {chip_name}: {l} ', end='')
- # Verify and print MSC mount (inquiry + disk size)
if msc_devs:
assert b'MassStorage device is mounted' in data, 'MSC device not mounted on host'
assert b'Disk Size' in data, 'MSC Disk Size not reported'
@@ -526,7 +583,6 @@ def test_host_cdc_msc_hid(board):
packet_size = 64
- # Echo test: write random 1-packet_size chunks, wait for echo before sending next
echo_len = 1024
echo_data = rand_ascii(echo_len)
ser.reset_input_buffer()
@@ -534,7 +590,6 @@ def test_host_cdc_msc_hid(board):
while offset < echo_len:
chunk_size = min(random.randint(1, packet_size), echo_len - offset)
serial_write_all(ser, echo_data[offset:offset + chunk_size])
- # wait until this chunk is echoed back
echo = b''
t_end = time.monotonic() + 1.0
while time.monotonic() < t_end and len(echo) < chunk_size:
@@ -555,7 +610,7 @@ def test_host_msc_file_explorer(board):
if not msc_devs:
return 'skipped'
- port = hil_flash.get_serial_dev(flasher["uid"], None, None, 0)
+ port = hil_util.get_serial_dev(flasher["uid"], None, None, 0)
ser = open_serial_dev(port)
ser.timeout = 0.1
@@ -563,7 +618,6 @@ def test_host_msc_file_explorer(board):
ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board)
assert ret.returncode == 0, 'Failed to reset device'
- # Wait for MSC mount (Disk Size message)
data = b''
timeout = enum_timeout()
while timeout > 0:
@@ -600,14 +654,12 @@ def test_host_msc_file_explorer(board):
if MSC_README_TXT.decode() in resp_text:
print('README.TXT matched ', end='')
- # MSC throughput test: send dd command to read sectors
time.sleep(0.5)
ser.reset_input_buffer()
for ch in 'dd 1024\r':
serial_write_all(ser, ch.encode())
time.sleep(0.002)
- # Read dd output until prompt
resp = b''
t = 30.0
while t > 0:
@@ -642,15 +694,14 @@ def test_host_msc_file_explorer_freertos(board):
# Tests: device
# -------------------------------------------------------------
def test_device_board_test(board):
- # Dummy test
pass
def test_device_cdc_dual_ports(board):
uid = board['uid']
port = [
- hil_flash.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0),
- hil_flash.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 2)
+ hil_util.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0),
+ hil_util.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 2)
]
ser = [open_serial_dev(p) for p in port]
@@ -689,7 +740,7 @@ def test_device_cdc_dual_ports(board):
def test_device_cdc_msc(board):
uid = board['uid']
# CDC Echo test
- port = hil_flash.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0)
+ port = hil_util.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0)
ser = open_serial_dev(port)
def rand_ascii(length):
@@ -718,6 +769,20 @@ def test_device_cdc_msc_freertos(board):
test_device_cdc_msc(board)
+def link_is_fs(speed) -> bool:
+ """Payload scaling from a `speed` attribute. Anything not positively read as high speed
+ counts as FS -- including None and SYSFS_UNKNOWN: the FS payload merely tests an HS
+ board less, while the HS payload hard-fails a healthy FS board."""
+ return speed not in ('480', '5000', '10000')
+
+
+def dd_timeout(mib: float) -> int:
+ """Bound one dd by what was ASKED for: 2.5 s/MiB is the slowest rate this test has
+ measured (FS CDC, ~420 kB/s), over a 30 s floor. A flat bound fails a healthy board as
+ soon as the payload grows or the leaf-hub uplink is shared."""
+ return int(30 + 2.5 * mib)
+
+
def test_device_cdc_msc_throughput(board):
uid = board['uid']
@@ -728,7 +793,6 @@ def test_device_cdc_msc_throughput(board):
return f'{float(m.group(1)):.1f} {m.group(2)}ps'
return '?'
- # Wait for MSC disk enumeration
dev = get_disk_dev(uid, 'TinyUSB', 0)
timeout = enum_timeout()
while timeout > 0:
@@ -737,8 +801,7 @@ def test_device_cdc_msc_throughput(board):
time.sleep(0.1); timeout -= 0.1
assert timeout > 0, f'Disk {dev} not found'
- # Wait for CDC tty enumeration
- tty = hil_flash.get_serial_dev(uid, 'TinyUSB', 'Throughput', 0)
+ tty = hil_util.get_serial_dev(uid, 'TinyUSB', 'Throughput', 0)
timeout = enum_timeout()
while timeout > 0:
if os.path.exists(tty):
@@ -746,41 +809,48 @@ def test_device_cdc_msc_throughput(board):
time.sleep(0.1); timeout -= 0.1
assert timeout > 0, f'CDC tty {tty} not found'
- # Detect speed (12 Mbps FS / 480 Mbps HS) for payload scaling
- is_fs = False
- for f in glob.glob('/sys/bus/usb/devices/*/serial'):
- try:
- if open(f).read().strip().lower() == uid.lower():
- is_fs = (open(os.path.join(os.path.dirname(f), 'speed')).read().strip() == '12')
- break
- except (OSError, ValueError):
- pass
+ # Detect speed (12 Mbps FS / 480 Mbps HS) for payload scaling; a device we never find
+ # keeps the FS payload (see link_is_fs)
+ # usb_scan, not a private glob: it skips root hubs and remembers paths that already
+ # stranded, so one wedged peer cannot spend this worker's blindness budget four reads
+ # at a time.
+ is_fs = True
+ speed_known = False
+ devs, _ = hil_util.usb_scan(vid='cafe', serial=uid)
+ if devs:
+ speed = hil_util.read_sysfs(os.path.join(devs[0]['dir'], 'speed'))
+ is_fs = link_is_fs(speed)
+ speed_known = speed not in (None, hil_util.SYSFS_UNKNOWN)
# Put tty in raw mode so dd sees pure binary throughput.
- rs = hil_flash.run_cmd(f'timeout 30 stty -F {tty} raw -echo')
- assert rs.returncode == 0, f'stty failed: {hil_flash.cmd_stdout_text(rs.stdout)}'
+ rs = hil_util.run_cmd(f'timeout 30 stty -F {tty} raw -echo')
+ assert rs.returncode == 0, f'stty failed: {hil_util.cmd_stdout_text(rs.stdout)}'
# Payload aim: ~5 s per direction at FS (~830 kB/s), much less at HS.
msc_count = 2 if is_fs else 16 # bs=1M
cdc_count = 16 if is_fs else 128 # bs=64K
tmp_file = f'/tmp/cdc_msc_tp_{uid}.bin'
+ t_cdc, t_msc = dd_timeout(cdc_count / 16), dd_timeout(msc_count)
- rw = hil_flash.run_cmd(f'timeout 30 dd if=/dev/zero of={tty} bs=64K count={cdc_count} 2>&1')
- assert rw.returncode == 0, f'CDC dd write failed: {hil_flash.cmd_stdout_text(rw.stdout)}'
- cdc_w = parse_speed(hil_flash.cmd_stdout_text(rw.stdout))
+ rw = hil_util.run_cmd(f'timeout {t_cdc} dd if=/dev/zero of={tty} bs=64K count={cdc_count} 2>&1')
+ assert rw.returncode == 0, f'CDC dd write failed: {hil_util.cmd_stdout_text(rw.stdout)}'
+ cdc_w = parse_speed(hil_util.cmd_stdout_text(rw.stdout))
- rr = hil_flash.run_cmd(f'timeout 30 dd if={tty} of=/dev/null bs=64K count={cdc_count} iflag=fullblock 2>&1')
- assert rr.returncode == 0, f'CDC dd read failed: {hil_flash.cmd_stdout_text(rr.stdout)}'
- cdc_r = parse_speed(hil_flash.cmd_stdout_text(rr.stdout))
+ rr = hil_util.run_cmd(f'timeout {t_cdc} dd if={tty} of=/dev/null bs=64K count={cdc_count} iflag=fullblock 2>&1')
+ assert rr.returncode == 0, f'CDC dd read failed: {hil_util.cmd_stdout_text(rr.stdout)}'
+ cdc_r = parse_speed(hil_util.cmd_stdout_text(rr.stdout))
- rmr = hil_flash.run_cmd(f'dd if={dev} of={tmp_file} bs=1M count={msc_count} iflag=direct 2>&1')
- assert rmr.returncode == 0, f'MSC dd read failed: {hil_flash.cmd_stdout_text(rmr.stdout)}'
- msc_r = parse_speed(hil_flash.cmd_stdout_text(rmr.stdout))
+ # inner bound, like the CDC pair above: run_cmd's SIGKILL is merely QUEUED against a
+ # dd blocked in the block layer on a half-dead device, so without one the call rides
+ # CMD_TIMEOUT and is abandoned holding the disk and usbfs nodes.
+ rmr = hil_util.run_cmd(f'timeout {t_msc} dd if={dev} of={tmp_file} bs=1M count={msc_count} iflag=direct 2>&1')
+ assert rmr.returncode == 0, f'MSC dd read failed: {hil_util.cmd_stdout_text(rmr.stdout)}'
+ msc_r = parse_speed(hil_util.cmd_stdout_text(rmr.stdout))
- rmw = hil_flash.run_cmd(f'dd if={tmp_file} of={dev} bs=1M count={msc_count} oflag=direct 2>&1')
- assert rmw.returncode == 0, f'MSC dd write failed: {hil_flash.cmd_stdout_text(rmw.stdout)}'
- msc_w = parse_speed(hil_flash.cmd_stdout_text(rmw.stdout))
+ rmw = hil_util.run_cmd(f'timeout {t_msc} dd if={tmp_file} of={dev} bs=1M count={msc_count} oflag=direct 2>&1')
+ assert rmw.returncode == 0, f'MSC dd write failed: {hil_util.cmd_stdout_text(rmw.stdout)}'
+ msc_w = parse_speed(hil_util.cmd_stdout_text(rmw.stdout))
try:
os.remove(tmp_file)
@@ -789,8 +859,7 @@ def test_device_cdc_msc_throughput(board):
print(f' CDC read {cdc_r} write {cdc_w}, MSC read {msc_r} write {msc_w} ', end='')
- # compact read/write speeds for the report cell, e.g. "✅ C 652/422k M 1.1M/783k"
- # (C=CDC, M=MSC; the unit is shown once when both sides share it)
+ # report cell, e.g. "✅ C 652/422k M 1.1M/783k" (C=CDC, M=MSC; shared unit shown once)
def short(s):
return (s.split()[0].rstrip('0').rstrip('.') + s.split()[-1][0]) if ' ' in s else s
@@ -800,20 +869,29 @@ def test_device_cdc_msc_throughput(board):
r = r[:-1]
return f'{r}/{w}'
- return f'{REPORT_CELL["pass"]} C {pair(cdc_r, cdc_w)} M {pair(msc_r, msc_w)}'
+ # 'FS?' when the speed could not be read: the numbers below were produced against the FS
+ # payload, so an HS board reads as suspiciously slow. Say so rather than publish a green
+ # cell whose scale is a guess.
+ scale = '' if speed_known else ' FS?'
+ return f'{REPORT_CELL["pass"]} C {pair(cdc_r, cdc_w)} M {pair(msc_r, msc_w)}{scale}'
def test_device_dfu(board):
uid = board['uid']
+ vid_pid = 'cafe:400b'
- # Wait device enum. Deadline-based: dfu-util -l itself takes ~1 s per call, which a
- # per-iteration countdown would not charge against the budget.
+ # Deadline-based: dfu-util takes ~1 s per call, which a countdown would not charge
+ # against the budget. -d pins enumeration to THIS example's ids: a bare `-l` opens every
+ # DFU-capable node, and one wedged node blocks that open in D state. The pair is doubled
+ # because dfu-util matches run-time and DFU-mode devices against SEPARATE id pairs
+ # (parse_vendprod: an omitted DFU-mode pair matches ANY DFU-mode device). The deadline
+ # is only tested BETWEEN calls, so the per-call bound is what caps a blocked open.
deadline = time.monotonic() + enum_timeout()
found = False
while time.monotonic() < deadline:
- ret = hil_flash.run_cmd(f'dfu-util -l')
- stdout = hil_flash.cmd_stdout_text(ret.stdout)
- if f'serial="{uid}"' in stdout and 'Found DFU: [cafe:400b]' in stdout:
+ ret = hil_util.run_cmd(f'dfu-util -d {vid_pid},{vid_pid} -l', timeout=15)
+ stdout = hil_util.cmd_stdout_text(ret.stdout)
+ if f'serial="{uid}"' in stdout and f'Found DFU: [{vid_pid}]' in stdout:
found = True
break
time.sleep(1)
@@ -823,17 +901,23 @@ def test_device_dfu(board):
f_dfu0 = f'dfu0_{uid}'
f_dfu1 = f'dfu1_{uid}'
- # Test upload
try:
os.remove(f_dfu0)
os.remove(f_dfu1)
except OSError:
pass
- ret = hil_flash.run_cmd(f'dfu-util -S {uid} -a 0 -U {f_dfu0}')
+ # -d as well as -S: dfu-util matches the SERIAL only after libusb_open() (dfu_util.c
+ # probes the descriptor for iSerialNumber), so -S alone still opens every DFU-capable
+ # node. The id filter runs BEFORE the open; -S then picks our board (see the poll).
+ # Each partition is one short string, so a healthy upload is ~1 s; the bound is there
+ # for a node that stops answering mid-transfer.
+ ret = hil_util.run_cmd(f'dfu-util -d {vid_pid},{vid_pid} -S {uid} -a 0 -U {f_dfu0}',
+ timeout=30)
assert ret.returncode == 0, 'Upload failed'
- ret = hil_flash.run_cmd(f'dfu-util -S {uid} -a 1 -U {f_dfu1}')
+ ret = hil_util.run_cmd(f'dfu-util -d {vid_pid},{vid_pid} -S {uid} -a 1 -U {f_dfu1}',
+ timeout=30)
assert ret.returncode == 0, 'Upload failed'
with open(f_dfu0) as f:
@@ -848,13 +932,14 @@ def test_device_dfu(board):
def test_device_dfu_runtime(board):
uid = board['uid']
- # Wait device enum (deadline-based, see test_device_dfu)
+ vid_pid = 'cafe:400c'
+ # enumeration pinned to this example's ids, same per-call bound (see test_device_dfu)
deadline = time.monotonic() + enum_timeout()
found = False
while time.monotonic() < deadline:
- ret = hil_flash.run_cmd(f'dfu-util -l')
- stdout = hil_flash.cmd_stdout_text(ret.stdout)
- if f'serial="{uid}"' in stdout and 'Found Runtime: [cafe:400c]' in stdout:
+ ret = hil_util.run_cmd(f'dfu-util -d {vid_pid},{vid_pid} -l', timeout=15)
+ stdout = hil_util.cmd_stdout_text(ret.stdout)
+ if f'serial="{uid}"' in stdout and f'Found Runtime: [{vid_pid}]' in stdout:
found = True
break
time.sleep(1)
@@ -867,7 +952,6 @@ def test_device_hid_boot_interface(board):
kbd = get_hid_dev(uid, 'TinyUSB', 'TinyUSB_Device', 'event-kbd')
mouse1 = get_hid_dev(uid, 'TinyUSB', 'TinyUSB_Device', 'if01-event-mouse')
mouse2 = get_hid_dev(uid, 'TinyUSB', 'TinyUSB_Device', 'if01-mouse')
- # Wait device enum
timeout = enum_timeout()
while timeout > 0:
if os.path.exists(kbd) and os.path.exists(mouse1) and os.path.exists(mouse2):
@@ -884,12 +968,9 @@ def test_device_hid_composite_freertos(id):
def test_device_printer_to_cdc(board):
- import threading
-
uid = board['uid']
- # Wait for CDC port and printer device
- cdc_port = hil_flash.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0)
+ cdc_port = hil_util.get_serial_dev(uid, 'TinyUSB', "TinyUSB_Device", 0)
ser = open_serial_dev(cdc_port)
lp_dev = open_printer_dev(uid, 'TinyUSB', 'TinyUSB_Device', 2)
@@ -909,7 +990,6 @@ def test_device_printer_to_cdc(board):
sizes = [32, 64, 128, 256, 512, random.randint(2000, 5000)]
- # flush any stale data
ser.reset_input_buffer()
# Test 1: Printer -> CDC with multiple sizes, write in random 1-64 byte chunks
@@ -919,7 +999,17 @@ def test_device_printer_to_cdc(board):
ser.reset_input_buffer()
rd = b''
offset = 0
- lp_fd = os.open(lp_dev, os.O_WRONLY | os.O_NONBLOCK)
+ # bounded: O_NONBLOCK does NOT save us -- usblp_open() takes the device mutex
+ # first -- and this open runs on the worker itself, with no thread to abandon
+ lp_fd = hil_util.bounded_open(lp_dev, os.O_WRONLY | os.O_NONBLOCK, 5)
+ # Three-valued on purpose: an OSError here is a FACT about the node (EBUSY from
+ # usblp's single-opener rule, ENOENT from a re-enumeration race, EACCES from a
+ # udev gap) and must not be reported as a wedge -- that sends the operator to
+ # usb-kernel-recover for hardware that is fine.
+ assert lp_fd is not hil_util.SYSFS_UNKNOWN, (
+ f'printer: opening {lp_dev} for write blocked (device wedged)'
+ f'{hil_util.sysfs_blind_note()}')
+ assert lp_fd is not None, f'printer: {lp_dev} could not be opened for write'
try:
while offset < size:
chunk_size = min(random.randint(1, 64), size - offset)
@@ -943,128 +1033,88 @@ def test_device_printer_to_cdc(board):
assert rd == test_data, (f'Printer->CDC wrong data ({size} bytes):\n'
f' expected: {test_data[:64]}\n received: {rd[:64]}')
- # Test 2: CDC -> Printer with multiple sizes, write in random 1-64 byte chunks
- # Use a thread to read from printer since /dev/usb/lp read blocks
+ # Test 2: CDC -> Printer with multiple sizes, write in random 1-64 byte chunks.
+ # The lp read runs in a PROCESS, not a thread: /dev/usb/lp* blocks on read, usblp
+ # allows a SINGLE opener, and a blocked thread cannot be abandoned without keeping
+ # that fd -- which poisoned the node for every later test this worker ran. A killed
+ # process takes its fd with it.
ser.reset_input_buffer()
time.sleep(0.5)
for size in sizes:
test_data = rand_ascii(size)
- rd_result = [b'', None] # [data, error]
- reader_ready = threading.Event()
-
- def lp_reader():
- try:
- rd = b''
- fd = os.open(lp_dev, os.O_RDONLY)
- reader_ready.set()
- try:
- while len(rd) < size:
- chunk = os.read(fd, min(64, size - len(rd)))
- if not chunk:
- break
- rd += chunk
- finally:
- os.close(fd)
- rd_result[0] = rd
- except Exception as e:
- rd_result[1] = e
- reader_ready.set()
- reader = threading.Thread(target=lp_reader, daemon=True)
- reader.start()
- # wait for reader to open lp device before writing
- reader_ready.wait(timeout=5)
- time.sleep(0.1)
+ ready = Path(tempfile.gettempdir()) / f'hil-lp-ready-{os.getpid()}-{size}'
+ ready.unlink(missing_ok=True)
- # Write to CDC in small chunks with flush to avoid overflowing device FIFO
- offset = 0
- while offset < size:
- chunk_size = min(random.randint(1, 64), size - offset)
- serial_write_all(ser, test_data[offset:offset + chunk_size])
- time.sleep(0.01)
- offset += chunk_size
+ def write_cdc():
+ # WAIT for the reader to have the node open. The child has to fork, exec and
+ # boot a CPython interpreter; on a loaded rig that routinely exceeds the 0.3s
+ # this used to sleep, and every byte sent early is lost -- surfacing as a
+ # spurious data mismatch rather than a timeout.
+ deadline = time.monotonic() + LP_OPEN_TIMEOUT + 5
+ while not ready.exists():
+ if time.monotonic() > deadline:
+ return # reader never opened; the rc/compare below reports it
+ time.sleep(0.02)
+ offset = 0
+ while offset < size:
+ chunk_size = min(random.randint(1, 64), size - offset)
+ serial_write_all(ser, test_data[offset:offset + chunk_size])
+ time.sleep(0.01)
+ offset += chunk_size
- reader.join(timeout=10)
- assert not reader.is_alive(), f'CDC->Printer timeout ({size} bytes)'
- assert rd_result[1] is None, f'CDC->Printer read error: {rd_result[1]}'
- assert rd_result[0] == test_data, (f'CDC->Printer wrong data ({size} bytes):\n'
- f' expected: {test_data[:64]}\n received: {rd_result[0][:64]}')
+ try:
+ r = hil_util.run_alongside(
+ [sys.executable, '-c', LP_READER, lp_dev, str(size), str(ready)],
+ write_cdc, LP_OPEN_TIMEOUT + 12)
+ finally:
+ ready.unlink(missing_ok=True)
+ # stderr, not stdout: run_alongside keeps the payload stream clean, so a traceback
+ # from the reader now arrives on its own pipe
+ assert r.returncode == 0, (f'CDC->Printer reader failed ({size} bytes, rc '
+ f'{r.returncode}): {hil_util.cmd_stdout_text(r.stderr)[:200]}')
+ assert r.stdout == test_data, (f'CDC->Printer wrong data ({size} bytes):\n'
+ f' expected: {test_data[:64]}\n received: {r.stdout[:64]}')
time.sleep(0.2)
ser.close()
def test_device_mtp(board):
+ # The whole session lives in mtp_test.py under run_cmd: libmtp calls are synchronous
+ # ctypes that block unkillably (D state) on a wedged device, so a disposable process is
+ # the only thing the harness can walk away from.
uid = board['uid']
-
- # --- BEFORE: mute C-level stderr for libmtp vid/pid warnings ---
- fd = sys.stderr.fileno()
- _saved = os.dup(fd)
- _null = os.open(os.devnull, os.O_WRONLY)
- os.dup2(_null, fd)
-
- mtp = open_mtp_dev(uid)
-
- # --- AFTER: restore stderr ---
- os.dup2(_saved, fd)
- os.close(_null)
- os.close(_saved)
-
- if mtp is None or mtp.device is None:
- assert False, 'MTP device not found'
-
- try:
- assert b"TinyUSB" == mtp.get_manufacturer(), 'MTP wrong manufacturer'
- assert b"MTP Example" == mtp.get_modelname(), 'MTP wrong model'
- assert b'1.0' == mtp.get_deviceversion(), 'MTP wrong version'
- assert b'TinyUSB MTP' == mtp.get_devicename(), 'MTP wrong device name'
-
- # read and compare readme.txt and logo.png
- f1_expect = b'TinyUSB MTP Filesystem example'
- f2_md5_expect = '40ef23fc2891018d41a05d4a0d5f822f' # md5sum of logo.png
- f1 = uid.encode("utf-8") + b'_file1'
- f2 = uid.encode("utf-8") + b'_file2'
- f3 = uid.encode("utf-8") + b'_file3'
- mtp.get_file_to_file(1, f1)
- with open(f1, 'rb') as file:
- f1_data = file.read()
- os.remove(f1)
- assert f1_data == f1_expect, 'MTP file1 wrong data'
- mtp.get_file_to_file(2, f2)
- with open(f2, 'rb') as file:
- f2_data = file.read()
- os.remove(f2)
- assert f2_md5_expect == hashlib.md5(f2_data).hexdigest(), 'MTP file2 wrong data'
- # test send file
- with open(f3, "wb") as file:
- f3_data = os.urandom(random.randint(1024, 3*1024))
- file.write(f3_data)
- file.close()
- fid = mtp.send_file_from_file(f3, b'file3')
- f3_readback = f3 + b'_readback'
- mtp.get_file_to_file(fid, f3_readback)
- with open(f3_readback, 'rb') as f:
- f3_rb_data = f.read()
- os.remove(f3_readback)
- assert f3_rb_data == f3_data, 'MTP file3 wrong data'
- os.remove(f3)
- mtp.delete_object(fid)
- finally:
- mtp.disconnect()
+ script = Path(__file__).resolve().parent / 'mtp_test.py'
+ # 2x, as master's in-process open_mtp_dev used: libmtp-runtime publishes
+ # /dev/libmtp-* only after its SYNCHRONOUS mtp-probe finishes, seconds on a freshly
+ # flashed FS board, and the gio unmount eats part of what is left before the first
+ # probe. Extracting the session into a subprocess halved this by accident (8s/4s),
+ # which fails healthy hardware on the retry.
+ t = 2 * enum_timeout()
+ r = hil_util.run_cmd(
+ f'{shlex.quote(sys.executable)} {shlex.quote(str(script))} --uid {shlex.quote(uid)} --timeout {t}',
+ timeout=t + MTP_SESSION_MARGIN)
+ if r.returncode == 124:
+ # "abandoned", not "killed": a session blocked in a usbfs ioctl (D state) never
+ # receives the SIGKILL -- it lingers until its device path clears, by design
+ raise AssertionError(f'MTP session wedged (abandoned after {t + MTP_SESSION_MARGIN}s; '
+ f'the session process may linger unkillable in D state)')
+ assert r.returncode == 0, f'MTP session failed (rc {r.returncode}):\n{r.stdout}'
def test_device_net_lwip_webserver(board):
# MAC hard-coded in examples/device/net_lwip_webserver/src/main.c; Linux names the
- # USB network interface enx<MAC_lowercase_no_colons>. Device IP is 192.168.7.1 and
- # the example runs an iperf2 TCP server on port 5001 (INCLUDE_IPERF).
+ # iface enx<MAC_lowercase_no_colons>. Device IP 192.168.7.1, iperf2 TCP server on 5001
+ # (INCLUDE_IPERF).
import socket
mac_no_colons = '0202846a9600'
iface = 'enx' + mac_no_colons
device_ip = '192.168.7.1'
iperf_port = 5001
- # Wait for the host to get an IPv4 address in the device's subnet (DHCP served by the device).
- # USB enum + DHCP serve can take longer on the CI HIL hardware than on local — give it 30s.
+ # Wait for an IPv4 address in the device's subnet (it serves DHCP); 30s because USB
+ # enum + DHCP serve is slower on the CI HIL hardware than locally.
iface_timeout = 30
deadline = time.monotonic() + iface_timeout
host_ip = None
@@ -1078,8 +1128,7 @@ def test_device_net_lwip_webserver(board):
time.sleep(0.5)
assert host_ip, f'USB net iface {iface} did not come up with 192.168.7.x within {iface_timeout}s'
- # Poll the iperf TCP port until the device is accepting. The net stack comes up a bit
- # after DHCP completes; iperf server binding isn't instantaneous after reflash.
+ # Poll until the device accepts: the net stack and the iperf bind come up after DHCP.
deadline = time.monotonic() + enum_timeout()
last_err = None
while time.monotonic() < deadline:
@@ -1092,12 +1141,12 @@ def test_device_net_lwip_webserver(board):
time.sleep(0.3)
assert last_err is None, f'iperf TCP {device_ip}:{iperf_port} not accepting within {enum_timeout()}s: {last_err}'
- # Throughput: 5-second iperf2 TCP test, CSV output for stable parsing.
- # iperf2 CSV final summary line: timestamp,src_ip,src_port,dst_ip,dst_port,id,interval,bytes,bps
- ret = subprocess.run(['iperf', '-c', device_ip, '-t', '5', '-y', 'C'],
- capture_output=True, text=True, timeout=30)
- stderr = ret.stderr.strip()
- stdout = ret.stdout.strip()
+ # 5-second iperf2 TCP test; -y C for stable parsing (final summary line is
+ # timestamp,src_ip,src_port,dst_ip,dst_port,id,interval,bytes,bps).
+ ret = hil_util.run_cmd(f'iperf -c {device_ip} -t 5 -y C',
+ timeout=30, split_stderr=True, quiet=True)
+ stderr = (ret.stderr or '').strip()
+ stdout = (ret.stdout or '').strip()
assert ret.returncode == 0, f'iperf rc={ret.returncode}: stderr={stderr!r} stdout={stdout!r}'
lines = [l for l in stdout.splitlines() if l]
assert lines, f'iperf produced no output (rc={ret.returncode}, stderr={stderr!r})'
@@ -1108,19 +1157,16 @@ def test_device_net_lwip_webserver(board):
mbps = bps / 1e6
print(f' iperf {mbps:5.1f} Mbps', end='')
- # Reject implausibly low throughput - a working USB-net link should clear this easily.
assert mbps >= 1.0, f'iperf throughput too low: {mbps:.2f} Mbps'
def test_device_msc_dual_lun(board):
uid = board['uid']
- # Read README from LUN 0
data0 = read_disk_file(uid, 0, 'README0.TXT')
readme0 = b"LUN0: " + MSC_README_TXT
assert data0 == readme0, f'MSC LUN0 wrong data in README0.TXT\n expected: {readme0}\n received: {data0}'
- # Read README from LUN 1
data1 = read_disk_file(uid, 1, 'README1.TXT')
readme1 = b"LUN1: " + MSC_README_TXT
assert data1 == readme1, f'MSC LUN1 wrong data in README1.TXT\n expected: {readme1}\n received: {data1}'
@@ -1129,7 +1175,6 @@ def test_device_msc_dual_lun(board):
def test_device_midi_test(board):
uid = board['uid']
- # Find MIDI device via /dev/snd/by-id using board UID
timeout = enum_timeout()
midi_port = None
while timeout > 0:
@@ -1147,31 +1192,40 @@ def test_device_midi_test(board):
timeout -= 1
assert midi_port is not None, f'MIDI device not found for {uid}'
- # Read MIDI messages and verify note on/off
import select
- with open(midi_port, 'rb') as f:
- notes = []
+ midi_fd = os.open(midi_port, os.O_RDONLY | os.O_NONBLOCK)
+ try:
+ data = bytearray()
# Read for up to 3 seconds to capture a few notes (286ms interval)
end_time = time.monotonic() + 3
- while time.monotonic() < end_time:
- ready, _, _ = select.select([f], [], [], 0.5)
- if ready:
- data = f.read(64)
- if data:
- # Parse MIDI bytes: note_on = 0x90, note_off = 0x80
- i = 0
- while i + 2 < len(data):
- status = data[i]
- if (status & 0xF0) == 0x90: # Note On
- notes.append(data[i + 1])
- i += 3
- elif (status & 0xF0) == 0x80: # Note Off
- i += 3
- else:
- i += 1
+ while (remaining := end_time - time.monotonic()) > 0:
+ ready, _, _ = select.select([midi_fd], [], [], min(0.5, remaining))
+ if not ready:
+ continue
+ try:
+ chunk = os.read(midi_fd, 64)
+ except BlockingIOError:
+ continue
+ if not chunk:
+ break
+ data.extend(chunk)
+ finally:
+ os.close(midi_fd)
+
+ notes = []
+ # Parse MIDI bytes: note_on = 0x90, note_off = 0x80
+ i = 0
+ while i + 2 < len(data):
+ status = data[i]
+ if (status & 0xF0) == 0x90: # Note On
+ notes.append(data[i + 1])
+ i += 3
+ elif (status & 0xF0) == 0x80: # Note Off
+ i += 3
+ else:
+ i += 1
assert len(notes) >= 2, f'Expected at least 2 MIDI notes, got {len(notes)}'
- # Verify notes are from the expected sequence
note_sequence = [
74, 78, 81, 86, 90, 93, 98, 102, 57, 61, 66, 69, 73, 78, 81, 85,
88, 92, 97, 100, 97, 92, 88, 85, 81, 78, 74, 69, 66, 62, 57, 62,
@@ -1212,8 +1266,11 @@ def test_device_audio_test_freertos(board):
raw_path,
]
- ret = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
- assert ret.returncode == 0, f'arecord failed: {ret.stderr.strip() or ret.stdout.strip()}'
+ # run_cmd: ALSA capture from a wedged device blocks in D state (see read_disk_file)
+ ret = hil_util.run_cmd(' '.join(shlex.quote(c) for c in cmd),
+ timeout=20, split_stderr=True, quiet=True)
+ assert ret.returncode == 0, \
+ f'arecord failed: {(ret.stderr or "").strip() or (ret.stdout or "").strip()}'
try:
with open(raw_path, 'rb') as f:
@@ -1231,31 +1288,22 @@ def test_device_audio_test_freertos(board):
samples = [int.from_bytes(raw[i:i + 2], 'little', signed=False) for i in range(0, len(raw), 2)]
assert sample_count > 1024, f'Not enough samples captured: {sample_count}'
- # The firmware sends a continuous uint16 ramp. Using ALSA hw: capture bypasses
- # PulseAudio processing, so most adjacent samples should differ by exactly 1.
- total_diffs = sample_count - 1
- one_step = 0
- near_step = 0
- for i in range(total_diffs):
- d = (samples[i + 1] - samples[i]) & 0xFFFF
- if d == 1:
- one_step += 1
- if d in (0, 1, 2, 47, 48, 49):
- near_step += 1
-
- one_ratio = one_step / total_diffs
- near_ratio = near_step / total_diffs
- assert one_ratio >= 0.85, f'Unexpected audio pattern (strict ratio={one_ratio:.3f})'
- assert near_ratio >= 0.98, f'Unexpected audio pattern (relaxed ratio={near_ratio:.3f})'
+ # The producer is already running while ALSA activates streaming, so the
+ # initial overwritable software FIFO (at most 224 samples) can transition
+ # between ramp generations. After that startup window, require an exact ramp.
+ startup_samples = 256
+ for i in range(startup_samples, sample_count - 1):
+ expected = (samples[i] + 1) & 0xFFFF
+ assert samples[i + 1] == expected, (
+ f'Audio mismatch at sample {i + 1}: expected {expected}, got {samples[i + 1]}')
- print(f' ALSA {pcm} strict={one_ratio:.3f} relaxed={near_ratio:.3f}', end='')
+ print(f' ALSA {pcm}', end='')
def test_device_hid_generic_inout(board):
uid = board['uid']
import hid # cython-hidapi (pip: hidapi, apt: python3-hid)
- # Find HID device by UID (VID=0xCafe)
timeout = enum_timeout()
dev = None
while timeout > 0:
@@ -1272,7 +1320,6 @@ def test_device_hid_generic_inout(board):
h = hid.device()
h.open(dev['vendor_id'], dev['product_id'], uid)
try:
- # Echo test: send random data and verify echo
for size in [8, 32, 63]:
# Report ID (0) + payload, padded to 64 bytes
payload = bytes([random.randint(1, 255) for _ in range(size)])
@@ -1289,63 +1336,177 @@ def test_device_hid_generic_inout(board):
def test_device_usbtest(board):
- # Run the Linux testusb tier-4 battery (test/hil/usbtest.py) against the enumerated cafe:4010
- # device; surface the pass count in the report cell ("✅ 30/30", or "❌ 29/30" on a partial).
+ global board_wedged
+ # Runs test/hil/usbtest.py against the cafe:4010 device; the pass count goes in the
+ # report cell ("✅ 30/30", or "❌ 29/30" on a partial).
uid = board['uid']
def usbtest_enumerated():
- # match VID:PID too, not just the serial: right after flashing, the previous example's
- # enumeration (same serial, different PID) can linger and would fail usbtest.py's lookup
- for f in glob.glob('/sys/bus/usb/devices/*/serial'):
- d = os.path.dirname(f)
- try:
- if (open(f).read().strip().lower() == uid.lower()
- and open(os.path.join(d, 'idVendor')).read().strip() == 'cafe'
- and open(os.path.join(d, 'idProduct')).read().strip() == '4010'):
- return True
- except OSError:
- pass
- return False
+ """True, False, or None when a bounded read did not answer -- absence unproven."""
+ # vid_pid FIRST: right after flashing, the previous example's enumeration (same
+ # serial, different PID) can linger and would fail usbtest.py's lookup -- and
+ # filtering on the two lock-free descriptor fields rules out every other device
+ # on the bus before the one read that can block. usb_scan memoises paths that
+ # already stranded, so one wedged peer cannot spend the blindness budget here.
+ devs, unknown = hil_util.usb_scan(vid_pid=('cafe', '4010'), serial=uid)
+ if devs:
+ return True
+ return None if unknown else False
end = time.monotonic() + enum_timeout()
- while time.monotonic() < end and not usbtest_enumerated():
+ seen = usbtest_enumerated()
+ while time.monotonic() < end and seen is not True:
time.sleep(0.2)
+ seen = usbtest_enumerated()
# fail before usbtest_permit: an absent device would otherwise queue on the battery
# mutex for minutes behind real batteries just to have usbtest.py report "no device"
- if not usbtest_enumerated():
+ if seen is not True:
# 0/30 rather than a bare cell: the battery never ran (30 = standard case count)
- raise TestFail(f'no cafe:4010 device with serial {uid}',
- metric=f'{REPORT_CELL["fail"]} 0/30')
- # settle: right after flashing the enumeration can bounce once (and on dual-port parts like
- # CH32V307 the other port's stale usbtest node — same serial and PID — lingers a moment);
- # running testusb into that gap sees the device drop mid-case
+ raise TestFail(
+ f'no cafe:4010 device with serial {uid}' if seen is False else
+ f'cannot tell whether cafe:4010 {uid} is present: the bounded sysfs reads did '
+ f'not answer{hil_util.sysfs_blind_note()}',
+ metric=f'{REPORT_CELL["fail"]} 0/30')
+ # settle: right after flashing the enumeration can bounce once (and on dual-port parts
+ # the other port's stale node — same serial and PID — lingers), and testusb run into
+ # that gap sees the device drop mid-case
time.sleep(3)
# --keep-binding is required for concurrent batteries: usbtest.py's cleanup unbinds
- # EVERY usbtest-bound interface (releasing stale same-PID grabs), which would kill a
- # peer battery mid-run under USBTEST_PARALLEL > 1; the unbind path has also wedged a
- # host xHCI (usb_hcd_alloc_bandwidth) on this rig. Leaving bindings is harmless with
- # unique example PIDs - the next example re-enumerates under a different PID and binds
- # its normal driver. usbtest_permit budgets USBTEST_PARALLEL batteries per controller.
+ # EVERY usbtest-bound interface, killing a peer battery under USBTEST_PARALLEL > 1, and
+ # that unbind path has also wedged a host xHCI (usb_hcd_alloc_bandwidth) here. Harmless
+ # to leave: the next example enumerates under a different PID.
script = Path(__file__).resolve().parent / 'usbtest.py'
- cmd = f'python3 "{script}" --serial "{uid}" --json --keep-binding --timeout 60'
+ # --budget makes the battery a real bound: repeated case timeouts (a FAIL, not a HUNG,
+ # so the battery keeps going) can otherwise spend the whole outer timeout inside the
+ # case loop, leaving the recovery below nothing.
+ cmd = (f'{shlex.quote(sys.executable)} {shlex.quote(str(script))} '
+ f'--serial {shlex.quote(uid)} --json --keep-binding '
+ f'--timeout 60 --budget {USBTEST_BATTERY_BUDGET}')
+ # Post-hang recovery reflashes the DUT through its own probe, NEVER a root-port cycle
+ # (one board reached instead of every fixture under the port; see usb-kernel-recover).
+ # _current_fw is the artifact test_example flashed for THIS test: re-deriving it from
+ # board['name'] reflashes the wrong build on variant-only boards. --outer-timeout lets
+ # usbtest skip a reflash it cannot finish before our run_cmd kill, which would orphan
+ # the flasher (own session) on the probe. Never under --skip-flash -- and say so: a
+ # HUNG case then holds the DUT's usbfs lock for the rest of the run, and a probe reset
+ # is no substitute (the DWC2 pullup survives a core halt).
+ # ...and only when this flasher can DELIVER that reflash past a poisoned node
+ # (hil_flash.convoy_safe). Otherwise the flags cost twice: the delivery adds a SECOND
+ # stray, and the board reserves recovery budget for a path that cannot fire.
+ # The RECOVERY flasher, which may be the roster's optional `flasher_recover` rather
+ # than the primary -- a jlink/stlink board can name an openocd entry that reaches the
+ # same probe convoy-safely without changing how the board is normally flashed.
+ _rec_flasher = hil_flash.recover_flasher(board)
+ recovery = bool(_current_fw and not skip_flash and hil_flash.convoy_safe(_rec_flasher))
+ # ONE bound, computed here and used for BOTH the child's --outer-timeout and our own
+ # run_cmd kill below. Three separate expressions disagreed: --skip-flash appended no
+ # --outer-timeout at all (usbtest reads 0 as "no limit"), and the no-recovery branch
+ # narrowed only the CHILD's view while run_cmd still waited the full reserve -- so a
+ # board that cannot recover held a pool worker AND its battery permit idle for
+ # USBTEST_RECOVERY_BUDGET it had no way to spend, under a usbtest width of 2.
+ outer = USBTEST_BATTERY_BUDGET + (USBTEST_RECOVERY_BUDGET if recovery
+ else USBTEST_OVERSHOOT)
+ if _current_fw and skip_flash:
+ print('note: --skip-flash disables usbtest hang recovery; a HUNG case will leave '
+ 'the device wedged until it is reflashed', flush=True)
+ elif _current_fw and not recovery:
+ print(f'note: {_rec_flasher["name"]} cannot deliver a reflash past a poisoned '
+ f'usbfs node, so usbtest hang recovery is disabled for {board["name"]}; a '
+ f'HUNG case will leave it wedged for the rest of the run', flush=True)
+ if recovery:
+ # ship the RECOVERY flasher as `flasher`: usbtest.py, recovery_steps and
+ # convoy_safe all read board['flasher'], so substituting here keeps the entire
+ # child side unaware that a second roster entry exists
+ rb = json.dumps({'name': board['name'], 'flasher': _rec_flasher})
+ cmd += f' --recover-board {shlex.quote(rb)} --recover-fw {shlex.quote(_current_fw)}'
+ cmd += f' --outer-timeout {outer}'
+ # The reserve above USBTEST_BATTERY_BUDGET exists because the battery can overrun by
+ # one already-started case, and a hang there needs room for the recovery (whose reflash
+ # is bounded by usbtest.RECOVER_FLASH_TIMEOUT, not HIL_CMD_TIMEOUT). Without it run_cmd
+ # SIGKILLs usbtest.py mid-recovery, losing the JSON and the diagnosis.
with hil_lock.usbtest_permit(uid):
- r = hil_flash.run_cmd(cmd, timeout=200)
- out = hil_flash.cmd_stdout_text(r.stdout)
+ # split_stderr: the battery's final JSON is parsed from stdout, and stderr is the
+ # only detail left when the outer timeout kills the battery before it prints
+ r = hil_util.run_cmd(cmd, timeout=outer, split_stderr=True)
+ out = hil_util.cmd_stdout_text(r.stdout)
brace = out.find('{')
try:
+ # brace < 0 would slice from the END ('...rc 0' -> '0' -> int 0, whose subscript
+ # raises TypeError outside the tuple below and loses the diagnosis)
+ if brace < 0:
+ raise ValueError('no JSON object on stdout')
data = json.loads(out[brace:])
passed, failed = int(data['passed']), int(data['failed'])
- except (ValueError, KeyError, json.JSONDecodeError):
- raise TestFail(f'usbtest did not run: {compact_output(out) or hil_flash.cmd_stdout_text(r.stderr)}',
+ except (ValueError, KeyError, TypeError, json.JSONDecodeError):
+ # compact BOTH, never `or`: a battery SIGKILLed mid-print leaves a truthy JSON
+ # fragment on stdout, so an `or` drops the stderr that explains the failure
+ parts = [compact_output(hil_util.cmd_stdout_text(r.stderr)), compact_output(out)]
+ detail = ' | '.join(p for p in parts if p)
+ # Retryable even on rc 124 (run_cmd's outer kill), though the retry re-pays the
+ # whole budget: 124 only says the timer expired, which a healthy battery can hit
+ # under load, and test_example REFLASHES before each attempt. Where usbtest's
+ # in-band recovery is off (--skip-flash, a flasher failing convoy_safe, a terminal
+ # wedge) that reflash is the only thing left to unpoison the DUT for the boards
+ # that share its controller.
+ # No JSON to read the verdict from, so fall back to the text: a battery SIGKILLed
+ # mid-hang still says HUNG on stdout, and this raise happens BEFORE the latch below
+ # -- which is why the outer-timeout case, the likeliest real wedge, never latched.
+ if 'HUNG' in out:
+ board_wedged = (f'{board["name"]}: usbtest reported a hang and was killed '
+ f'before it could report a verdict')
+ raise TestFail(f'usbtest did not run: {detail}',
metric=f'{REPORT_CELL["fail"]} 0/30')
- total = passed + failed
- if failed == 0 and total > 0:
+ # A HUNG case that recovery could not clear leaves a D-state holder on this board's
+ # usbfs node. Latch it: the remaining examples would each flash THROUGH that node,
+ # block, survive SIGKILL and add another stray -- turning one wedge into one stray per
+ # remaining example, which is the convoy this branch exists to contain.
+ # The battery's OWN verdict first: `recovery` only says the flags were passed, not that
+ # the reflash worked, so a convoy-safe board whose recovery failed used to come back
+ # unlatched and flash every remaining example through the poisoned node.
+ if data.get('wedged') or (not recovery and 'HUNG' in out):
+ # _rec_flasher, NOT board['flasher']: recovery was decided against recover_flasher()
+ # at the top of this function, and the two diverge as soon as a roster carries the
+ # optional `flasher_recover` key -- naming the wrong one sends the operator to the
+ # wrong probe. The wording stays on what usbtest actually reported ("still wedged"),
+ # because unrecovered_hang is also set by the ambiguous/inconclusive aborts, where
+ # nothing hung and the old text was false on both clauses.
+ board_wedged = (f'{board["name"]}: usbtest reports the device still wedged '
+ + (f'after a recovery reflash via {_rec_flasher["name"]}' if recovery
+ else f'and {_rec_flasher["name"]} cannot deliver a recovery reflash'))
+
+ # notrun counts toward the denominator but is NOT a failure: listing cases that never
+ # ran as failures sends a maintainer bisecting one of them.
+ notrun = int(data.get('notrun', 0))
+ total = passed + failed + notrun
+ if board_wedged and failed == 0 and notrun == 0:
+ # Every case passed and the device STILL wedged -- usbtest's inconclusive/ambiguous
+ # abort fires after the last case, so nothing back-fills a BUDGET entry. Reporting
+ # the pass would exit 0 with a D-state holder on the rig and the board absent from
+ # the re-run spec. parsed=True: a retry re-pays the whole battery to re-observe a
+ # wedge, and flashes through the poisoned node to do it.
+ raise TestFail(f'usbtest {passed}/{total} but the device wedged ({board_wedged})',
+ metric=f'{REPORT_CELL["fail"]} {passed}/{total}', parsed=True)
+ if failed == 0 and notrun == 0 and total > 0:
return f'{REPORT_CELL["pass"]} {passed}/{total}'
- bad = [c.get('num') for c in data.get('cases', []) if c.get('status') != 'PASS']
- raise TestFail(f'usbtest {passed}/{total} (cases failed: {bad})',
- metric=f'{REPORT_CELL["fail"]} {passed}/{total}')
+ bad = [c.get('num') for c in data.get('cases', [])
+ if c.get('status') not in ('PASS', 'BUDGET')]
+ why = f'usbtest {passed}/{total}'
+ if bad:
+ why += f' (cases failed: {bad})'
+ if notrun:
+ # the reason is per BUDGET entry: a hang or a device drop also aborts the battery,
+ # and blaming the budget points the maintainer at the wrong thing
+ reasons = {c.get('detail', '') for c in data.get('cases', [])
+ if c.get('status') == 'BUDGET'}
+ reason = (reasons.pop().replace('not run: ', '') if len(reasons) == 1
+ else 'the battery stopped early')
+ why += f'; {notrun} case(s) never ran ({reason}), so this says nothing about them'
+ # parsed ONLY when every case ran: an aborted battery (budget expiry, kernel hang, bus
+ # drop) leaves BUDGET entries, and those are exactly what a reflash retry can fix.
+ raise TestFail(why, metric=f'{REPORT_CELL["fail"]} {passed}/{total}',
+ parsed=(notrun == 0))
# -------------------------------------------------------------
@@ -1370,42 +1531,68 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
test_name = f'{variant:40} {example:30} ...'
- # --skip-flash runs whatever is already on the board, so any build counts as present:
- # only the flashing path needs the artifact this board's flasher actually consumes.
- # Filtering there too would skip the test as "no binary" over an extension it never uses.
+ # --skip-flash runs whatever is already on the board, so any build counts as present;
+ # filtering by flasher there would skip the test over an extension it never uses.
fw_name = hil_flash.find_firmware(variant, example,
flasher=None if skip_flash else board['flasher']['name'])
if fw_name is None:
log_line(f'{test_name} Skip (no binary)')
return 0, 'skip', None
+ # usbtest's hang recovery reflashes the exact artifact under test; re-deriving it from
+ # board['name'] breaks on variant-only boards
+ global _current_fw
+ _current_fw = str(fw_name)
if verbose:
log_line(f'Firmware {fw_name}')
- # flash firmware (unless --skip-flash), then run the test. Both may fail randomly,
- # retry a few times.
global _enum_timeout
start_s = time.time()
flash_ok = True
last_err = ''
last_detail = ''
+ wedge_break = False
for i in range(max_retry):
+ if board_wedged and i:
+ # The latch is set MID-attempt (a HUNG usbtest whose flasher cannot recover),
+ # so test_board's check between tests is too late for THIS test's own retries:
+ # every further attempt re-flashes into the D-state-held node, blocks, survives
+ # SIGKILL and leaves another stray. The wedge is not something a retry can fix.
+ log_line(f'{test_name} not retrying: {board_wedged}')
+ # COUNT it. Breaking out here skips the i == max_retry - 1 branch that would
+ # have incremented err_count, so the board rendered a red cell, contributed 0
+ # to the exit status and was omitted from the re-run spec -- a rig left with a
+ # D-state holder published under sys.exit(0). Latent at CI's --retry 1, live
+ # for every local run and for the workflows that pass no -r.
+ wedge_break = True
+ break
_enum_timeout = ENUM_TIMEOUT if i == 0 else ENUM_TIMEOUT_RETRY
attempt_out = io.StringIO()
with redirect_stdout(attempt_out):
if not skip_flash:
with hil_lock.flash_permit(board['uid']):
t_flash = time.monotonic()
- ret = getattr(hil_flash, f'flash_{board["flasher"]["name"].lower()}')(board, str(fw_name))
+ try:
+ ret = getattr(hil_flash,
+ f'flash_{board["flasher"]["name"].lower()}')(board, str(fw_name))
+ except Exception as e:
+ # A flasher that RAISES (esptool's get_serial_dev when the adapter
+ # drops off the bus, a missing config.env, an unwritable CWD) would
+ # propagate out of the worker and abort the whole drain, costing
+ # every board still in flight.
+ print(f'flash raised: {type(e).__name__}: {e}', flush=True)
+ ret = subprocess.CompletedProcess(args='flash', returncode=1,
+ stdout=f'{type(e).__name__}: {e}')
if PROFILE:
log_line(f'[prof] {variant} {example} flash attempt {i + 1}: '
f'{time.monotonic() - t_flash:.1f}s rc={ret.returncode}')
flash_ok = (ret.returncode == 0)
- # A wedged RP2040/RP2350 DAP answers nothing and the probe has no reset
- # line, so the retry would fail identically; POR it via the Rescue DP
- # first. No-op for every other board and every other flash failure.
- if not flash_ok and i + 1 < max_retry and \
- hil_flash.rescue_openocd(board, hil_flash.cmd_stdout_text(ret.stdout)):
+ # A wedged RP2040/RP2350 DAP answers nothing and the probe has no
+ # reset line, so the retry fails identically; POR it via the Rescue DP
+ # first (no-op otherwise). NOT gated on a remaining attempt: CI HIL jobs
+ # run --retry 1, and this leaves the DAP POR'd for the jobs that follow.
+ if not flash_ok and \
+ hil_flash.rescue_openocd(board, hil_util.cmd_stdout_text(ret.stdout)):
log_line(f'{variant} {example}: DAP wedged, rescued via Rescue DP')
if flash_ok:
try:
@@ -1417,7 +1604,6 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
else:
status = STATUS_OK
result_status = 'pass'
- # a test may return a string to show in its report cell (e.g. speed)
metric = tret if isinstance(tret, str) else None
msg = f'{test_name} {status}'
if last_detail:
@@ -1428,9 +1614,20 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
except Exception as e:
last_err = str(e)
last_detail = compact_output(attempt_out.getvalue())
+ if getattr(e, 'parsed', False):
+ # a PARSED per-case result (usbtest's "29/30"): retrying re-pays
+ # the whole battery, inside the fleet's usbtest permit, to
+ # re-observe a number the JSON already reported. Only that case.
+ err_count += 1
+ metric = getattr(e, 'metric', None)
+ msg = f'{test_name} {STATUS_FAILED}: {e}'
+ if last_detail:
+ msg += f' {last_detail}'
+ msg += f' in {time.time() - start_s:.1f}s'
+ log_line(msg)
+ break
if i == max_retry - 1:
err_count += 1
- # a failing test may still carry a metric to show in its cell (e.g. "❌ 29/30")
metric = getattr(e, 'metric', None)
msg = f'{test_name} {STATUS_FAILED}: {e}'
if last_detail:
@@ -1463,13 +1660,21 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
msg += f' in {time.time() - start_s:.1f}s'
log_line(msg)
+ if wedge_break and not err_count:
+ # ONE error for the test, never two: a board that also failed to flash has already
+ # been counted just above. Without this the test returns 0 -- red cell, clean exit
+ # status, absent from the re-run spec.
+ err_count += 1
return err_count, result_status, metric
def build_board(board: Board) -> tuple[str, int]:
"""Build firmware for this board via tools/build.py.
Honors board config's variant list and build.args defines.
- Output goes to cmake-build/cmake-build-<variant>/ (tools/build.py layout)."""
+ Output goes to cmake-build/cmake-build-<variant>/ (tools/build.py layout).
+
+ Unbounded on purpose: --build is a local convenience (no CI workflow passes it), so
+ the developer watching the build is the timeout."""
name = board['name']
bcfg = cast(BuildCfg, board.get('build', {}))
extra_defs = bcfg.get('args', [])
@@ -1477,7 +1682,7 @@ def build_board(board: Board) -> tuple[str, int]:
failed = 0
for v in variants:
- cmd = [sys.executable, str(hil_flash.TINYUSB_ROOT / 'tools' / 'build.py'), '-b', name]
+ cmd = [sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'build.py'), '-b', name]
for d in extra_defs:
cmd += ['-D', d]
if v['name'] != name:
@@ -1489,8 +1694,19 @@ def build_board(board: Board) -> tuple[str, int]:
if verbose:
cmd.append('-v')
print(f' + {" ".join(cmd)}')
- r = subprocess.run(cmd, cwd=hil_flash.TINYUSB_ROOT)
- if r.returncode != 0:
+ # stdio is inherited so the build STREAMS: a silent buffer is
+ # indistinguishable from a stall.
+ proc = subprocess.Popen(cmd, cwd=hil_util.TINYUSB_ROOT, start_new_session=True)
+ try:
+ rc = proc.wait()
+ except KeyboardInterrupt:
+ # start_new_session means the build never saw the terminal's SIGINT
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except OSError:
+ proc.kill()
+ raise
+ if rc != 0:
failed += 1
return name, failed
@@ -1500,28 +1716,29 @@ BOUNDARY_CELL = 'same-PID boundary'
def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
+ swept = False
name = board['name']
flasher = board['flasher']
+ global board_wedged
+ board_wedged = ''
try:
_lock_fh = hil_lock.acquire_board_lock(name)
except RuntimeError as e:
log_line(f'{name:25} {STATUS_FAILED}: {e}')
- # visible report row so the ❌ matches the exit code; failed-tests stays
- # empty so a re-run repeats the whole board (no bogus -bt test filter)
+ # visible report row so the ❌ matches the exit code; failed-tests stays empty so a
+ # re-run repeats the whole board (no bogus -bt filter)
return name, 1, [], [(name, {'board-locked': 'fail'}, None)], 0.0
# after the lock: flock wait behind a concurrent run is not board cost
t_board = time.monotonic()
try:
- # default to all tests
test_list = []
if name in board_test:
test_list = board_test[name]
elif len(test_only) > 0:
- # Explicit -t: filter against the board's capabilities so a device-only
- # board doesn't try to run host/dual tests (the test functions need a
- # `dev_attached` entry in the board config that won't exist).
+ # Explicit -t: filter against the board's capabilities, or a device-only board
+ # runs host/dual tests whose `dev_attached` config entry does not exist.
board_tests = board.get('tests', {})
if 'only' in board_tests:
allowed = set(board_tests['only'])
@@ -1551,7 +1768,7 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
err_count = 0
failed_tests = []
board_wide_fail = False # re-run the whole board, not a subset of its tests
- rows = [] # list of (row_label, {example: status}, duration) — one row per build variant
+ rows = [] # list of (row_label, {example: status}, duration) — one per build variant
# a -t/-bt filtered run times only a subset; report no duration so an accumulate
# re-run keeps the previous full-run value
partial = bool(test_only) or name in board_test
@@ -1560,11 +1777,10 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
prev_last = None # last test of the previous variant: the variant boundary is an adjacency too
for v in variants:
vname = v['name']
- # Shuffle each (board, variant)'s run order — de-synchronizes the worker pool so
- # usbtest batteries and flash churn spread across the timeline instead of convoying,
- # and surfaces order-dependent bugs. Seeded for replay (HIL_SHUFFLE_SEED, logged by
- # main). Unique per-example PIDs make any two different examples re-enumerate; only
- # the variant boundary can repeat the same example (same PID) — swap it away.
+ # Shuffle each (board, variant)'s run order: spreads batteries and flash churn
+ # across the timeline instead of convoying, and surfaces order-dependent bugs.
+ # Seeded for replay (HIL_SHUFFLE_SEED). Unique per-example PIDs re-enumerate
+ # between examples; only the variant boundary can repeat one.
run_list = list(test_list)
if shuffle_seed is not None and len(run_list) > 1:
random.Random(f'{shuffle_seed}:{name}:{vname}').shuffle(run_list)
@@ -1572,23 +1788,33 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
run_list[0], run_list[-1] = run_list[-1], run_list[0]
cells = {}
if run_list and run_list[0] == prev_last and not skip_flash:
- # Same example (same PID) still repeats across the boundary: a one-test
- # list (the common case for a -bt scoped run) leaves nothing to swap
- # with. Park on board_test first - it disables the board's USB, so the
- # PID goes away and the next flash must re-enumerate to be seen.
+ # Same example (same PID) still repeats across the boundary (a one-test
+ # -bt run has nothing to swap with). Park on board_test first: it disables
+ # the board's USB, so the next flash must re-enumerate to be seen.
t_park = time.monotonic()
- park_ec, park_status, _ = test_example(board, vname, 'device/board_test')
+ # _should_park, same as the teardown park: this is attempt 0, so
+ # test_example's retry guard does not stop it flashing into a poisoned node
+ park_ec, park_status, _ = (
+ test_example(board, vname, 'device/board_test') if _should_park(skip_flash)
+ else (0, 'skip', None))
if park_ec or park_status == 'skip':
- # Boundary not cleared: the previous variant's device may still be
- # enumerated under the same PID, so this variant's tests could pass
- # against its firmware. Skip them - a false green proves nothing and
- # is worse than a gap - and record the boundary itself as the failure
- # (a visible ❌ cell, mirroring the board-lock row above) so the report
- # matches the exit code instead of rendering all-green.
- why = 'no board_test binary' if park_status == 'skip' else 'park flash failed'
+ # Boundary not cleared: the previous variant may still be enumerated
+ # under the same PID, so this variant's tests could pass against ITS
+ # firmware. Skip them and record the boundary as the failure, so the
+ # report matches the exit code instead of rendering all-green.
+ # A 'skip' here has two very different causes: no board_test build, or
+ # _should_park refusing to flash a WEDGED board. Reporting the latter as
+ # a missing binary sends the operator hunting a build that exists.
+ wedge_skip = park_status == 'skip' and bool(board_wedged)
+ why = ('the board is wedged' if wedge_skip else
+ 'no board_test binary' if park_status == 'skip' else
+ 'park flash failed')
log_line(f'{vname:40} {"same-PID boundary":30} {STATUS_FAILED}: not cleared ({why}); '
f'skipping {len(run_list)} test(s) on this variant')
- err_count += 1
+ # the wedge already charged its own error through test_device_usbtest;
+ # charging again would double-count one incident in the exit code
+ if not wedge_skip:
+ err_count += 1
cells[BOUNDARY_CELL] = 'fail'
# blaming run_list[0] would re-run an innocent test that then passes,
# leaving the boundary unretested; re-run the whole board instead
@@ -1601,31 +1827,72 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
prev_last = run_list[-1]
t_variant = time.monotonic()
for test in run_list:
+ if board_wedged:
+ # Do NOT flash through a poisoned node: each attempt enumerates into
+ # it, blocks uninterruptibly and leaves another stray behind. Report
+ # the skip so the cell is not mistaken for a pass.
+ cells[test] = f'{REPORT_CELL["skip"]} board wedged'
+ # ...and re-run the WHOLE board, like the boundary-failure path above:
+ # these tests never executed, so naming them individually in the .failed
+ # spec is not enough -- an --accumulate re-run that fixes only the wedged
+ # test would merge a green cell over it and leave these skips standing
+ # from the earlier attempt, forever, under a green job.
+ board_wide_fail = True
+ continue
ec, status, metric = test_example(board, vname, test)
err_count += ec
cells[test] = metric if metric else status
if ec > 0:
failed_tests.append(test)
+ if board_wedged:
+ log_line(f'{vname:40} SKIPPING the rest of this board: {board_wedged}; '
+ f'flashing through the poisoned node would add a stray per test')
dur = f'{time.monotonic() - t_variant:.0f}s' if run_list and not partial else None
rows.append((vname, cells, dur))
- # board duration excludes the teardown park-flash below; a partial (filtered)
- # run reports 0.0 so it never overwrites a cached full-run duration
+ # excludes the teardown park-flash below; a partial (filtered) run reports 0.0 so
+ # it never overwrites a cached full-run duration
t_total = 0.0 if partial else time.monotonic() - t_board
- # flash board_test last to disable board's usb (skipped when --skip-flash is set);
- # this is teardown/park, not a test — not recorded in the report
- if not skip_flash:
+ # park: flash board_test last to disable the board's usb; teardown, not a test,
+ # so it is not recorded in the report.
+ #
+ # NOT on a wedged board: the latch has just skipped every remaining test precisely
+ # because flashing through a D-state-held node blocks, survives SIGKILL and leaves
+ # a stray -- and this park is a flash like any other. test_example's own guard does
+ # not stop it (that one only suppresses RETRIES, and this is attempt 0), so the
+ # containment path would add the very stray it exists to prevent.
+ if _should_park(skip_flash):
test_example(board, variants[0]['name'], 'device/board_test')
- return name, err_count, [] if board_wide_fail else sorted(set(failed_tests)), rows, t_total
+ # Sweep HERE, not in main()'s finally: maxtasksperchild=1 retires this process as
+ # soon as it returns, reparenting anything it spawned to init and off the pool's
+ # ppid tree, so the main-side sweep walks fresh idle workers and finds nothing.
+ # Measured: 4 tasks, zero overlap, sweep 0, all 4 strays alive.
+ stray = hil_health.kill_own_children()
+ swept = True
+
+ # LAST fields: whether this worker ran out of bounded-read budget, and what it could
+ # not kill. Only the worker can answer either -- the blindness latch is
+ # process-global and this is a separate process -- and the result tuple already
+ # crosses back, so no Manager round-trip.
+ return (name, err_count, [] if board_wide_fail else sorted(set(failed_tests)),
+ rows, t_total, hil_util.sysfs_blind(), stray)
finally:
+ # A raise skips the sweep above, and maxtasksperchild=1 retires this process
+ # immediately afterwards -- reparenting its flasher to init and erasing the ppid
+ # link, so main's sweep cannot see it either. The count cannot reach the report on
+ # this path (there is no result tuple), but the KILL still frees the probe.
+ if not swept:
+ try:
+ hil_health.kill_own_children()
+ except Exception as se: # noqa: BLE001 - never mask the original failure
+ print(f'warning: stray sweep failed: {type(se).__name__}: {se}', flush=True)
if _lock_fh:
try:
- # clear our pid record before dropping the flock: this worker
- # process lives on (pool reuse), so a stale record would make
- # hil_lock.py's pid-liveness checks report a freed board as
- # still locked for the rest of the run
+ # clear our pid record before dropping the flock: this worker process
+ # lives on (pool reuse), so a stale record would make hil_lock's
+ # pid-liveness checks report a freed board as locked for the rest of the run
_lock_fh.truncate(0)
except OSError:
pass
@@ -1634,10 +1901,9 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
REPORT_MD = 'hil_report.md'
REPORT_JSON = 'hil_report.json'
-# controller hints learned from previous runs: uid -> {'name', 'pci', 'duration'}. Only
-# 'pci' is consumed (dispatch order and first-flash budgeting, never battery
-# serialization); name/duration are informational. PCI addresses are boot-stable (bus
-# numbers are not), so the cache survives reboots and only goes stale on re-cabling.
+# controller hints from previous runs: uid -> {'name', 'pci', 'duration'}. Only 'pci' is
+# consumed (dispatch order and first-flash budgeting, never battery serialization). PCI
+# addresses are boot-stable, so the cache survives reboots and goes stale on re-cabling.
CONTROLLER_CACHE = Path.home() / '.cache' / 'tinyusb-hil' / 'controller_cache.json'
@@ -1662,8 +1928,8 @@ def render_matrix(rows_all: list) -> str:
if not seen:
return 'No tests were run.'
- # metric-bearing columns pinned first (usbtest score, throughput, explorer read speed),
- # the rest alphabetical by bare test name: stable regardless of the (shuffled) execution order
+ # metric-bearing columns pinned first, the rest alphabetical: stable regardless of the
+ # shuffled execution order
pinned = ['usbtest', 'cdc_msc_throughput', 'msc_file_explorer', 'msc_file_explorer_freertos']
def col_key(t):
@@ -1694,9 +1960,8 @@ def render_matrix(rows_all: list) -> str:
sep = '| ' + '-' * board_w + ' | ' + ' | '.join(':' + '-' * (w - 2) + ':' for w in col_w) + ' |'
body = [line(lbl, vals) for lbl, vals in rows_vals]
- # tally run cells (blank/not-run cells are absent from the dicts). A cell is a bare status
- # ('pass'/'fail'/'skip') or a metric string that carries its own icon (e.g. "❌ 29/30" is a
- # fail, "✅ 30/30" / "✅ CDC …" a pass), so classify by the leading icon.
+ # tally run cells (not-run cells are absent from the dicts). A cell is a bare status or
+ # a metric string carrying its own icon ("❌ 29/30"), so classify by the leading icon.
def cell_kind(v):
if v == 'fail' or (isinstance(v, str) and v.startswith(REPORT_CELL['fail'])):
return 'fail'
@@ -1713,7 +1978,119 @@ def render_matrix(rows_all: list) -> str:
return summary + '\n\n' + '\n'.join([header, sep] + body)
-def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = '') -> str:
+def _write_failed_spec(failed_fname: Path, report_dir: Path, mret: list) -> None:
+ """Re-run spec: only the failed boards (-b), each restricted to its own failed tests
+ (-bt); a board with failures but no test list re-runs entirely.
+
+ Shared with the pool-guard path, which feeds it the boards that never reported. That
+ path used to leave this unwritten -- and a fresh run has already unlinked it -- so
+ build.yml's "Get re-run spec" step found nothing and the GitHub re-run repeated the
+ whole fleet to find the one board that wedged."""
+ parts = ['--accumulate']
+ for name, err, fts, *_ in mret:
+ if err > 0:
+ parts.append(f'-b {name}')
+ if fts:
+ parts.append(f'-bt {name}:{",".join(fts)}')
+ if len(parts) > 1: # build-only failures have no boards to re-run
+ report_dir.mkdir(parents=True, exist_ok=True)
+ with failed_fname.open('w') as f:
+ f.write(' '.join(parts))
+ else:
+ failed_fname.unlink(missing_ok=True)
+
+
+class PoolDrainTimeout(MpTimeoutError):
+ """Guard expiry, carrying the rows that DID finish.
+
+ They ride on the exception because the raise is the containment path: losing them here
+ is what map_async did, and what the drain exists to stop.
+ """
+
+ def __init__(self, finished: list):
+ super().__init__()
+ self.finished = finished
+
+
+def drain_pool(it, boards: list, deadline: float, out: list | None = None) -> list:
+ """Collect imap_unordered results against ONE deadline. Returns the finished rows.
+
+ Raises PoolDrainTimeout (carrying those same rows) when the deadline passes with boards
+ still in flight -- the caller keeps them, names only what is missing, and writes a
+ re-run spec covering just those.
+
+ A function, not an inline loop, so the tests can call THIS instead of a copy of it: the
+ loop's previous test built its own ThreadPool and its own drain and asserted on those,
+ so deleting the real one outright kept the suite green.
+ """
+ # `out` is the CALLER's list: a worker that raises something other than a timeout
+ # (get_serial_dev on a dropped adapter, a Manager EOFError) propagates bare, and a
+ # local accumulator would take every finished board with it -- the exact loss the
+ # drain replaced map_async to prevent.
+ mret: list = out if out is not None else []
+ for _ in boards:
+ left = deadline - time.monotonic()
+ if left <= 0:
+ raise PoolDrainTimeout(mret)
+ try:
+ mret.append(it.next(timeout=left))
+ except MpTimeoutError:
+ raise PoolDrainTimeout(mret) from None
+ return mret
+
+
+def _should_park(skip_flash: bool) -> bool:
+ """Flash the teardown park (device/board_test, to switch the DUT's USB off)?
+
+ Not on a wedged board. The latch has just skipped every remaining test precisely
+ because flashing through a D-state-held node blocks, survives SIGKILL and leaves a
+ stray -- and the park is a flash like any other. test_example's own guard does not stop
+ it either: that one only suppresses RETRIES, and the park is always attempt 0. So the
+ containment path would end by adding the very stray it exists to prevent.
+ """
+ return not skip_flash and not board_wedged
+
+
+def _stray_note(mret: list) -> str:
+ """Name the strays the workers could not kill, for the report banner.
+
+ Summed from the result tuples rather than computed in main()'s finally: that finally
+ runs AFTER accumulate_report on both abort paths, so a banner appended there was
+ written to a variable nobody read again.
+ """
+ dirty = [(r[0], r[6]) for r in mret if len(r) > 6 and r[6]]
+ if not dirty:
+ return ''
+ total = sum(n for _, n in dirty)
+ return (f'> **Rig dirty.** {total} process(es) survived SIGKILL and still hold a probe '
+ f'or usbfs node into the next job: '
+ f'{", ".join(f"{b} ({n})" for b, n in dirty)}.\n')
+
+
+def _blind_note(mret: list) -> str:
+ """Name the boards whose worker went blind, for the report banner.
+
+ A blind worker answers SYSFS_UNKNOWN for every attribute, so its "device not found" is
+ "could not tell". That already reaches the log and the per-cell failure text, but the
+ TABLE is what gets quoted -- and a red cell there is read as a broken board. Seen live
+ (run 31794359407): four workers blind, several cells red because of it, and a report
+ that said nothing.
+
+ Per-board, not global: maxtasksperchild=1 gives every board a fresh worker, so a board
+ that ran on a healthy one is not smeared by a neighbour's wedge. Rows synthesised by
+ the timeout path are 5 fields wide and have nothing to report.
+ """
+ blind = [r[0] for r in mret if len(r) > 5 and r[5]]
+ if not blind:
+ return ''
+ return (f'> **Not all verdicts are evidence.** {len(blind)} board(s) ran on a worker '
+ f'that went blind on sysfs -- too many bounded reads stranded on a wedged '
+ f'device -- so "not found" from them means "could not tell": '
+ f'{", ".join(blind)}. See the usb-kernel-recover skill.\n')
+
+
+def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = '',
+ banner: str = '') -> str:
"""Merge this run's results into hil_report.json in report_dir, then (re)write
the markdown matrix to hil_report.md. `fresh` (a first run, no --accumulate)
starts a new report; otherwise a re-run accumulates so boards/tests that
@@ -1721,29 +2098,34 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = ''
board filter, if any, so a scoped table is not mistaken for a full one.
Returns the md."""
acc = {} # ordered {row_label: [cells dict, duration str|None]}
+ prior_banner = ''
jpath = report_dir / REPORT_JSON
if not fresh and jpath.is_file():
try:
saved = json.loads(jpath.read_text())
- # CI keys the report dir by run id, so the sidecar can only have been
- # written by an earlier attempt of the same run
+ # CI keys the report dir by run id, so the sidecar is from an earlier attempt
for entry in saved.get('rows', []):
acc[entry['board']] = [dict(entry['cells']), entry.get('duration')]
+ # ... and so is the caveat those cells were collected under. A rerun on a rig
+ # that has since recovered contributes no banner, and the .failed spec reruns
+ # only FAILURES -- so the earlier attempt's passes are never re-earned and
+ # would be published as clean results of a rig that was not.
+ prior_banner = saved.get('banner', '')
except (ValueError, KeyError, TypeError):
pass # corrupt/old sidecar: start fresh
- # merge this run: current cells override prior for boards/tests that ran; a filtered
- # run reports duration None, keeping the previous full-run value
- for name, _, _, rows, _ in mret:
+ # current cells override prior for boards/tests that ran; a filtered run reports
+ # duration None, keeping the previous full-run value
+ for name, _, _, rows, *_ in mret:
if rows and not any('board-locked' in cells for _, cells, _ in rows):
- # board ran for real this time: clear a stale lock-failure cell
- # (its row is keyed by board name; test rows may be variant names)
+ # board ran for real: clear a stale lock-failure cell (its row is keyed by
+ # board name; test rows may be variant names)
stale = acc.get(name)
if stale is not None:
stale[0].pop('board-locked', None)
if not stale[0]:
- # variant-keyed boards never repopulate the board-name row —
- # drop it or it renders as a blank ghost row
+ # variant-keyed boards never repopulate the board-name row, so drop it
+ # or it renders as a blank ghost row
del acc[name]
for row_label, cells, dur in rows:
row = acc.setdefault(row_label, [{}, None])
@@ -1756,18 +2138,100 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = ''
row[1] = dur
report_dir.mkdir(parents=True, exist_ok=True)
+ # by LINE, deduped: attempts repeat the same caveat far more often than they add a new
+ # one, and three copies of the D-state note reads as three incidents
+ seen, merged = set(), []
+ for line in (prior_banner + banner).splitlines():
+ if line.strip() and line not in seen:
+ seen.add(line)
+ merged.append(line)
+ banner = '\n'.join(merged) + '\n' if merged else ''
jpath.write_text(json.dumps({'rows': [{'board': k, 'cells': c, 'duration': d}
- for k, (c, d) in acc.items()]}, indent=2) + '\n')
+ for k, (c, d) in acc.items()],
+ 'banner': banner}, indent=2) + '\n')
md = render_matrix([(k, c, d) for k, (c, d) in acc.items()])
if scope:
- # a scoped run's small table is otherwise indistinguishable from a full one,
- # and it replaces the previous full table in the sticky PR comment
+ # a scoped run's small table is otherwise indistinguishable from a full one, and
+ # it replaces the previous full table in the sticky PR comment
md = f'_Scoped run: {scope}. Boards/tests not listed were not run._\n\n' + md
+ # LAST, so it is outermost: a rig-health caveat outranks the table AND the scope note,
+ # and the top of the report is where hil/SKILL.md tells the agent to look for it.
+ if banner:
+ md = banner + '\n' + md
(report_dir / REPORT_MD).write_text(md + '\n', encoding='utf-8')
return md
+# containment paths print through hil_health._p: stdout may already be a dead pipe (a
+# dropped ssh session), and a BrokenPipeError there would skip os._exit
+_p = hil_health._p
+
+
+def _abandon_exit(pool, mgr, abandoned: bool, err_count: int,
+ report: Path | None = None) -> None:
+ """Free the runner when the pool could not be shut down. Returns only if not abandoned.
+
+ Must run even while an exception is propagating: multiprocessing's atexit handler
+ SIGTERMs its daemon workers (ignored in uninterruptible sleep) and then join()s them
+ with NO timeout, so an abandoned pool plus any raise between the pool's finally and
+ here hangs the interpreter until the job ceiling kills it. Reproduced: rc=124 at 25s
+ with SIGTERM-ignoring workers standing in for D state."""
+ if not abandoned:
+ return
+ try:
+ if sys.exc_info()[0] is not None:
+ # os._exit below discards the traceback, and this is often the only place the
+ # real failure would ever be printed
+ traceback.print_exc()
+ except OSError:
+ pass
+ # Word this on evidence: shutdown_pool also returns False when terminate() RAISES, and
+ # a live worker after terminate() is what distinguishes a wedge from a harness bug.
+ # Count WORKERS only -- _pool_procs appends the Manager, our own healthy child, so
+ # including it made n >= 1 always and the harness-error branch unreachable. It is killed
+ # separately: os._exit skips its finalizer, and orphaned it holds the runner's stdout.
+ n = hil_health.kill_pool_children(pool)
+ hil_health.kill_pool_children(None, mgr)
+ if n:
+ _p(f'HIL worker pool would not terminate ({n} worker(s) still live, '
+ f'uninterruptible); SIGKILLed them and abandoned the rest to free the '
+ f'runner. Boards held by any leaked worker stay locked until the host is '
+ f'power-cycled.', flush=True)
+ else:
+ _p('HIL worker pool shutdown failed but left no live worker behind, so this is '
+ 'a harness error rather than a wedged rig -- see the Pool.terminate() '
+ 'warning above. Exiting early anyway to free the runner; no board should '
+ 'stay locked.', flush=True)
+ # A report already written by accumulate_report says nothing about the abandon, and a
+ # green table under a red job is how an agent ends up pasting it as this run's result.
+ # Prepend the caveat; best-effort, never at the cost of exiting.
+ if report is not None:
+ try:
+ if report.exists():
+ # utf-8 explicitly (the cells are ✅/❌/⚪) and catch ValueError too: a torn
+ # report or a LANG=C locale raises UnicodeDecodeError -- NOT an OSError --
+ # straight past os._exit, stranding the runner.
+ body = report.read_text(encoding='utf-8', errors='replace')
+ # Only when no banner is there yet, searched anywhere in the head rather
+ # than at char 0: write_timeout_report's banner must stay FIRST (its table
+ # is a PREVIOUS attempt's) and it puts the rig-health quote above itself.
+ if '**HIL run ab' not in body[:2000]:
+ report.write_text(
+ '**HIL run abandoned: the worker pool would not shut down.** The '
+ 'table below was collected before the abandon; treat board '
+ 'results as unverified.\n\n' + body, encoding='utf-8')
+ except (OSError, ValueError):
+ pass
+ try:
+ sys.stdout.flush()
+ except OSError:
+ pass
+ # Clamped: os._exit takes a status byte, so err_count == 256 would truncate to 0 and
+ # report a failing, abandoned run as green.
+ os._exit(min(err_count, 125) if err_count else 1)
+
+
def main() -> None:
"""
Hardware test on specified boards
@@ -1797,14 +2261,21 @@ def main() -> None:
help='Per-board test list as BOARD:test1,test2 (overrides -t for that board); repeat for multiple boards')
parser.add_argument('-B', '--build-dir', default='cmake-build', help='Build folder name (default: cmake-build)')
parser.add_argument('--build', action='store_true', help='Build firmware for selected boards with cmake before running tests')
- parser.add_argument('-r', '--retry', type=int, default=3, help='Retry count for failed tests (default: 3)')
+ # default 1, not 3: the pool guard is a FLAT 3600s that does not scale with max_retry,
+ # and one usbtest test at default 3 can burn 1530s of it (510s outer x3) for a single
+ # board. Every CI caller already pins --retry 1; the bare invocations in the hil skill
+ # and hil-validate.js run against the same one-slot rig and used to inherit 3.
+ parser.add_argument('-r', '--retry', type=int, default=1, help='Retry count for failed tests (default: 1)')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
args = parser.parse_args()
+ if args.retry < 1:
+ # 0 would make every test loop body never run: all-red cells, exit 0
+ parser.error('--retry must be >= 1')
config_file = Path(args.config_file)
boards = args.board
verbose = args.verbose
- hil_flash.verbose = args.verbose
+ hil_util.verbose = args.verbose
test_only = args.test_only
for entry in args.board_test:
bname, _, tnames = entry.partition(':')
@@ -1832,6 +2303,32 @@ def main() -> None:
config_boards = [e for e in config['boards'] if e['name'] in boards]
config_boards = [e for e in config_boards if e['flasher']['name'] not in args.exclude_flasher
and (not args.flasher or e['flasher']['name'] in args.flasher)]
+ if not config_boards:
+ # same reason the unknown -b board exits 1: 'No tests were run.' with rc 0 reads as
+ # a green HIL leg, so a roster edit emptying a leg's filter stops testing silently
+ msg = (f'No boards left after the flasher filter (--flasher '
+ f'{args.flasher or "-"}, --exclude-flasher {args.exclude_flasher or "-"})')
+ print(msg, flush=True)
+ # loud AND leaving evidence: exiting with no report at all lets the PR comment
+ # keep the previous push's stale table under a red job
+ try:
+ rd = Path(os.environ.get('HIL_REPORT_DIR', '.'))
+ rd.mkdir(parents=True, exist_ok=True)
+ (rd / REPORT_MD).write_text(f'**HIL run selected no boards.** {msg}\n',
+ encoding='utf-8')
+ except OSError:
+ pass
+ sys.exit(1)
+
+
+ # Before the build: the probe needs nothing from it, and the annotation is more useful
+ # early than after a multi-board cmake build has been paid for.
+ # One line, not a probe: a D-state pid at start-up is a hint for whoever reads a red
+ # cell, never a reason to refuse the run. hil_pool_check does diagnosis.
+ note = hil_health.d_state_note()
+ if note:
+ log_line(f'rig note: {note}')
+ health_banner = f'> **Rig note.** {note}. Not a fault on its own -- a healthy testusb sits in D state for most of every case.\n' if note else ''
build_err = 0
if args.build:
@@ -1848,26 +2345,24 @@ def main() -> None:
print(f'Build phase done: {build_err} failed')
print('-' * 30)
- # HIL report sidecar (hil_report.json/.md) and the .failed re-run spec live in
- # report_dir (CI keys it by run id, so it persists across run attempts but is
- # private to one run). A full run starts fresh; a re-run (--accumulate, which
- # the generated .failed spec always starts with) merges so already-passed
- # boards/tests are preserved. Clear prior state up front on a fresh run so a
- # crash mid-run can't leave a stale report or re-run spec for a retry.
- # -bt alone is not a re-run marker: PR-scoped first attempts pass -bt too.
+ # The report sidecar and the .failed re-run spec live in report_dir (CI keys it by run
+ # id: persistent across attempts, private to one run). A full run starts fresh; a re-run
+ # (--accumulate, which .failed always starts with) merges so already-passed boards
+ # survive. -bt alone is not a re-run marker.
report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.'))
failed_fname = report_dir / (config_file.name + '.failed')
fresh = not args.accumulate
- if fresh:
- report_dir.mkdir(parents=True, exist_ok=True)
- for f in (REPORT_JSON, REPORT_MD):
- (report_dir / f).unlink(missing_ok=True)
- failed_fname.unlink(missing_ok=True)
+ # The unlink is DEFERRED to inside the pool try/except below: wiping here leaves
+ # Manager() and Pool() running with the old report gone and no report-writing path
+ # armed, so an EAGAIN/ENOMEM on fork gives CI an EMPTY report dir with no reason.
seed = os.getenv('HIL_SHUFFLE_SEED') or str(int(time.time()))
log_line(f'test-order shuffle seed: {seed} (HIL_SHUFFLE_SEED={seed} to replay); '
f'flash/usbtest parallel per controller: {hil_lock.FLASH_PARALLEL}/{hil_lock.USBTEST_PARALLEL}; '
- f'enum timeout first/retry: {ENUM_TIMEOUT}/{ENUM_TIMEOUT_RETRY}s')
+ f'enum timeout first/retry: {ENUM_TIMEOUT}/{ENUM_TIMEOUT_RETRY}s; '
+ # all three are env-tunable, so a run that dies on the guard is otherwise
+ # unattributable from the log alone
+ f'pool guard: {POOL_TIMEOUT}s')
hints = {}
try:
@@ -1882,94 +2377,232 @@ def main() -> None:
config_boards = schedule_boards(config_boards, hints_by_uid)
log_line('dispatch order: ' + ', '.join(b['name'] for b in config_boards))
- mgr = Manager()
- cmap = mgr.dict()
- initargs = (Lock(), seed,
- [Semaphore(hil_lock.USBTEST_PARALLEL) for _ in range(hil_lock.CONTROLLER_SLOTS)],
- [Semaphore(hil_lock.FLASH_PARALLEL) for _ in range(hil_lock.CONTROLLER_SLOTS)],
- cmap, Lock(), hints_by_uid)
- with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=initargs) as pool:
- async_ret = pool.map_async(test_board, config_boards)
+ # Bound BEFORE the try so the finally can name them whatever failed: Pool() forks, and
+ # the EAGAIN/ENOMEM the wipe comment below worries about is most likely to come from
+ # that fork -- after a convoy, where every stranded read holds a thread and an fd. Left
+ # outside, an OSError there escaped with mgr LIVE and `pool` unbound, so no report was
+ # written and the interpreter unwound into multiprocessing's unbounded atexit join.
+ pool = mgr = cmap = None
+ # Defined before the pool so _abandon_exit always has a value: a raise before
+ # `err_count = build_err + ...` would turn the containment path into a NameError.
+ err_count = build_err
+ # Fail CLOSED: only a shutdown_pool() that actually returned True clears this, and the
+ # assignment sits at the END of the inner finally, so anything raising before it
+ # (kill_worker_children, a BrokenPipeError from its print) leaves _abandon_exit armed.
+ pool_abandoned = True
+ # BEFORE Manager()/Pool(), not inside the try: hil_ci.sh reuses a persistent REMOTE_DIR
+ # and scp's the report back unconditionally, so if a fork failure (OSError/EAGAIN right
+ # after a convoy -- the case this whole block guards) skipped the wipe, the finally's
+ # _abandon_exit would prepend "HIL run abandoned" to the PREVIOUS run's table and
+ # publish last night's board results as this run's. Nothing is live yet here, so an
+ # OSError from the wipe itself just exits with its traceback -- it cannot strand the
+ # interpreter in multiprocessing's unbounded atexit join, which is what deferring it
+ # was protecting against.
+ if fresh:
+ report_dir.mkdir(parents=True, exist_ok=True)
+ for f in (REPORT_JSON, REPORT_MD):
+ (report_dir / f).unlink(missing_ok=True)
+ failed_fname.unlink(missing_ok=True)
+ try:
+ mgr = Manager()
+ cmap = mgr.dict()
+ initargs = (Lock(), seed,
+ hil_lock.make_permit_sems(Semaphore, hil_lock.USBTEST_PARALLEL),
+ hil_lock.make_permit_sems(Semaphore, hil_lock.FLASH_PARALLEL),
+ cmap, Lock(), hints_by_uid)
+ # maxtasksperchild=1: the sysfs blindness latch is process-global and permanent
+ # (no decrement anywhere -- see hil_util.SYSFS_STUCK_MAX), so a worker that goes
+ # blind on ONE wedged board would report 0/30 and "probe missing" for the 2-3
+ # healthy boards it picked up afterwards. A fresh worker per board confines the
+ # damage to the board that caused it; the extra fork is noise against a
+ # flash+test cycle.
+ pool = Pool(processes=os.cpu_count() or 1, initializer=init_worker,
+ initargs=initargs, maxtasksperchild=1)
+ # OUTER: encloses the pool block too, not just the reporting below. An exception
+ # escaping async_ret.get() (a worker exception, a Ctrl-C) runs the pool finally and
+ # then propagates straight out of main(); with _abandon_exit in a sibling try it
+ # was never reached.
try:
- mret = async_ret.get(timeout=POOL_TIMEOUT)
- except MpTimeoutError:
- pool.terminate()
- pool.join()
- raise RuntimeError(f'HIL worker pool timed out after {POOL_TIMEOUT}s')
+ # imap_unordered, NOT map_async: map_async is all-or-nothing, so a guard expiry
+ # threw away every board that had already finished -- up to a worker-width of
+ # completed rig time -- and left the re-run spec unwritten, so CI re-tested all
+ # ~26 boards to find the one that wedged. Draining as results arrive keeps what
+ # finished and names only what was still in flight.
+ it = pool.imap_unordered(test_board, config_boards)
+ mret = []
+ deadline = time.monotonic() + POOL_TIMEOUT
+ try:
+ mret = drain_pool(it, config_boards, deadline, out=mret)
+ except MpTimeoutError as te:
+ mret = te.finished
+ stuck = [b['name'] for b in config_boards
+ if b['name'] not in {r[0] for r in mret}]
+ # The re-run spec FIRST and before the raise: a fresh run already unlinked
+ # it, so leaving it unwritten is what made the GitHub re-run repeat the
+ # whole fleet. Only the boards that never reported go in it.
+ _write_failed_spec(failed_fname, report_dir,
+ [(n, 1, [], None, 0) for n in stuck]
+ + [r for r in mret if r[1] > 0])
+ # Then the report, with the rows that DID finish, before anything that can
+ # block. Then RAISE into the ONE containment path: the inner finally runs
+ # the ordered sweep (kill_worker_children BEFORE terminate, or a reaped
+ # worker's flasher reparents out of reach), the outer one os._exit's.
+ banner = (f'**HIL run abandoned: worker pool timed out after '
+ f'{POOL_TIMEOUT}s.** {len(mret)} board(s) below finished and '
+ f'are this run\'s; {len(stuck)} never reported and are NOT in '
+ f'the table: {", ".join(stuck)}. Re-run covers those.\n')
+ try:
+ accumulate_report(mret, report_dir, fresh, '',
+ health_banner + _blind_note(mret)
+ + _stray_note(mret) + banner)
+ except Exception as rerr: # noqa: BLE001 - the raise below must still happen
+ # FALL BACK, do not just warn: accumulate_report can raise on an
+ # unwritable/root-owned report dir or a torn JSON, and _abandon_exit
+ # only PREPENDS to a report that exists. Without this the artifact
+ # upload finds nothing (if-no-files-found: ignore) and the sticky PR
+ # comment keeps the previous push's green table under a red job.
+ print(f'warning: partial report failed: {type(rerr).__name__}: {rerr}; '
+ f'falling back to the board list', flush=True)
+ try:
+ hil_health.write_timeout_report(
+ report_dir, [b for b in config_boards
+ if b['name'] in stuck], POOL_TIMEOUT, REPORT_MD,
+ prefix=health_banner)
+ except Exception as re2: # noqa: BLE001
+ print(f'warning: fallback report failed too: '
+ f'{type(re2).__name__}: {re2}', flush=True)
+ _p(f'HIL worker pool timed out after {POOL_TIMEOUT}s; sweeping and '
+ f'shutting it down (abandoning it if a worker is unkillable)',
+ flush=True)
+ raise RuntimeError(f'HIL worker pool timed out after {POOL_TIMEOUT}s')
+ except Exception as e:
+ # A worker RAISED -- e.g. a flasher adapter dropping off the bus makes
+ # get_serial_dev raise in the worker's flash section, which no per-test
+ # handler guards. Same treatment as the timeout path: the drain means
+ # `mret` already holds every board that finished, so keep those rows and
+ # name only the ones still in flight. (Under map_async they were all lost,
+ # which is what the old banner here claimed.)
+ done = {r[0] for r in mret}
+ stuck = [b['name'] for b in config_boards if b['name'] not in done]
+ _write_failed_spec(failed_fname, report_dir,
+ [(n, 1, [], None, 0) for n in stuck]
+ + [r for r in mret if r[1] > 0])
+ banner = (f'**HIL run aborted: a worker raised {type(e).__name__}: {e}.** '
+ f'{len(mret)} board(s) below finished and are this run\'s; '
+ f'{len(stuck)} did not report: {", ".join(stuck)}.\n')
+ try:
+ accumulate_report(mret, report_dir, fresh, '',
+ health_banner + _blind_note(mret)
+ + _stray_note(mret) + banner)
+ except Exception as re2: # noqa: BLE001 - the raise below must still happen
+ print(f'warning: partial report failed: {type(re2).__name__}: {re2}',
+ flush=True)
+ raise
- err_count = build_err + sum(e[1] for e in mret)
- # generate the re-run spec if anything failed: run ONLY the failed boards (-b),
- # each restricted to its own failed tests (-bt); a board with failures but no
- # test list (e.g. board-locked) re-runs entirely. --accumulate preserves the
- # already-passed cells in the report.
- parts = ['--accumulate']
- for name, err, fts, _, _ in mret:
- if err > 0:
- parts.append(f'-b {name}')
- if fts:
- parts.append(f'-bt {name}:{",".join(fts)}')
- if len(parts) > 1: # build-only failures have no boards to re-run
- report_dir.mkdir(parents=True, exist_ok=True)
- with failed_fname.open('w') as f:
- f.write(' '.join(parts))
- else:
- failed_fname.unlink(missing_ok=True)
+ err_count = build_err + sum(e[1] for e in mret)
+ _write_failed_spec(failed_fname, report_dir, mret)
+ finally:
+ # Not `with Pool(...)`: its __exit__ joins the workers unbounded, hanging on
+ # any worker in uninterruptible sleep. shutdown_pool bounds the same terminate()
+ # by a grace period, so the pool is NOT cleanly closed/joined when it returns
+ # False. Record the outcome but never exit here: the report below is the only
+ # record of a run that otherwise passed.
+ #
+ # Same ordering as the timeout path: what the workers spawned must be
+ # snapshotted and killed while its parent is alive, or terminate() reparents it
+ # out of reach.
+ #
+ # Both calls must stay guarded: a raise here skips accumulate_report(), so a run
+ # whose boards ALL passed publishes an empty report dir -- and both can raise
+ # for reasons unrelated to the results. pool_abandoned stays fail-CLOSED, so
+ # _abandon_exit still arms.
+ try:
+ # Still worth running for the TIMEOUT path, where the workers are
+ # genuinely stuck mid-task and their children are still reachable through
+ # the pool's ppid tree. On the normal path every worker has already swept
+ # its own (kill_own_children) and retired, so this finds nothing.
+ #
+ # No banner from here: this finally runs AFTER accumulate_report on both
+ # abort paths, so anything appended to health_banner now is written to a
+ # variable nobody reads again. The report gets its count from the result
+ # tuples instead, via _stray_note.
+ hil_health.kill_worker_children(pool, mgr)
+ except Exception as e:
+ print(f'warning: worker-child sweep failed: {type(e).__name__}: {e}',
+ flush=True)
+ try:
+ pool_abandoned = not hil_health.shutdown_pool(pool)
+ except Exception as e:
+ print(f'warning: pool shutdown failed: {type(e).__name__}: {e}', flush=True)
- # refresh controller hints: pci resolved this run, plus board durations when the
- # full test list ran (a -t/-bt filtered run would understate the board's real cost)
- try:
- if PROFILE:
- # debug snapshot of the run's live uid->PCI / PCI->slot resolutions
- report_dir.mkdir(parents=True, exist_ok=True)
- with (report_dir / 'hil_profile_ctrl.json').open('w') as f:
- json.dump(dict(cmap), f, indent=1, sort_keys=True)
- uid_of = {b['name']: b['uid'] for b in config['boards']}
- for name, _, _, _, dur in mret:
- uid = uid_of.get(name)
- if uid is None:
- continue
- h = dict(hints.get(uid) or {})
- h['name'] = name # informational: cache is keyed by uid
- h['pci'] = cmap.get(f'uid:{uid}') or h.get('pci')
- if dur > 0: # test_board reports 0.0 for filtered (partial) runs
- h['duration'] = round(dur, 1)
- hints[uid] = h
- # merge-on-write: another HIL job (e.g. the esp split) may have finished since
- # our startup read - re-read and overlay only this run's boards so its entries
- # survive, then replace atomically so a concurrent reader never sees a torn file
- merged = {}
+ # refresh controller hints: pci resolved this run, plus durations from full runs
+ # only (a filtered run would understate the board's real cost)
try:
- with CONTROLLER_CACHE.open() as f:
- cur = json.load(f)
- if isinstance(cur, dict):
- merged = {k: v for k, v in cur.items() if isinstance(v, dict)}
- except (OSError, ValueError):
- pass
- merged.update({uid_of[n]: hints[uid_of[n]] for n, *_ in mret if n in uid_of})
- CONTROLLER_CACHE.parent.mkdir(parents=True, exist_ok=True)
- tmp = CONTROLLER_CACHE.with_suffix('.json.tmp')
- with tmp.open('w') as f:
- json.dump(merged, f, indent=1, sort_keys=True)
- tmp.replace(CONTROLLER_CACHE)
- except OSError as e:
- print(f'warning: cannot persist controller hints to {CONTROLLER_CACHE}: {e}')
+ if PROFILE:
+ # debug snapshot of the run's live uid->PCI / PCI->slot resolutions
+ report_dir.mkdir(parents=True, exist_ok=True)
+ with (report_dir / 'hil_profile_ctrl.json').open('w') as f:
+ json.dump(dict(cmap), f, indent=1, sort_keys=True)
+ uid_of = {b['name']: b['uid'] for b in config['boards']}
+ for name, _, _, _, dur, *_ in mret:
+ uid = uid_of.get(name)
+ if uid is None:
+ continue
+ h = dict(hints.get(uid) or {})
+ h['name'] = name # informational: cache is keyed by uid
+ h['pci'] = cmap.get(f'uid:{uid}') or h.get('pci')
+ if dur > 0: # test_board reports 0.0 for filtered (partial) runs
+ h['duration'] = round(dur, 1)
+ hints[uid] = h
+ # merge-on-write: another HIL job (e.g. the esp split) may have finished since
+ # our startup read, so overlay only this run's boards and replace atomically
+ merged = {}
+ try:
+ with CONTROLLER_CACHE.open() as f:
+ cur = json.load(f)
+ if isinstance(cur, dict):
+ merged = {k: v for k, v in cur.items() if isinstance(v, dict)}
+ except (OSError, ValueError):
+ pass
+ merged.update({uid_of[n]: hints[uid_of[n]] for n, *_ in mret if n in uid_of})
+ CONTROLLER_CACHE.parent.mkdir(parents=True, exist_ok=True)
+ tmp = CONTROLLER_CACHE.with_suffix('.json.tmp')
+ with tmp.open('w') as f:
+ json.dump(merged, f, indent=1, sort_keys=True)
+ tmp.replace(CONTROLLER_CACHE)
+ except Exception as e:
+ # Deliberately broad, and it must stay that way: this best-effort refresh makes
+ # Manager proxy RPCs that raise EOFError / BrokenPipeError / RemoteError when
+ # the Manager child has died, none of them OSErrors -- an OSError-only guard let
+ # those skip accumulate_report(). Nothing here is worth the report.
+ print(f'warning: cannot persist controller hints to {CONTROLLER_CACHE}: '
+ f'{type(e).__name__}: {e}')
- # board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout
- # -b/-bt in play means a filtered run (PR selection or a re-run spec): say so in the
- # report, which otherwise looks exactly like a full run that happened to be small
- scoped = sorted(set(args.board) | set(board_test))
- scope = f'{len(scoped)} board(s) — {", ".join(scoped)}' if scoped else ''
- report = accumulate_report(mret, report_dir, fresh, scope)
- print()
- print(report)
- print(f'\nReport written to {(report_dir / REPORT_MD).resolve()}')
- duration = time.time() - duration
- print()
- print("-" * 30)
- print(f'Total failed: {err_count} in {duration:.1f}s')
- print("-" * 30)
- sys.exit(err_count)
+ # board x test result matrix -> hil_report.md (accumulates across re-runs) + stdout.
+ # -b/-bt means a filtered run (PR selection or a re-run spec): say so, or the report
+ # looks exactly like a full run that happened to be small
+ scoped = sorted(set(args.board) | set(board_test))
+ scope = f'{len(scoped)} board(s) — {", ".join(scoped)}' if scoped else ''
+ report = accumulate_report(mret, report_dir, fresh, scope,
+ health_banner + _blind_note(mret)
+ + _stray_note(mret))
+ print()
+ print(report)
+ print(f'\nReport written to {(report_dir / REPORT_MD).resolve()}')
+
+ duration = time.time() - duration
+ print()
+ print("-" * 30)
+ print(f'Total failed: {err_count} in {duration:.1f}s')
+ print("-" * 30)
+ finally:
+ # In the finally, not after: any raise above (accumulate_report sits outside the
+ # OSError handler) would skip the abandon path and unwind into multiprocessing's
+ # unbounded atexit join, hanging the runner.
+ _abandon_exit(pool, mgr, pool_abandoned, err_count, report_dir / REPORT_MD)
+ # Same clamp: exit status is a byte either way, so 256 failures would report green.
+ sys.exit(min(err_count, 125))
if __name__ == '__main__':
diff --git a/test/hil/mtp_test.py b/test/hil/mtp_test.py
new file mode 100644
index 000000000..92d54bdbe
--- /dev/null
+++ b/test/hil/mtp_test.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# One MTP test session for one board, in a disposable process. Every libmtp call is
+# synchronous ctypes in our own address space and blocks in a usbfs ioctl in D state on
+# a wedged device, where not even SIGKILL is delivered — so the session must be
+# something the harness can abandon: hil_test.test_device_mtp runs it under
+# hil_util.run_cmd (killpg + bounded reap, rc 124 on timeout). Imports stay stdlib +
+# pymtp: nothing here may pull in the harness.
+#
+# Exit 0 on a fully passing session; 1 with the failure on stdout/stderr otherwise.
+import argparse
+import ctypes
+import glob
+import hashlib
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+sys.path.append(os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it
+# -- APPEND so PYTHONPATH still wins (the tests steer a fake pymtp that way)
+
+from pathlib import Path
+from pymtp import LIBMTP_DeviceEntry, LIBMTP_RawDevice, MTP
+
+FILE1_EXPECT = b'TinyUSB MTP Filesystem example'
+FILE2_MD5_EXPECT = '40ef23fc2891018d41a05d4a0d5f822f' # md5sum of logo.png
+
+
+# Real paths by default; the offline tests point these at a fixture tree, the same way
+# they steer the pymtp fake through FAKE_PYMTP_*.
+# The one test seam: '' in production, a tmpdir in the offline tests, which mirror the
+# real layout beneath it. This runs as a SUBPROCESS (a libmtp call blocked in a usbfs
+# ioctl hangs its thread forever, so the session must be somewhere killable), and neither
+# monkeypatching nor import shadowing crosses that boundary -- unlike the fake pymtp,
+# which the tests inject through PYTHONPATH alone.
+_ROOT = os.environ.get('HIL_MTP_FAKE_ROOT', '')
+_MARKER_GLOB = f'{_ROOT}/dev/libmtp-*'
+_SYS_USB = Path(f'{_ROOT}/sys/bus/usb/devices')
+_USB_DEV = Path(f'{_ROOT}/dev/bus/usb')
+
+
+def _bounded_read(path, grace: float = 2.0):
+ """Read a sysfs attribute with a wall-clock bound, or return None.
+
+ `serial` is served under the device lock a wedged usbfs ioctl holds, and EVERY MTP DUT
+ is cafe:4017 -- so the vid/pid filter below cannot rule out a wedged NEIGHBOUR, and an
+ unbounded read of its serial would burn this session's whole budget and report a
+ healthy board as wedged. Stdlib only by design (this file never imports the harness),
+ so this is a small local twin of hil_util.read_sysfs.
+ """
+ out = {}
+
+ def _read():
+ try:
+ out['v'] = path.read_text().strip()
+ except OSError:
+ pass
+
+ t = threading.Thread(target=_read, daemon=True)
+ t.start()
+ t.join(grace)
+ return out.get('v')
+
+
+def _ready_marker(uid: str):
+ """(busnum, devnum) of the udev-ready MTP device with this serial, or None.
+
+ /dev/libmtp-<sysname> is published by libmtp-runtime AFTER its synchronous mtp-probe
+ accepts the device, so this set is both small and ready -- unlike a sysfs-wide scan,
+ which races re-enumerations from other boards' jobs. Requires the libmtp-runtime
+ package.
+ """
+ for marker_name in glob.glob(_MARKER_GLOB):
+ marker = Path(marker_name)
+ try:
+ dev = _SYS_USB / marker.name[len('libmtp-'):]
+ # vid/pid first: lock-free descriptor fields, so they rule out every other
+ # device before the `serial` read, which the kernel serves under the device
+ # lock a wedged usbfs ioctl would hold
+ if ((dev / 'idVendor').read_text().strip() != 'cafe'
+ or (dev / 'idProduct').read_text().strip() != '4017'):
+ continue
+ # bounded: this one CAN block, and a wedged neighbour shares the vid/pid above
+ serial = _bounded_read(dev / 'serial')
+ if serial is None or serial.lower() != uid.lower():
+ continue
+ busnum = int((dev / 'busnum').read_text())
+ devnum = int((dev / 'devnum').read_text())
+ node = _USB_DEV / f'{busnum:03d}' / f'{devnum:03d}'
+ if marker.resolve(strict=True) != node or not os.access(node, os.R_OK | os.W_OK):
+ continue
+ return busnum, devnum
+ except (OSError, ValueError):
+ # a marker can vanish while another board flashes: not our device's problem
+ continue
+ return None
+
+
+def _gvfs_unmount(uid: str, deadline: float) -> None:
+ """Drop any gvfs claim on this device, immediately before opening it.
+
+ Called only once the udev marker exists. gvfs claims an MTP device AFTER udev
+ probing, so before the marker there is nothing to unmount: an earlier call is a
+ guaranteed no-op that still forks a process, and it leaves the gap between the
+ unmount and the open unprotected -- the hang this exists to prevent. Per-iteration
+ calls also forked one gio per second of the enumeration budget.
+ """
+ # Popen, not run(timeout=): run's post-timeout reap is an unbounded wait(), and a gio
+ # blocked in D state on a wedged usbfs node does not die on SIGKILL, so run(timeout=2)
+ # can hang for good. Bounded by at most HALF of what is LEFT of our own budget, never
+ # a fixed sub-bound: the parent gives us --timeout 8 (4 on a retry), so anything larger
+ # collapsed the poll loop to one attempt and made a slow gio look like a wedged session.
+ gio_bound = max(0.5, min(3.0, (deadline - time.monotonic()) / 2))
+ try:
+ # argv, not shell=True: uid comes from a hand-edited roster and is board firmware
+ # output, so a space or $(...) would unmount the wrong URI (leaving the gvfs mount
+ # held) or run as us.
+ gio = subprocess.Popen(['gio', 'mount', '-u',
+ f'mtp://TinyUsb_TinyUsb_Device_{uid}/'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, start_new_session=True)
+ except OSError:
+ # glib2.0-bin absent (ci.lan has no gio at all): nothing holds a gvfs mount
+ # either, so go straight on to the open.
+ return
+ try:
+ gio.wait(timeout=gio_bound)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(gio.pid, signal.SIGKILL)
+ except OSError:
+ gio.kill()
+ try:
+ gio.wait(timeout=2) # reap it: an abandoned gio leaves a zombie
+ except subprocess.TimeoutExpired:
+ pass
+ print('gio unmount timed out; continuing', file=sys.stderr)
+
+
+def open_mtp_dev(uid: str, timeout: float):
+ mtp = MTP()
+ deadline = time.monotonic() + timeout
+ while True:
+ try:
+ # pymtp raises USB_LAYER/PTP_LAYER/GENERAL/AlreadyConnected on a board still
+ # settling right after a flash; an unguarded raise would skip the rest of the
+ # enumeration budget (and the disconnect) instead of retrying.
+ #
+ # Never detect_devices(): that PROBES every MTP device on the rig, so a board
+ # still initialising in a parallel job answers our scan (the race #3790 fixed).
+ # libmtp-runtime publishes /dev/libmtp-<sysname> only after its own mtp-probe
+ # has accepted a device, so start from that small, ready-only set and open OUR
+ # device directly by bus/dev address.
+ target = _ready_marker(uid)
+ if target:
+ # ready first, THEN unmount, then open -- see _gvfs_unmount
+ _gvfs_unmount(uid, deadline)
+ busnum, devnum = target
+ # TinyUSB needs no libmtp quirks, so the raw entry can be built here
+ entry = LIBMTP_DeviceEntry(None, 0xcafe, None, 0x4017, 0)
+ raw = LIBMTP_RawDevice(entry, busnum, devnum)
+ mtp.device = mtp.mtp.LIBMTP_Open_Raw_Device(ctypes.byref(raw))
+ if mtp.device:
+ serial = mtp.get_serialnumber()
+ if (serial.decode('utf-8') if serial else '').lower() == uid.lower():
+ return mtp
+ mtp.disconnect()
+ except Exception as e:
+ print(f'mtp poll: {type(e).__name__}: {e}', file=sys.stderr)
+ # only when a device was actually opened: pymtp's `self.device == None`
+ # guard does NOT catch a ctypes NULL pointer (falsy, but != None), so
+ # disconnecting blindly calls LIBMTP_Release_Device(NULL)
+ if getattr(mtp, 'device', None):
+ try:
+ mtp.disconnect()
+ except Exception:
+ pass
+ mtp.device = None
+ if time.monotonic() >= deadline:
+ return None
+ time.sleep(1)
+
+
+def run_session(uid: str, timeout: float) -> int:
+ mtp = open_mtp_dev(uid, timeout)
+ if mtp is None or mtp.device is None:
+ print('MTP device not found')
+ return 1
+
+ try:
+ assert b"TinyUSB" == mtp.get_manufacturer(), 'MTP wrong manufacturer'
+ assert b"MTP Example" == mtp.get_modelname(), 'MTP wrong model'
+ assert b'1.0' == mtp.get_deviceversion(), 'MTP wrong version'
+ assert b'TinyUSB MTP' == mtp.get_devicename(), 'MTP wrong device name'
+
+ f1 = uid.encode("utf-8") + b'_file1'
+ f2 = uid.encode("utf-8") + b'_file2'
+ f3 = uid.encode("utf-8") + b'_file3'
+ mtp.get_file_to_file(1, f1)
+ with open(f1, 'rb') as file:
+ f1_data = file.read()
+ os.remove(f1)
+ assert f1_data == FILE1_EXPECT, 'MTP file1 wrong data'
+ mtp.get_file_to_file(2, f2)
+ with open(f2, 'rb') as file:
+ f2_data = file.read()
+ os.remove(f2)
+ assert FILE2_MD5_EXPECT == hashlib.md5(f2_data).hexdigest(), 'MTP file2 wrong data'
+ with open(f3, "wb") as file:
+ # 1524-byte payload + 12-byte MTP header = 3 full 512-byte buffers, so this
+ # exercises delivery of the final OUT payload before its ZLP. Deliberate and
+ # FIXED: a random size hits that boundary in ~0.2% of runs, which is not a test
+ # of it. Deterministic content so a mismatch is reproducible.
+ f3_data = bytes((i % 251) + 1 for i in range(1524))
+ file.write(f3_data)
+ file.close()
+ fid = mtp.send_file_from_file(f3, b'file3')
+ f3_readback = f3 + b'_readback'
+ mtp.get_file_to_file(fid, f3_readback)
+ with open(f3_readback, 'rb') as f:
+ f3_rb_data = f.read()
+ os.remove(f3_readback)
+ assert f3_rb_data == f3_data, 'MTP file3 wrong data'
+ os.remove(f3)
+ mtp.delete_object(fid)
+ except AssertionError as e:
+ print(e)
+ return 1
+ finally:
+ mtp.disconnect()
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--uid', required=True, help='board_get_unique_id serial to match')
+ parser.add_argument('--timeout', type=float, default=30, help='enumeration wait budget (s)')
+ args = parser.parse_args()
+ return run_session(args.uid, args.timeout)
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/test/hil/pymtp.py b/test/hil/pymtp.py
index 8b694df94..fc0c66104 100644
--- a/test/hil/pymtp.py
+++ b/test/hil/pymtp.py
@@ -420,6 +420,8 @@ _libmtp.LIBMTP_Get_Playlist.restype = ctypes.POINTER(LIBMTP_Playlist)
_libmtp.LIBMTP_Get_Folder_List.restype = ctypes.POINTER(LIBMTP_Folder)
_libmtp.LIBMTP_Find_Folder.restype = ctypes.POINTER(LIBMTP_Folder)
_libmtp.LIBMTP_Get_Errorstack.restype = ctypes.POINTER(LIBMTP_Error)
+_libmtp.LIBMTP_Dump_Errorstack.argtypes = [ctypes.POINTER(LIBMTP_MTPDevice)]
+_libmtp.LIBMTP_Dump_Errorstack.restype = None
_libmtp.LIBMTP_Open_Raw_Device.restype = ctypes.POINTER(LIBMTP_MTPDevice)
_libmtp.LIBMTP_Open_Raw_Device.argtypes = [ctypes.POINTER(LIBMTP_RawDevice)]
@@ -451,16 +453,14 @@ class MTP:
def debug_stack(self):
"""
- Checks if __DEBUG__ is set, if so, prints and clears the
- errorstack.
+ Checks if __DEBUG__ is set, and if so prints the error stack.
@rtype: None
@return: None
"""
- if __DEBUG__:
- self.mtp.LIBMTP_Dump_Errorstack()
- #self.mtp.LIBMTP_Clear_Errorstack()
+ if __DEBUG__ and self.device:
+ self.mtp.LIBMTP_Dump_Errorstack(self.device)
def detect_devices(self):
"""
diff --git a/test/hil/requirements.txt b/test/hil/requirements.txt
index ef1cf575b..abfb93783 100644
--- a/test/hil/requirements.txt
+++ b/test/hil/requirements.txt
@@ -1,7 +1,8 @@
# System packages (install separately):
-# sudo apt install mtools libmtp9 alsa-utils iperf
+# sudo apt install mtools libmtp9 libmtp-runtime alsa-utils iperf
# mtools - read_disk_file (device/cdc_msc, device/msc_dual_lun)
# libmtp9 - pymtp ctypes load (device/mtp); Debian 13 uses libmtp9t64
+# libmtp-runtime - mtp-probe and the completed-device /dev/libmtp-* marker
# alsa-utils - arecord (device/audio_test_freertos)
# iperf - throughput tests (device/net_lwip_*)
hidapi
diff --git a/test/hil/test/stubs/pymtp.py b/test/hil/test/stubs/pymtp.py
new file mode 100644
index 000000000..2720321f6
--- /dev/null
+++ b/test/hil/test/stubs/pymtp.py
@@ -0,0 +1,125 @@
+# SPDX-License-Identifier: MIT
+# Fake pymtp for the hil unit tests — stands in both for the import (GitHub's bare
+# pre-commit runner has no libmtp/pymtp) and for a scripted MTP device. Behavior is
+# driven by env vars so subprocesses (mtp_test.py under run_cmd) can be steered:
+# FAKE_PYMTP_MODE absent (default) | ok | hang
+# FAKE_PYMTP_UID serial number the fake device reports
+# FAKE_PYMTP_FILE1 text served as file id 1 (README.TXT)
+# FAKE_PYMTP_LOGO path to the logo bytes served as file id 2
+# File contents come from env, not constants: the test extracts them from the example's
+# own sources, so this stub cannot drift out of sync with the firmware.
+# 'hang' blocks forever inside detect_devices — the in-process libmtp equivalent of a
+# D-state usbfs ioctl on a wedged device.
+import ctypes
+import os
+import time
+
+
+class NotConnected(Exception):
+ pass
+
+
+class LIBMTP_DeviceEntry(ctypes.Structure):
+ """Real pymtp exposes this; mtp_test builds one to open a KNOWN device instead of
+ probing every MTP device on the bus."""
+ _fields_ = [('vendor', ctypes.c_char_p), ('vendor_id', ctypes.c_uint16),
+ ('product', ctypes.c_char_p), ('product_id', ctypes.c_uint16),
+ ('device_flags', ctypes.c_uint32)]
+
+
+class LIBMTP_RawDevice(ctypes.Structure):
+ _fields_ = [('device_entry', LIBMTP_DeviceEntry), ('bus_location', ctypes.c_uint32),
+ ('devnum', ctypes.c_uint8)]
+
+
+class _LibShim:
+ @staticmethod
+ def LIBMTP_Open_Raw_Device(_ref):
+ # mtp_test no longer calls detect_devices() (it probed every MTP device on the
+ # rig), so the scripted modes have to act here -- this is the only libmtp entry
+ # point the marker-based open goes through.
+ mode = os.environ.get('FAKE_PYMTP_MODE', 'absent')
+ if mode == 'hang':
+ time.sleep(10000)
+ if mode == 'error':
+ raise RuntimeError('CommandFailed: LIBMTP_ERROR_USB_LAYER')
+ if mode == 'error_then_ok':
+ flag = os.environ.get('FAKE_PYMTP_ERRED_MARKER', '/tmp/.fake_pymtp_erred')
+ if not os.path.exists(flag):
+ open(flag, 'w').close()
+ raise RuntimeError('CommandFailed: LIBMTP_ERROR_PTP_LAYER')
+ if mode == 'absent':
+ return ctypes.POINTER(ctypes.c_int)() # NULL: nothing to open
+ # the real one has restype POINTER(LIBMTP_MTPDevice): a failed open returns a
+ # NULL pointer, which is FALSY but compares unequal to None -- the distinction
+ # mtp_test's `if mtp.device:` guards depend on
+ if os.environ.get('FAKE_PYMTP_OPEN') == 'null':
+ return ctypes.POINTER(ctypes.c_int)()
+ return 1
+
+
+class MTP:
+ def __init__(self):
+ self.mtp = _LibShim()
+ self.device = None
+ self._sent = {}
+ self._next_id = 3
+
+ def detect_devices(self):
+ mode = os.environ.get('FAKE_PYMTP_MODE', 'absent')
+ if mode == 'hang':
+ time.sleep(10000)
+ if mode == 'error':
+ # real pymtp raises for USB_LAYER/PTP_LAYER/GENERAL/AlreadyConnected;
+ # the first poll after a flash routinely hits one
+ raise RuntimeError('CommandFailed: LIBMTP_ERROR_USB_LAYER')
+ if mode == 'error_then_ok':
+ if not getattr(self, '_erred', False):
+ self._erred = True
+ raise RuntimeError('CommandFailed: LIBMTP_ERROR_USB_LAYER')
+ return [ctypes.c_int(1)]
+ if mode != 'ok':
+ return []
+ return [ctypes.c_int(1)]
+
+ def get_serialnumber(self):
+ return os.environ.get('FAKE_PYMTP_UID', '').encode()
+
+ def get_manufacturer(self):
+ return b'TinyUSB'
+
+ def get_modelname(self):
+ return b'MTP Example'
+
+ def get_deviceversion(self):
+ return b'1.0'
+
+ def get_devicename(self):
+ return b'TinyUSB MTP'
+
+ def get_file_to_file(self, fid, path):
+ if fid == 1:
+ data = os.environ['FAKE_PYMTP_FILE1'].encode()
+ elif fid == 2:
+ with open(os.environ['FAKE_PYMTP_LOGO'], 'rb') as f:
+ data = f.read()
+ else:
+ data = self._sent[fid]
+ with open(path, 'wb') as f:
+ f.write(data)
+
+ def send_file_from_file(self, path, _name):
+ with open(path, 'rb') as f:
+ self._sent[self._next_id] = f.read()
+ self._next_id += 1
+ return self._next_id - 1
+
+ def delete_object(self, fid):
+ del self._sent[fid]
+
+ def disconnect(self):
+ # vendored pymtp raises when nothing is connected; a stub that silently accepts
+ # it hides a LIBMTP_Release_Device(NULL) call on real hardware
+ if self.device is None:
+ raise NotConnected('no device connected')
+ self.device = None
diff --git a/test/hil/test/test_hil_bounded.py b/test/hil/test/test_hil_bounded.py
new file mode 100644
index 000000000..908a142d5
--- /dev/null
+++ b/test/hil/test/test_hil_bounded.py
@@ -0,0 +1,1701 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests proving hil_test's storage and MTP helpers cannot hang the worker: a
+# wedged device blocks the call in D state forever (child process or in-process ioctl),
+# so these paths go through a bounded runner. Fakes stand in for the wedge (a real one
+# cannot be manufactured on demand): a PATH-injected `mtype` script and a
+# PYTHONPATH-injected `pymtp` module, each with a mode that blocks forever.
+# Scope: mtype, the gio unmount, the libmtp session, the arecord/iperf reaps, and the
+# printer read (a process now, via run_alongside, so a killed reader takes its fd with
+# it -- usblp allows ONE opener, and a blocked thread kept the node for the worker's life).
+# Known residue (unbounded, backstopped only by the pool guard): hid open/write and
+# midi's read(64).
+#
+# hil_test imports pyserial, which GitHub's bare pre-commit runner does not have — so
+# an inert serial module is stubbed into sys.modules BEFORE the import (nothing here
+# exercises serial paths). MTP traffic never touches hil_test: it all goes through the
+# mtp_test.py subprocess, which gets the fake pymtp via PYTHONPATH.
+# Run directly:
+# python3 test/hil/test/test_hil_bounded.py
+import os
+import stat
+import sys
+import threading
+from multiprocessing import TimeoutError as MpTimeoutError
+import time
+import types
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+TEST_DIR = os.path.dirname(os.path.abspath(__file__))
+# the modules under test live in the parent dir (test/hil), not here
+sys.path.insert(0, os.path.dirname(TEST_DIR))
+
+serial_stub = types.ModuleType('serial')
+serial_stub.Serial = type('Serial', (), {})
+serial_stub.SerialException = type('SerialException', (Exception,), {})
+serial_stub.SerialTimeoutException = type('SerialTimeoutException', (Exception,), {})
+sys.modules.setdefault('serial', serial_stub)
+import hil_flash
+import hil_test
+
+
+def write_script(path: Path, body: str) -> None:
+ path.write_text('#!/bin/sh\n' + body + '\n')
+ path.chmod(path.stat().st_mode | stat.S_IEXEC)
+
+
+def run_bounded(fn, timeout: float):
+ """Run fn in a daemon thread; return (finished, exception). A still-running thread is
+ the hang under test — leave it to die with the interpreter."""
+ exc = []
+
+ def wrapper():
+ try:
+ fn()
+ except BaseException as e: # noqa: BLE001 - tests inspect the exception
+ exc.append(e)
+
+ t = threading.Thread(target=wrapper, daemon=True)
+ t.start()
+ t.join(timeout)
+ return not t.is_alive(), exc[0] if exc else None
+
+
[email protected](os.name == 'nt', 'POSIX shell fakes')
+class ReadDiskFile(unittest.TestCase):
+ def setUp(self):
+ self.tmp = TemporaryDirectory()
+ tmp = Path(self.tmp.name)
+ self.addCleanup(self.tmp.cleanup)
+ # fake block device node: get_disk_dev is patched to this existing path
+ self.dev = tmp / 'fakedev'
+ self.dev.write_bytes(b'')
+ # addCleanup, not tearDown: tearDown does NOT run when setUp raises, and a leaked
+ # PATH entry points at a temp bin dir this class already deleted.
+ for name in ('get_disk_dev', '_enum_timeout', 'MTYPE_TIMEOUT'):
+ self.addCleanup(setattr, hil_test, name, getattr(hil_test, name))
+ hil_test.get_disk_dev = lambda uid, vendor, lun: str(self.dev)
+ hil_test._enum_timeout = 2
+ self.bin = tmp / 'bin'
+ self.bin.mkdir()
+ self.addCleanup(os.environ.__setitem__, 'PATH', os.environ['PATH'])
+ os.environ['PATH'] = f'{self.bin}:{os.environ["PATH"]}'
+ self.pidfile = tmp / 'mtype.pid'
+ self.addCleanup(self._reap_mtype)
+
+ def _reap_mtype(self):
+ if self.pidfile.exists(): # reap a leaked hang-mode mtype
+ try:
+ os.kill(int(self.pidfile.read_text()), 9)
+ except (OSError, ValueError):
+ pass
+
+ def test_returns_exact_bytes_despite_stderr_noise(self):
+ # \377 is invalid UTF-8 and stderr noise must not leak into the data
+ write_script(self.bin / 'mtype', r"printf 'R\377EADME-DATA'; printf 'vfat warning' >&2")
+ data = hil_test.read_disk_file('uid0', 0, 'README.TXT')
+ self.assertEqual(data, b'R\xffEADME-DATA')
+
+ def test_failure_message_carries_mtype_stderr_and_fname(self):
+ write_script(self.bin / 'mtype', "printf 'mtype: cannot read' >&2; exit 1")
+ with self.assertRaises(AssertionError) as cm:
+ hil_test.read_disk_file('uid0', 0, 'README.TXT')
+ self.assertIn('cannot read', str(cm.exception))
+ self.assertIn('README.TXT', str(cm.exception))
+
+ def test_empty_read_fails_immediately_with_fname(self):
+ # rc 0 with no data is a real answer (bad sectors, empty file), not "not ready":
+ # fail at once like the old assert did, naming the file — don't spin the budget
+ write_script(self.bin / 'mtype', 'exit 0')
+ t0 = time.monotonic()
+ with self.assertRaises(AssertionError) as cm:
+ hil_test.read_disk_file('uid0', 0, 'README.TXT')
+ self.assertLess(time.monotonic() - t0, 1.5)
+ self.assertIn('README.TXT', str(cm.exception))
+
+ def test_hung_mtype_cannot_hang_the_worker(self):
+ # a D-state child never exits; the bounded runner must give up without it
+ write_script(self.bin / 'mtype', f'echo $$ > {self.pidfile}; exec sleep 1000')
+ hil_test.MTYPE_TIMEOUT = 2
+ finished, exc = run_bounded(lambda: hil_test.read_disk_file('uid0', 0, 'README.TXT'), 20)
+ self.assertTrue(finished, 'read_disk_file hung on a stuck mtype')
+ self.assertIsInstance(exc, AssertionError)
+
+
+class CompactOutput(unittest.TestCase):
+ def test_strips_workflow_command_markers(self):
+ """Defense-in-depth: the historical marker source was worker-side run_cmd
+ (now suppressed at the emitter); anything future that pipes markers into a
+ captured stdout would land them mid-row where GitHub renders them literally."""
+ raw = '::group::COMMAND TIMEOUT (1s): x\nboom\n::endgroup::\ntail'
+ self.assertEqual(hil_test.compact_output(raw), 'COMMAND TIMEOUT (1s): x | boom | tail')
+
+
+class UsbtestRecovery(unittest.TestCase):
+ def test_recovery_flags_and_flash_bound_fit_the_reserve(self):
+ """The post-hang reflash plumbing: the CLI flags exist, and the bounded reflash
+ plus the fixed recovery costs (60s case timeout + 5s kill wait + 5s settle)
+ fits inside USBTEST_RECOVERY_BUDGET -- otherwise the outer run_cmd kill lands
+ mid-flash and orphans the flasher (own session) on the probe."""
+ import subprocess
+ hil_dir = Path(TEST_DIR).parents[0]
+ r = subprocess.run([sys.executable, str(hil_dir / 'usbtest.py'), '--help'],
+ capture_output=True, text=True, timeout=30)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ for flag in ('--recover-board', '--recover-fw', '--outer-timeout'):
+ self.assertIn(flag, r.stdout)
+
+ def test_the_bounded_reflash_actually_fits_the_reserve(self):
+ """The arithmetic the docstring above claims but never checked -- the two
+ constants never met in any test, so bumping either silently broke the promise.
+ Overrun means run_cmd's outer kill lands MID-FLASH and orphans the flasher
+ (start_new_session, so killpg misses it) holding the probe."""
+ import re
+ import usbtest
+ hil_dir = Path(TEST_DIR).parents[0]
+ # read the case timeout hil_test actually passes, so this cannot drift silently
+ src = (hil_dir / 'hil_test.py').read_text()
+ m = re.search(r'--timeout (\d+) --budget', src)
+ self.assertIsNotNone(m, 'usbtest invocation changed shape; re-derive this bound')
+ case_timeout = int(m.group(1))
+ kill_wait, settle, time_left_reserve = 5, 5, 35 # usbtest.py's fixed costs
+ worst = (case_timeout + kill_wait + usbtest.RECOVER_FLASH_TIMEOUT
+ + settle + time_left_reserve)
+ self.assertLessEqual(
+ worst, hil_test.USBTEST_RECOVERY_BUDGET,
+ f'a HUNG case needs {worst}s to recover but only '
+ f'{hil_test.USBTEST_RECOVERY_BUDGET}s is reserved')
+
+
+class UsbtestRunHelper(unittest.TestCase):
+ """usbtest.run() is the bounded replacement for subprocess.run: sysfs_write feeds it
+ input=, and every battery calls that before case 1."""
+
+ def setUp(self):
+ import usbtest
+ self.usbtest = usbtest
+
+ def test_input_kwarg_is_honoured(self):
+ r = self.usbtest.run(['cat'], input='payload', timeout=10)
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(r.stdout, 'payload')
+
+ def test_capture_output_kwarg_is_accepted(self):
+ r = self.usbtest.run(['printf', 'x'], capture_output=True, timeout=10)
+ self.assertEqual(r.stdout, 'x')
+
+ def test_timeout_is_bounded_and_raises(self):
+ import subprocess
+ t0 = time.monotonic()
+ with self.assertRaises(subprocess.TimeoutExpired):
+ self.usbtest.run(['sleep', '30'], timeout=1)
+ self.assertLess(time.monotonic() - t0, 15)
+
+
+class BuildBoardContract(unittest.TestCase):
+ def test_every_return_path_is_a_pair(self):
+ """main() unpacks `_, nfail = build_board(board)`; a bare int on any path
+ (the timeout path did) raises TypeError before the pool exists."""
+ import ast
+ src = (Path(TEST_DIR).parents[0] / 'hil_test.py').read_text()
+ fn = next(n for n in ast.walk(ast.parse(src))
+ if isinstance(n, ast.FunctionDef) and n.name == 'build_board')
+ for node in ast.walk(fn):
+ if isinstance(node, ast.Return) and node.value is not None:
+ self.assertIsInstance(node.value, ast.Tuple,
+ f'build_board returns a non-tuple at line {node.lineno}')
+
+
+class RemoteStaging(unittest.TestCase):
+ def test_import_closure_is_staged_to_the_rig(self):
+ # hil_ci.sh stages an explicit scp whitelist; a module that is not on it exists
+ # locally and in CI checkouts but silently never reaches the remote rig (how
+ # mtp_test.py was first missed). Walk the local-import closure of everything
+ # the rig executes and require each file's exact scp entry — a bare-substring
+ # match would be satisfied by a mention in a comment or the run line.
+ import ast
+ hil_dir = Path(TEST_DIR).parents[0]
+ staged = (hil_dir / 'hil_ci.sh').read_text()
+
+ def imported_paths(pyfile):
+ # ast, not regex: an earlier regex walker went silently vacuous on a
+ # multi-line import. ast also sees function-local deferred imports
+ # (usbtest.py's `import hil_flash` inside the recovery branch).
+ for node in ast.walk(ast.parse(pyfile.read_text())):
+ if isinstance(node, ast.Import):
+ for a in node.names:
+ yield a.name.replace('.', '/') + '.py'
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ if node.module == 'helper':
+ for a in node.names:
+ yield f'helper/{a.name}.py'
+ else:
+ yield node.module.replace('.', '/') + '.py'
+
+ seeds = ['hil_test.py', 'usbtest.py', 'mtp_test.py'] # CLI + spawned helpers
+ for f in seeds: # a renamed seed must fail loudly, not fall out of the walk
+ self.assertTrue((hil_dir / f).exists(), f'stale RemoteStaging seed: {f}')
+ todo, seen = list(seeds), set()
+ while todo:
+ f = todo.pop()
+ if f in seen or not (hil_dir / f).exists():
+ continue # stdlib/site-packages imports have no test/hil file
+ seen.add(f)
+ todo += list(imported_paths(hil_dir / f))
+ for f in sorted(seen):
+ self.assertIn(f'"$ROOT_DIR/test/hil/{f}"', staged,
+ f'{f} runs on the rig but hil_ci.sh does not scp it')
+
+
+class _MtpFakeRig:
+ """The fake rig shared by the MTP cases: a udev-marker tree under one tmp root and
+ the scripted pymtp on PYTHONPATH. A plain mixin, NOT a TestCase -- subclassing a
+ TestCase to reuse a fixture re-runs every inherited test in each subclass."""
+
+ @classmethod
+ def setUpClass(cls):
+ # both file fixtures come from the example's sources, so drift there fails here:
+ # file id 1 is README.TXT (C define), file id 2 is logo.png (C byte array)
+ import hashlib
+ import re
+ src = Path(TEST_DIR).parents[2] / 'examples/device/mtp/src'
+ m = re.search(r'#define README_TXT_CONTENT "([^"]+)"', (src / 'mtp_fs_example.c').read_text())
+ assert m, 'README_TXT_CONTENT define not found in mtp_fs_example.c'
+ cls.readme = m.group(1)
+ data = bytes(int(x, 16) for x in
+ re.findall(r'0x([0-9a-fA-F]{2})', (src / 'tinyusb_logo_png.h').read_text()))
+ assert hashlib.md5(data).hexdigest() == '40ef23fc2891018d41a05d4a0d5f822f'
+ cls.logo = data
+
+ def setUp(self):
+ self.tmp = TemporaryDirectory()
+ tmp = Path(self.tmp.name)
+ self.addCleanup(self.tmp.cleanup)
+ logo = tmp / 'logo.bin'
+ logo.write_bytes(self.logo)
+ self.board = {'uid': 'CAFE01', 'name': 'fakeboard'}
+ # addCleanup, not tearDown: tearDown does NOT run when setUp raises, and a leaked
+ # chdir into a deleted temp dir breaks every test after it.
+ self.saved_env = {k: os.environ.get(k) for k in
+ ('FAKE_PYMTP_MODE', 'FAKE_PYMTP_UID', 'FAKE_PYMTP_LOGO',
+ 'FAKE_PYMTP_FILE1', 'PYTHONPATH', 'PYTHONSAFEPATH',
+ 'HIL_MTP_FAKE_ROOT', 'FAKE_PYMTP_ERRED_MARKER')}
+ self.addCleanup(self._restore_env)
+ # A udev-ready marker tree: libmtp-runtime publishes /dev/libmtp-<sysname> only
+ # after mtp-probe accepts a device, and mtp_test opens THAT device directly rather
+ # than probing every MTP device on the rig (the parallel-probe race #3790 fixed).
+ # mirrors the real layout under one root, so <tmp>/sys/bus/usb/devices/1-1 reads
+ # as the stand-in for /sys/bus/usb/devices/1-1 that it is
+ dev = tmp / 'sys/bus/usb/devices/1-1'
+ usbdev = tmp / 'dev/bus/usb/001'
+ markers = tmp / 'dev' # created by usbdev's parents=True
+ dev.mkdir(parents=True); usbdev.mkdir(parents=True)
+ (dev / 'idVendor').write_text('cafe\n')
+ (dev / 'idProduct').write_text('4017\n')
+ (dev / 'serial').write_text(self.board['uid'] + '\n')
+ (dev / 'busnum').write_text('1\n')
+ (dev / 'devnum').write_text('2\n')
+ node = usbdev / '002'
+ node.write_bytes(b'')
+ (markers / 'libmtp-1-1').symlink_to(node)
+ os.environ['HIL_MTP_FAKE_ROOT'] = str(tmp)
+ os.environ['FAKE_PYMTP_ERRED_MARKER'] = str(tmp / 'erred')
+ os.environ['FAKE_PYMTP_UID'] = self.board['uid']
+ os.environ['FAKE_PYMTP_LOGO'] = str(logo)
+ os.environ['FAKE_PYMTP_FILE1'] = self.readme
+ stubs = os.path.join(TEST_DIR, 'stubs')
+ pp = self.saved_env['PYTHONPATH']
+ os.environ['PYTHONPATH'] = stubs if not pp else f'{stubs}:{pp}'
+ # pymtp is vendored next to mtp_test.py, and a script's own dir (sys.path[0])
+ # outranks PYTHONPATH — safe-path mode (3.11+) drops it so the fake wins there
+ os.environ['PYTHONSAFEPATH'] = '1'
+ for name in ('_enum_timeout', 'MTP_SESSION_MARGIN'):
+ self.addCleanup(setattr, hil_test, name, getattr(hil_test, name))
+ hil_test._enum_timeout = 2
+ # the session scratch files land in cwd
+ self.addCleanup(os.chdir, os.getcwd())
+ os.chdir(tmp)
+
+ def _restore_env(self):
+ for k, v in self.saved_env.items():
+ if v is None:
+ os.environ.pop(k, None)
+ else:
+ os.environ[k] = v
+
+
[email protected](os.name == 'nt', 'POSIX shell fakes')
[email protected](sys.version_info < (3, 11), 'fake-pymtp steering needs PYTHONSAFEPATH')
+class DeviceMtp(_MtpFakeRig, unittest.TestCase):
+ """test_device_mtp end to end: the real mtp_test.py subprocess under run_cmd,
+ with the scripted pymtp fake steered in via PYTHONPATH."""
+
+ def test_mtp_session_passes_against_scripted_device(self):
+ os.environ['FAKE_PYMTP_MODE'] = 'ok'
+ hil_test.test_device_mtp(self.board) # no exception
+
+ def test_absent_device_fails_cleanly(self):
+ os.environ['FAKE_PYMTP_MODE'] = 'absent'
+ finished, exc = run_bounded(lambda: hil_test.test_device_mtp(self.board), 30)
+ self.assertTrue(finished)
+ self.assertIsInstance(exc, AssertionError)
+ self.assertIn('MTP device not found', str(exc))
+
+ def test_libmtp_error_on_one_poll_retries_instead_of_dying(self):
+ """pymtp raises for USB_LAYER/PTP_LAYER errors -- routine on the first poll
+ after a flash. An unguarded raise skipped the whole enumeration budget."""
+ os.environ['FAKE_PYMTP_MODE'] = 'error_then_ok'
+ hil_test.test_device_mtp(self.board) # retries past the error, then passes
+
+ def test_libmtp_error_every_poll_fails_cleanly(self):
+ os.environ['FAKE_PYMTP_MODE'] = 'error'
+ finished, exc = run_bounded(lambda: hil_test.test_device_mtp(self.board), 30)
+ self.assertTrue(finished)
+ self.assertIsInstance(exc, AssertionError)
+
+ def test_hung_mtp_stack_cannot_hang_the_worker(self):
+ # in-process libmtp blocking in a usbfs ioctl (D state) hangs whatever thread
+ # made the call, forever — the session must be somewhere disposable
+ os.environ['FAKE_PYMTP_MODE'] = 'hang'
+ hil_test.MTP_SESSION_MARGIN = 3
+ finished, exc = run_bounded(lambda: hil_test.test_device_mtp(self.board), 25)
+ self.assertTrue(finished, 'test_device_mtp hung on a wedged MTP stack')
+ self.assertIsInstance(exc, AssertionError)
+
+
+class ConvoySafeFlasher(unittest.TestCase):
+ """hil_flash.convoy_safe decides whether a board gets post-HUNG recovery at all.
+
+ It must be true ONLY for flashers that can reach their probe without opening the
+ poisoned usbfs node: openocd pinned with a roster vid_pid (filters on kernel-cached
+ sysfs descriptors) and esptool (delivers to a named tty, never enumerates usbfs).
+ Anything else enumerates by opening nodes, would block in D state on the wedged one
+ and become a second stray -- JLinkExe included, whose selection is serial-only and
+ so cannot be pinned at all."""
+
+ def setUp(self):
+ import hil_flash
+ self.f = hil_flash.convoy_safe
+
+ def test_pinned_openocd_is_safe(self):
+ self.assertTrue(self.f({'name': 'openocd', 'vid_pid': '0x2e8a 0x000c'}))
+
+ def test_unpinned_openocd_is_not(self):
+ self.assertFalse(self.f({'name': 'openocd'}))
+ self.assertFalse(self.f({'name': 'openocd', 'vid_pid': ''}))
+
+ def test_esptool_is_safe_without_a_pin(self):
+ """Delivery is `-p <ttyACM>`; there is no usbfs walk to poison."""
+ self.assertTrue(self.f({'name': 'esptool'}))
+
+ def test_enumerating_flashers_are_not(self):
+ for name in ('jlink', 'stlink', 'lm4flash', 'dfu-util'):
+ self.assertFalse(self.f({'name': name, 'vid_pid': '0x1366 0x1024'}),
+ f'{name} must not be treated as convoy-safe')
+
+ def test_missing_or_odd_name_is_not_safe(self):
+ for flasher in ({}, {'name': None}, {'name': ''}):
+ self.assertFalse(self.f(flasher))
+
+
+class BoundedOpen(unittest.TestCase):
+ """hil_util.bounded_open must return rather than block, and must not leak the fd if
+ the open completes after we gave up (usblp_open takes the device mutex before it
+ consults O_NONBLOCK, so a wedged node blocks the open uninterruptibly)."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.tmp = TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ # bounded_open counts its stranded threads now, and the counter is process-global
+ # with no decrement: three wedged-FIFO tests here reach SYSFS_STUCK_MAX and every
+ # later test in this file reads SYSFS_UNKNOWN for perfectly good attributes
+ self.addCleanup(setattr, hil_util, '_sysfs_stuck', hil_util._sysfs_stuck)
+
+ def test_opens_a_normal_file(self):
+ f = Path(self.tmp.name) / 'plain'
+ f.write_text('x')
+ fd = self.hil_util.bounded_open(str(f), os.O_RDONLY, 5)
+ self.assertIsNotNone(fd)
+ os.close(fd)
+
+ def test_missing_path_returns_none_without_raising(self):
+ self.assertIsNone(self.hil_util.bounded_open(
+ str(Path(self.tmp.name) / 'nope'), os.O_RDONLY, 5))
+
+ @unittest.skipIf(os.name == 'nt', 'POSIX fifo')
+ def test_blocking_open_gives_up_and_does_not_leak_fds(self):
+ """A reader-less FIFO blocks open(O_WRONLY) forever -- the closest portable
+ stand-in for a wedged usblp node."""
+ fifo = Path(self.tmp.name) / 'fifo'
+ os.mkfifo(fifo)
+ before = len(os.listdir('/proc/self/fd'))
+ t0 = time.monotonic()
+ for _ in range(5):
+ self.assertIs(self.hil_util.bounded_open(str(fifo), os.O_WRONLY, 0.2),
+ self.hil_util.SYSFS_UNKNOWN)
+ self.assertLess(time.monotonic() - t0, 10, 'bounded_open did not bound')
+ self.assertLessEqual(len(os.listdir('/proc/self/fd')) - before, 1,
+ 'bounded_open leaked fds on the blocking path')
+
+ @unittest.skipIf(os.name == 'nt', 'POSIX fifo')
+ def test_open_completing_during_the_abandon_does_not_leak(self):
+ """The window the handoff lock exists for: the worker is at its store-or-close
+ decision when the caller gives up and drains the box.
+
+ The `abandoned` Event is instrumented to park the worker there, because timing
+ alone never reaches that window -- 1500 tries against the unlocked version leaked
+ nothing, so a test that merely completes the open late proves nothing. An empty
+ `hit` means the instrumentation no longer bites and the window is untested."""
+ hil_util = self.hil_util
+ fifo = Path(self.tmp.name) / 'fifo'
+ os.mkfifo(fifo)
+ caller = threading.current_thread()
+ drained, hit = threading.Event(), []
+
+ class RacingEvent(threading.Event):
+ def is_set(self):
+ v = super().is_set()
+ if not v and not hit and threading.current_thread() is not caller:
+ hit.append(True)
+ # bounded: the fixed bounded_open holds the lock across this call, so
+ # the caller cannot reach its abandon (and set drained) until we return
+ drained.wait(0.3)
+ return v
+
+ shim = types.ModuleType('threading_shim')
+ shim.__dict__.update(threading.__dict__)
+ shim.Event = RacingEvent
+ hil_util.threading = shim
+ self.addCleanup(setattr, hil_util, 'threading', threading)
+
+ before = len(os.listdir('/proc/self/fd'))
+ rd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) # the O_WRONLY open completes at once
+ try:
+ self.assertIs(hil_util.bounded_open(str(fifo), os.O_WRONLY, 0.05),
+ hil_util.SYSFS_UNKNOWN)
+ drained.set()
+ time.sleep(0.1) # let an abandoned worker act on what it saw
+ self.assertTrue(hit, 'the abandon window was never entered')
+ self.assertLessEqual(len(os.listdir('/proc/self/fd')) - before, 1,
+ 'bounded_open stored the fd after the caller drained the box')
+ finally:
+ drained.set()
+ os.close(rd)
+
+
+class SysfsUnknownIsNotAbsent(unittest.TestCase):
+ """read_sysfs must tell "no such attribute" (a fact) from "the read did not answer"
+ (not a fact). Every caller that concluded absence from the latter reported a healthy
+ board as a firmware regression."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.saved = (hil_util._sysfs_stuck, hil_util._sysfs_blind_logged)
+ self.tmp = TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+
+ def tearDown(self):
+ # a blocked read strands a counted daemon thread; leaving the count raised would
+ # blind every later test in this process
+ self.hil_util._sysfs_stuck, self.hil_util._sysfs_blind_logged = self.saved
+
+ def test_readable_attribute_returns_its_value(self):
+ p = Path(self.tmp.name) / 'serial'
+ p.write_text('CAFE01\n')
+ self.assertEqual(self.hil_util.read_sysfs(str(p)), 'CAFE01')
+
+ def test_missing_attribute_is_none(self):
+ self.assertIsNone(self.hil_util.read_sysfs(str(Path(self.tmp.name) / 'nope')))
+
+ @unittest.skipIf(os.name == 'nt', 'POSIX fifo')
+ def test_blocking_read_is_unknown_not_absent(self):
+ """A reader-less FIFO stands in for the wedged device whose sysfs read never
+ returns; None here would read as "the board is gone"."""
+ fifo = Path(self.tmp.name) / 'fifo'
+ os.mkfifo(fifo)
+ t0 = time.monotonic()
+ v = self.hil_util.read_sysfs(str(fifo), grace=0.3)
+ self.assertLess(time.monotonic() - t0, 10, 'read_sysfs did not bound')
+ self.assertIs(v, self.hil_util.SYSFS_UNKNOWN)
+ self.assertIsNotNone(v)
+
+ def test_blind_process_answers_unknown_for_a_readable_attribute(self):
+ p = Path(self.tmp.name) / 'serial'
+ p.write_text('CAFE01')
+ self.hil_util._sysfs_stuck = self.hil_util.SYSFS_STUCK_MAX
+ self.assertTrue(self.hil_util.sysfs_blind())
+ self.assertIs(self.hil_util.read_sysfs(str(p)), self.hil_util.SYSFS_UNKNOWN)
+ self.assertIn('blind', self.hil_util.sysfs_blind_note())
+
+ def test_unknown_is_falsy_but_not_none(self):
+ # call sites use `(v or '')` idioms; the sentinel must keep working there while
+ # still being distinguishable from a real absence
+ self.assertFalse(self.hil_util.SYSFS_UNKNOWN)
+ self.assertIsNotNone(self.hil_util.SYSFS_UNKNOWN)
+
+
+class UsbtestEnumerationVerdict(unittest.TestCase):
+ """test_device_usbtest must not report a healthy board as "no cafe:4010 device" just
+ because its own sysfs reads stopped answering."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.td = TemporaryDirectory()
+ self.addCleanup(self.td.cleanup)
+ # a real device dir: usb_scan reads idVendor/idProduct with a plain open (they are
+ # lock-free descriptor fields), and only `serial` through the bounded reader
+ dev = Path(self.td.name) / '1-2'
+ dev.mkdir()
+ (dev / 'idVendor').write_text('cafe\n')
+ (dev / 'idProduct').write_text('4010\n')
+ (dev / 'serial').write_text('CAFE01\n')
+ for obj, name, val in ((hil_util, 'read_sysfs', hil_util.read_sysfs),
+ (hil_util, 'glob', hil_util.glob),
+ (hil_util, '_sysfs_stranded', {}),
+ (hil_test, '_enum_timeout', 1)):
+ self.addCleanup(setattr, obj, name, getattr(obj, name))
+ setattr(obj, name, val)
+ hil_util.glob = types.SimpleNamespace(glob=lambda pat: [str(dev)])
+
+ def _fail(self, reader):
+ self.hil_util.read_sysfs = reader
+ with self.assertRaises(hil_test.TestFail) as cm:
+ hil_test.test_device_usbtest({'uid': 'CAFE01', 'name': 'fake', 'flasher': {}})
+ return str(cm.exception)
+
+ def test_unknown_reads_do_not_claim_the_device_is_absent(self):
+ msg = self._fail(lambda p, *a, **kw: self.hil_util.SYSFS_UNKNOWN)
+ self.assertNotIn('no cafe:4010 device', msg)
+ self.assertIn('did not answer', msg)
+
+ def test_a_readable_bus_without_the_device_still_says_absent(self):
+ msg = self._fail(lambda p, *a, **kw: 'OTHERUID')
+ self.assertIn('no cafe:4010 device', msg)
+
+
+class UnresolvedControllerBucket(unittest.TestCase):
+ """An unresolved controller must budget in ONE bucket. Taking a permit on every slot
+ serialized the whole fleet the moment a worker went blind."""
+
+ def setUp(self):
+ import threading
+ from helper import hil_lock
+ self.hil_lock = hil_lock
+ self.saved = (hil_lock.controller_map, hil_lock.controller_meta,
+ hil_lock.controller_hints, hil_lock.log)
+ hil_lock.controller_map, hil_lock.controller_meta = {}, threading.Lock()
+ hil_lock.controller_hints, hil_lock.log = {}, lambda *a, **k: None
+
+ def tearDown(self):
+ (self.hil_lock.controller_map, self.hil_lock.controller_meta,
+ self.hil_lock.controller_hints, self.hil_lock.log) = self.saved
+
+ def _slots(self, uid, warn):
+ import threading
+ sems = self.hil_lock.make_permit_sems(threading.Semaphore, 2)
+ return self.hil_lock.controller_permit(sems, uid, warn_unknown=warn).slots
+
+ def test_unresolved_boards_share_one_slot(self):
+ for warn in (False, True):
+ slots = self._slots('NOSUCHUID', warn)
+ self.assertEqual(len(slots), 1, 'unresolved uid took more than one slot')
+ self.assertEqual(slots, self._slots('OTHERUID', warn),
+ 'unresolved boards must share the bucket, not spread over it')
+
+ def test_the_semaphore_array_is_long_enough_for_the_unknown_slot(self):
+ """UNKNOWN_SLOT indexes one PAST the real slots. An array sized to
+ CONTROLLER_SLOTS IndexErrors on the first unresolved board, inside a pool worker,
+ which map_async turns into a total loss of every board's results."""
+ import threading
+ sems = self.hil_lock.make_permit_sems(threading.Semaphore, 2)
+ self.assertGreater(len(sems), self.hil_lock.UNKNOWN_SLOT)
+
+ def test_the_unknown_bucket_never_lends_a_controller_a_second_budget(self):
+ """A private FULL budget let 2 unknown batteries join 2 resolved ones on the same
+ physical controller -- 4 where the width is 2. One at a time caps that at +1."""
+ import threading
+ sems = self.hil_lock.make_permit_sems(threading.Semaphore, 2)
+ first = self.hil_lock.controller_permit(sems, 'NOSUCHUID')
+ first.__enter__()
+ self.addCleanup(first.__exit__)
+ second = self.hil_lock.controller_permit(sems, 'OTHERUID')
+ self.assertFalse(sems[second.slots[0]].acquire(blocking=False),
+ 'a second unresolved board got in alongside the first')
+
+ def test_every_real_slot_keeps_the_full_width(self):
+ import threading
+ sems = self.hil_lock.make_permit_sems(threading.Semaphore, 2)
+ for s in sems[:self.hil_lock.CONTROLLER_SLOTS]:
+ self.assertTrue(s.acquire(blocking=False) and s.acquire(blocking=False))
+ self.assertFalse(s.acquire(blocking=False))
+
+
+class ThroughputPayloadBound(unittest.TestCase):
+ """An unknown link speed must pick the FS payload, and each dd must be bounded by the
+ payload actually requested."""
+
+ def test_only_a_read_high_speed_gets_the_big_payload(self):
+ from helper import hil_util
+ for speed in (None, hil_util.SYSFS_UNKNOWN, '12', '1.5'):
+ self.assertTrue(hil_test.link_is_fs(speed), f'{speed!r} must scale as FS')
+ for speed in ('480', '5000', '10000'):
+ self.assertFalse(hil_test.link_is_fs(speed))
+
+ def test_dd_bound_scales_with_the_payload_and_stays_bounded(self):
+ self.assertGreater(hil_test.dd_timeout(16), hil_test.dd_timeout(1))
+ self.assertGreaterEqual(hil_test.dd_timeout(1), 30) # setup + flush floor
+ # still an INNER bound: run_cmd's own timeout must stay the outer one
+ self.assertLess(hil_test.dd_timeout(16), hil_test.hil_util.CMD_TIMEOUT)
+
+
+class FindDeviceCache(unittest.TestCase):
+ """usbtest.find_device's cache is keyed by sysname, a bus-topology path: after a
+ renumber it can name a different cafe:4010 board, and idVendor/idProduct are identical
+ on every one of them. Only `serial` tells them apart."""
+
+ def setUp(self):
+ import usbtest
+ self.usbtest = usbtest
+ self.tmp = TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.saved_sys_usb = usbtest.SYS_USB
+ usbtest.SYS_USB = Path(self.tmp.name)
+ usbtest._DEV_CACHE.clear()
+ self._dev('1-2', 'AAAA', devnum=2)
+ self._dev('1-3', 'BBBB', devnum=3)
+
+ def tearDown(self):
+ self.usbtest.SYS_USB = self.saved_sys_usb
+ self.usbtest._DEV_CACHE.clear()
+
+ def _dev(self, sysname, serial, devnum):
+ d = Path(self.tmp.name) / sysname
+ d.mkdir()
+ for name, val in (('idVendor', self.usbtest.VID), ('idProduct', self.usbtest.PID),
+ ('serial', serial), ('busnum', '1'), ('devnum', str(devnum)),
+ ('speed', '480'), ('bcdDevice', '0104')):
+ (d / name).write_text(val + '\n')
+
+ def test_cached_sysname_with_another_boards_serial_is_rejected(self):
+ self.usbtest._DEV_CACHE['bbbb'] = '1-2' # renumbered: 1-2 is board AAAA now
+ dev = self.usbtest.find_device('BBBB')
+ self.assertEqual(dev['sysname'], '1-3')
+ self.assertEqual(dev['serial'], 'BBBB')
+ self.assertEqual(self.usbtest._DEV_CACHE['bbbb'], '1-3')
+
+ def test_cached_sysname_with_the_right_serial_is_kept(self):
+ self.usbtest._DEV_CACHE['bbbb'] = '1-3'
+ dev = self.usbtest.find_device('BBBB')
+ self.assertEqual((dev['sysname'], dev['serial']), ('1-3', 'BBBB'))
+
+ def test_a_cached_device_that_vanished_falls_back_to_the_scan(self):
+ self.usbtest._DEV_CACHE['bbbb'] = '1-9' # gone from sysfs
+ self.assertEqual(self.usbtest.find_device('BBBB')['sysname'], '1-3')
+
+
+class EnumPollDoesNotReReadAWedgedPath(unittest.TestCase):
+ """usbtest_enumerated re-globs every device each 0.2 s pass. One wedged peer therefore
+ strands a fresh bounded reader thread per pass, and SYSFS_STUCK_MAX=4 of those blind
+ the WHOLE worker for the rest of the run -- measured at 8 s of polling. A path that
+ already stranded is known-unknown; reading it again buys nothing and costs the
+ blindness budget."""
+
+ def test_a_stranded_path_is_read_at_most_once(self):
+ from contextlib import contextmanager
+ from helper import hil_lock, hil_util
+
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ # A REAL device dir: usb_scan reads idVendor/idProduct with a plain open and
+ # `continue`s on OSError, so a bare FIFO is skipped before the bounded read is ever
+ # reached -- this test passed identically with the memo deleted until the ids were
+ # added. The FIFO must be the `serial` of a device that survives the cheap filter.
+ devdir = Path(td.name) / '1-2'
+ devdir.mkdir()
+ (devdir / 'idVendor').write_text('cafe\n')
+ (devdir / 'idProduct').write_text('4010\n')
+ wedged = devdir / 'serial'
+ os.mkfifo(wedged) # open() blocks forever: no writer, ever
+
+ def patch(obj, name, value):
+ self.addCleanup(setattr, obj, name, getattr(obj, name))
+ setattr(obj, name, value)
+
+ def _permit(uid):
+ yield
+
+ from helper import hil_util as _hu2
+ patch(_hu2, 'glob', types.SimpleNamespace(glob=lambda p: [str(devdir)]))
+ patch(_hu2, '_sysfs_stranded', {})
+ patch(hil_lock, 'usbtest_permit', contextmanager(_permit))
+ # Long enough for several 2 s reads, but under the blindness cap -- past the cap
+ # sysfs_blind() short-circuits reads on its own and would mask the memo entirely.
+ patch(hil_test, '_enum_timeout', 8)
+ # the blindness counter is process-global and never decrements: restore it or this
+ # test blinds every test that runs after it
+ patch(hil_util, '_sysfs_stuck', hil_util._sysfs_stuck)
+
+ # Count LEAKED THREADS, not _sysfs_stuck: a strand is booked only the first time a
+ # path is seen, so the counter is deduped by the memo's own bookkeeping and stays 1
+ # even when the memo is broken. Each re-read blocks a fresh thread on the FIFO
+ # forever and leaks its fd -- which is the cost the memo exists to avoid, and the
+ # only thing here that actually moves when it regresses.
+ before = threading.active_count()
+ with self.assertRaises(hil_test.TestFail): # never enumerates, by construction
+ hil_test.test_device_usbtest({'name': 'b', 'uid': 'UID1',
+ 'flasher': {'name': 'openocd'}})
+ self.assertLessEqual(threading.active_count() - before, 1,
+ 'the poll re-read a path it already knew was stranded')
+
+
+class ReRunSpecNamesOnlyWhatFailed(unittest.TestCase):
+ """The pool-guard path used to leave this unwritten -- and a fresh run has already
+ unlinked it -- so build.yml's re-run step found nothing and GitHub re-tested all ~26
+ boards to find the one that wedged."""
+
+ def test_only_failed_boards_and_their_failed_tests(self):
+ with TemporaryDirectory() as td:
+ d = Path(td)
+ spec = d / 'cfg.failed'
+ hil_test._write_failed_spec(spec, d, [
+ ('good', 0, [], None, 1.0),
+ ('bad', 2, ['device/cdc_msc'], None, 1.0),
+ ('wedged', 1, [], None, 0.0), # never reported: no test list
+ ])
+ got = spec.read_text()
+ self.assertIn('-b bad', got)
+ self.assertIn('-bt bad:device/cdc_msc', got)
+ self.assertIn('-b wedged', got)
+ self.assertNotIn('good', got)
+
+ def test_an_all_green_run_removes_a_stale_spec(self):
+ with TemporaryDirectory() as td:
+ d = Path(td)
+ spec = d / 'cfg.failed'
+ spec.write_text('--accumulate -b stale')
+ hil_test._write_failed_spec(spec, d, [('good', 0, [], None, 1.0)])
+ self.assertFalse(spec.exists(), 'a stale spec would re-run last time\'s boards')
+
+
+class WedgedPidsFailsClosed(unittest.TestCase):
+ """A scan that could not SEE the holder must not report "no holder". The holder is
+ root-owned (run_case uses sudo -n when the node is not writable) and that is exactly
+ what a hidepid/ProtectProc mount hides — so an unreadable /proc reading as clear
+ clears unrecovered_hang and lets cleanup unbind a device whose usbfs lock is still
+ held, which deadlocks the bus rather than one board."""
+
+ def test_returns_a_completeness_flag_not_just_pids(self):
+ import usbtest
+ got = usbtest.wedged_pids('/dev/bus/usb/999/999')
+ self.assertIsInstance(got, tuple)
+ self.assertEqual(len(got), 2, 'the caller needs (pids, complete)')
+
+ def test_a_restricted_proc_is_reported_incomplete(self):
+ import usbtest
+ self.addCleanup(setattr, usbtest.os, 'geteuid', usbtest.os.geteuid)
+ self.addCleanup(setattr, usbtest.os, 'access', usbtest.os.access)
+ usbtest.os.geteuid = lambda: 1000 # not root
+ usbtest.os.access = lambda p, m: False # /proc/1/cmdline unreadable
+ _, complete = usbtest.wedged_pids('/dev/bus/usb/999/999')
+ self.assertFalse(complete, 'a hidden holder was reported as absent')
+
+
[email protected](os.name == 'nt', 'POSIX shell fakes')
[email protected](sys.version_info < (3, 11), 'fake-pymtp steering needs PYTHONSAFEPATH')
+class StrandMemoRemembersUnstattablePaths(unittest.TestCase):
+ """A stranded path whose inode could not be read is stored as None -- which dict.get()
+ also returns for a MISS. Testing `is not None` therefore treats 'known stranded' as
+ 'never seen', and every later call strands ANOTHER permanent thread and fd on a path we
+ already know is wedged. That is the exact unbounded growth SYSFS_STUCK_MAX exists to
+ stop, and it is invisible: `first = path not in _sysfs_stranded` is False, so the
+ blindness counter does not advance either."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.addCleanup(hil_util._sysfs_stranded.clear)
+ hil_util._sysfs_stranded.clear()
+ self.addCleanup(setattr, hil_util, '_sysfs_stuck', hil_util._sysfs_stuck)
+ hil_util._sysfs_stuck = 0
+ self.td = TemporaryDirectory(); self.addCleanup(self.td.cleanup)
+ self.fifo = os.path.join(self.td.name, 'serial')
+ os.mkfifo(self.fifo) # open() succeeds, read() never returns
+
+ def test_an_unstattable_strand_is_not_re_read(self):
+ self.hil_util._sysfs_stranded[self.fifo] = None # as the record path stores it
+ before = threading.active_count()
+ self.assertIs(self.hil_util.read_sysfs(self.fifo, grace=0.5),
+ self.hil_util.SYSFS_UNKNOWN)
+ self.assertEqual(threading.active_count(), before,
+ 'a known-stranded path was re-read, stranding another thread')
+
+ def test_a_live_strand_is_still_re_read_when_the_node_is_replaced(self):
+ """The memo must not become permanent blindness: a NEW inode at the same path is a
+ different device and has to be read."""
+ self.hil_util._sysfs_stranded[self.fifo] = 999999999 # inode that is not this one
+ with open(os.path.join(self.td.name, 'other'), 'w') as f:
+ f.write('ok\n')
+ os.replace(os.path.join(self.td.name, 'other'), self.fifo)
+ self.assertEqual(self.hil_util.read_sysfs(self.fifo, grace=0.5), 'ok')
+
+
+class MtpGioOrdering(_MtpFakeRig, unittest.TestCase):
+ """gio must not run until the device is READY.
+
+ gvfs claims an MTP device only AFTER udev probing, so the mount this unmounts cannot
+ exist before /dev/libmtp-<sysname> is published -- an unmount issued earlier is a
+ guaranteed no-op that still forks a process, and it leaves the window between the
+ unmount and the open unprotected, which is the hang it exists to prevent. Running it
+ per poll iteration also forks one gio per second of the enumeration budget."""
+
+ def setUp(self):
+ super().setUp()
+ tmp = Path(self.tmp.name)
+ self.gio_log = tmp / 'gio.log'
+ binn = tmp / 'bin'; binn.mkdir()
+ (binn / 'gio').write_text('#!/bin/sh\necho "$@" >> "$GIO_LOG"\n')
+ (binn / 'gio').chmod(0o755)
+ for k in ('PATH', 'GIO_LOG'):
+ old = os.environ.get(k)
+ self.addCleanup(lambda k=k, v=old: os.environ.__setitem__(k, v)
+ if v is not None else os.environ.pop(k, None))
+ os.environ['GIO_LOG'] = str(self.gio_log)
+ os.environ['PATH'] = f'{binn}:{os.environ["PATH"]}'
+
+ def _gio_calls(self):
+ return self.gio_log.read_text().splitlines() if self.gio_log.exists() else []
+
+ def test_gio_does_not_run_before_the_device_is_ready(self):
+ (Path(self.tmp.name) / 'dev' / 'libmtp-1-1').unlink() # never becomes ready
+ os.environ['FAKE_PYMTP_MODE'] = 'absent'
+ run_bounded(lambda: hil_test.test_device_mtp(self.board), 30)
+ calls = self._gio_calls()
+ self.assertEqual(calls, [], f'gio ran {len(calls)}x with no device ready: {calls}')
+
+ def test_gio_still_runs_once_the_device_is_ready(self):
+ """The guard must delay the unmount, not delete it."""
+ os.environ['FAKE_PYMTP_MODE'] = 'ok'
+ hil_test.test_device_mtp(self.board)
+ self.assertTrue(self._gio_calls(), 'gio never ran for a ready device')
+
+
+class MtpGioFallthrough(unittest.TestCase):
+ """The missing-gio path must fall THROUGH to detection. `continue` there skips the
+ deadline check and the sleep as well, spinning at 100% CPU until the caller's outer
+ kill — reported as a wedged DUT for a missing apt package."""
+
+ def test_a_missing_gio_still_bounds_the_session(self):
+ import subprocess
+ with TemporaryDirectory() as td:
+ env = {**os.environ, 'PATH': td, # no gio, no anything
+ 'PYTHONPATH': os.path.join(TEST_DIR, 'stubs'),
+ 'FAKE_PYMTP_MODE': 'none', 'PYTHONSAFEPATH': '1'}
+ t0 = time.monotonic()
+ r = subprocess.run([sys.executable,
+ str(Path(TEST_DIR).parents[0] / 'mtp_test.py'),
+ '--uid', 'CAFE01', '--timeout', '3'],
+ capture_output=True, text=True, timeout=60, env=env)
+ elapsed = time.monotonic() - t0
+ self.assertLess(elapsed, 30, f'did not honour --timeout 3 ({elapsed:.1f}s)')
+ self.assertNotEqual(r.returncode, 0)
+ # The assertions above are satisfied by an immediate CRASH, which is exactly what
+ # shipped through this test once: `pass` left gio unbound and the next line
+ # dereferenced it. Assert the behaviour the docstring names -- it POLLED for the
+ # device (so it spent its budget) and did not die on a traceback.
+ self.assertGreater(elapsed, 2.0,
+ f'exited without polling ({elapsed:.1f}s) -- it crashed')
+ self.assertNotIn('Traceback', r.stderr)
+ self.assertIn('MTP device not found', r.stdout + r.stderr)
+
+
+class RunWhileContract(unittest.TestCase):
+ """The read-while-we-write runner. Its child can still outlast SIGKILL -- but unlike
+ the thread it replaced, an abandoned child is a real process in its own session, so
+ the containment sweep finds it and the report names it."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+
+ def test_an_error_in_work_is_not_swallowed(self):
+ """A `return` inside the reap's `finally` discarded it: an assert in the CDC
+ write half vanished and the caller went on to compare data it never sent."""
+ def boom():
+ raise AssertionError('the write failed')
+ with self.assertRaises(AssertionError):
+ self.hil_util.run_alongside(['sh', '-c', 'printf X'], boom, 5)
+
+ def test_the_child_is_reaped_even_when_work_raises(self):
+ seen = {}
+
+ def boom():
+ raise AssertionError('x')
+ with self.assertRaises(AssertionError):
+ self.hil_util.run_alongside(['sleep', '20'], boom, 1)
+ # nothing of ours is left running: the reap ran on the error path too
+ import subprocess
+ out = subprocess.run(['pgrep', '-f', '^sleep 20'], capture_output=True, text=True)
+ seen['strays'] = [p for p in out.stdout.split() if p]
+ self.assertEqual(seen['strays'], [], 'work() raising leaked the child')
+
+ def test_an_abandoned_child_is_in_its_own_session(self):
+ """killpg on it reaps whatever it spawned, and it cannot take our group with it."""
+ import subprocess
+ pgids = {}
+
+ def check():
+ time.sleep(0.2)
+ pgids['child'] = os.getpgid(self._proc_pid)
+
+ real_popen = subprocess.Popen
+
+ def spy(argv, **kw):
+ p = real_popen(argv, **kw)
+ self._proc_pid = p.pid
+ return p
+ self.addCleanup(setattr, subprocess, 'Popen', real_popen)
+ subprocess.Popen = spy
+ self.hil_util.run_alongside(['sleep', '0.5'], check, 5)
+ subprocess.Popen = real_popen
+ self.assertNotEqual(pgids['child'], os.getpgid(0))
+
+
+class StrandedPathMemoInvalidates(unittest.TestCase):
+ """The memo lives in read_sysfs, so every bounded reader gets it -- call-site memos
+ meant each new scanner had to remember (get_printer_dev and the throughput probe did
+ not). And it MUST expire on re-enumeration: the key is a bus path, which does not
+ change when a device comes back on the same port, so a memo that never invalidates
+ makes a board the branch's own HUNG reflash just recovered permanently invisible."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.td = TemporaryDirectory()
+ self.addCleanup(self.td.cleanup)
+ for name in ('_sysfs_stranded', '_sysfs_stuck'):
+ self.addCleanup(setattr, hil_util, name, getattr(hil_util, name))
+ hil_util._sysfs_stranded = {}
+ hil_util._sysfs_stuck = 0
+
+ def test_a_stranded_path_is_not_re_read(self):
+ f = Path(self.td.name) / 'serial'
+ os.mkfifo(f) # never answers
+ self.assertIs(self.hil_util.read_sysfs(str(f), 0.3), self.hil_util.SYSFS_UNKNOWN)
+ after_first = self.hil_util._sysfs_stuck
+ t0 = time.monotonic()
+ for _ in range(3):
+ self.assertIs(self.hil_util.read_sysfs(str(f), 0.3),
+ self.hil_util.SYSFS_UNKNOWN)
+ self.assertLess(time.monotonic() - t0, 0.3, 'the memo did not short-circuit')
+ self.assertEqual(self.hil_util._sysfs_stuck, after_first,
+ 'repeat reads spent more of the blindness budget')
+
+ def test_re_enumeration_clears_it(self):
+ """A new device on the same busport gets a fresh sysfs node, hence a fresh inode.
+ Without this the memo outlives the wedge it recorded."""
+ f = Path(self.td.name) / 'serial'
+ os.mkfifo(f)
+ self.assertIs(self.hil_util.read_sysfs(str(f), 0.3), self.hil_util.SYSFS_UNKNOWN)
+ f.unlink()
+ f.write_text('CAFE01\n') # same path, new inode = re-enumerated
+ self.assertEqual(self.hil_util.read_sysfs(str(f), 0.3), 'CAFE01',
+ 'a recovered device stayed invisible')
+
+ def test_a_vanished_path_is_not_remembered_as_stranded(self):
+ f = Path(self.td.name) / 'serial'
+ os.mkfifo(f)
+ self.assertIs(self.hil_util.read_sysfs(str(f), 0.3), self.hil_util.SYSFS_UNKNOWN)
+ f.unlink()
+ self.assertIsNone(self.hil_util.read_sysfs(str(f), 0.3))
+
+
+class UsbScanIsTheOneWalk(unittest.TestCase):
+ """Three call sites each had a different subset of the three things this must get
+ right; none had all three. The expensive read is `serial` -- served under the device
+ lock a wedged usbfs ioctl holds -- so it must come LAST, only for devices the free
+ descriptor fields could not rule out, and never twice for a path that stranded."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.td = TemporaryDirectory()
+ self.addCleanup(self.td.cleanup)
+ self.root = Path(self.td.name)
+ self.reads = []
+ real = hil_util.read_sysfs
+
+ def counting(path, *a, **k):
+ self.reads.append(path)
+ return real(path, *a, **k)
+ self.addCleanup(setattr, hil_util, 'read_sysfs', real)
+ hil_util.read_sysfs = counting
+ self.addCleanup(setattr, hil_util, '_sysfs_stranded',
+ dict(hil_util._sysfs_stranded))
+ self.addCleanup(setattr, hil_util, '_sysfs_stuck', hil_util._sysfs_stuck)
+
+ def _dev(self, name, vid, pid, serial='S1', fifo=False):
+ d = self.root / name
+ d.mkdir()
+ (d / 'idVendor').write_text(vid + '\n')
+ (d / 'idProduct').write_text(pid + '\n')
+ if fifo:
+ os.mkfifo(d / 'serial') # a read that never answers
+ else:
+ (d / 'serial').write_text(serial + '\n')
+ return d
+
+ def _scan(self, **kw):
+ import glob as _g
+ real_glob = _g.glob
+ self.addCleanup(setattr, self.hil_util.glob, 'glob', real_glob)
+ self.hil_util.glob.glob = lambda pat: [str(p) for p in self.root.iterdir()]
+ return self.hil_util.usb_scan(**kw)
+
+ def test_a_mismatched_vid_pid_costs_no_serial_read(self):
+ self._dev('1-1', '1234', '5678')
+ self._dev('1-2', 'cafe', '4010', serial='UID1')
+ devs, unknown = self._scan(vid_pid=('cafe', '4010'))
+ self.assertEqual([d['serial'] for d in devs], ['UID1'])
+ self.assertFalse(unknown)
+ # the ruled-out device's locked attribute was never touched
+ self.assertNotIn(str(self.root / '1-1' / 'serial'), self.reads)
+
+ def test_a_wedged_device_stays_unproven_on_every_scan(self):
+ """The memo lives in read_sysfs now, so usb_scan still CALLS it each pass -- what
+ must not repeat is the cost. StrandedPathMemoInvalidates covers the short-circuit;
+ here the invariant is that the device stays out of the results and absence stays
+ unproven, however many times we look."""
+ from helper import hil_util
+ self._dev('1-1', 'cafe', '4010', fifo=True)
+ first = None
+ t0 = time.monotonic()
+ for _ in range(3):
+ devs, unknown = self._scan()
+ self.assertTrue(unknown, 'a stranded read must leave absence unproven')
+ self.assertEqual(devs, [])
+ if first is None:
+ first = hil_util._sysfs_stuck
+ self.assertEqual(hil_util._sysfs_stuck, first,
+ 'repeat scans spent more of the blindness budget')
+ self.assertLess(time.monotonic() - t0, 3.0, 'repeat scans re-paid the grace')
+
+
+class BoundedOpenTellsAbsentFromUnknown(unittest.TestCase):
+ """Same contract as read_sysfs, in the sibling function of the same file: a real
+ OSError is a FACT (EBUSY, ENOENT, EACCES), a blocked open is UNKNOWN. Folding both
+ into None made an ordinary EBUSY report as a USB wedge, sending the operator to
+ usb-kernel-recover for healthy hardware -- and left the stranded thread uncounted,
+ so the cap that exists to stop the fd/thread ceiling never saw it."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ self.td = TemporaryDirectory()
+ self.addCleanup(self.td.cleanup)
+
+ def test_a_real_oserror_is_a_fact(self):
+ missing = str(Path(self.td.name) / 'nope')
+ self.assertIsNone(self.hil_util.bounded_open(missing, os.O_RDONLY, 1))
+
+ def test_a_blocked_open_is_unknown_and_counted(self):
+ fifo = Path(self.td.name) / 'fifo'
+ os.mkfifo(fifo) # no reader: O_WRONLY blocks forever
+ self.addCleanup(setattr, self.hil_util, '_sysfs_stuck',
+ self.hil_util._sysfs_stuck)
+ before = self.hil_util._sysfs_stuck
+ got = self.hil_util.bounded_open(str(fifo), os.O_WRONLY, 0.3)
+ self.assertIs(got, self.hil_util.SYSFS_UNKNOWN)
+ self.assertEqual(self.hil_util._sysfs_stuck, before + 1,
+ 'a stranded open is invisible to the blindness budget')
+
+
+class UsbtestSysfsReadIsCapped(unittest.TestCase):
+ """find_device re-scans every cafe:4010 peer after EVERY case, so the local twin --
+ which had no SYSFS_STUCK_MAX -- stranded a thread and an fd per wedged peer per case.
+ Delegating to hil_util gets the cap, and the deferred import keeps usbtest.py
+ importable standalone."""
+
+ def test_a_stranded_read_counts_against_the_shared_cap(self):
+ import usbtest
+ from helper import hil_util
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ wedged = Path(td.name) / 'serial'
+ os.mkfifo(wedged) # no writer: open() never returns
+ self.addCleanup(setattr, hil_util, '_sysfs_stuck', hil_util._sysfs_stuck)
+ before = hil_util._sysfs_stuck
+ # UNKNOWN, not None: folding them made a blinded scan read as "device dropped
+ # off the bus", which aborts past the HUNG reflash
+ self.assertIs(usbtest._read_sysfs_bounded(wedged, grace=0.5),
+ hil_util.SYSFS_UNKNOWN)
+ self.assertEqual(hil_util._sysfs_stuck, before + 1,
+ 'usbtest reads are invisible to the blindness budget')
+
+
+class AbandonExitSurvivesAFailedFork(unittest.TestCase):
+ """Pool() forks, and after a convoy -- every stranded read holding a thread and an fd --
+ that fork is what hits EAGAIN/ENOMEM. It now runs inside the try, so the finally can
+ reach _abandon_exit with pool and mgr still None."""
+
+ def test_none_pool_and_manager_still_write_the_banner(self):
+ # a subprocess, because _abandon_exit ends in os._exit: in-process it would take
+ # the test runner with it, before any assertion could run
+ import subprocess
+ with TemporaryDirectory() as td:
+ report = Path(td) / 'hil_report.md'
+ report.write_text('| board | test |\n|---|---|\n', encoding='utf-8')
+ src = (
+ 'import sys, types\n'
+ f'sys.path.insert(0, {str(Path(TEST_DIR).parents[0])!r})\n'
+ 'st = types.ModuleType("serial")\n'
+ 'st.Serial = type("Serial", (), {})\n'
+ 'st.SerialException = type("SerialException", (Exception,), {})\n'
+ 'st.SerialTimeoutException = type("E2", (Exception,), {})\n'
+ 'sys.modules.setdefault("serial", st)\n'
+ 'import hil_test\n'
+ f'hil_test._abandon_exit(None, None, True, 1, __import__("pathlib")'
+ f'.Path({str(report)!r}))\n')
+ r = subprocess.run([sys.executable, '-c', src], capture_output=True,
+ text=True, timeout=120)
+ self.assertEqual(r.returncode, 1, r.stderr)
+ self.assertTrue(report.read_text().startswith('**HIL run abandoned'),
+ 'the abandon banner never reached the report')
+
+ def test_kill_pool_children_tolerates_a_pool_that_never_existed(self):
+ from helper import hil_health
+ self.assertEqual(hil_health.kill_pool_children(None), 0)
+ self.assertEqual(hil_health.kill_pool_children(None, None), 0)
+
+
+class UsbtestOuterBoundIsOneValue(unittest.TestCase):
+ """The bound usbtest is TOLD and the bound run_cmd ENFORCES must be the same number.
+ Three separate expressions disagreed: --skip-flash appended no --outer-timeout at all
+ (usbtest reads 0 as no limit), and the no-recovery branch narrowed only the CHILD's
+ view while run_cmd still waited for a recovery reserve nothing on that path can
+ spend -- a pool worker and its battery permit idle for the difference."""
+
+ def _invoke(self, flasher, skip_flash=False):
+ from contextlib import contextmanager
+ from helper import hil_lock, hil_util
+
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ dev = Path(td.name) / 'dev1'
+ dev.mkdir()
+ for attr, val in (('serial', 'UID1'), ('idVendor', 'cafe'), ('idProduct', '4010')):
+ (dev / attr).write_text(val + '\n')
+
+ def patch(obj, name, value):
+ self.addCleanup(setattr, obj, name, getattr(obj, name))
+ setattr(obj, name, value)
+
+ def _permit(uid):
+ yield
+
+ seen = {}
+
+ def fake_run(cmd, **kw):
+ import subprocess
+ seen['cmd'], seen['timeout'] = cmd, kw.get('timeout')
+ return subprocess.CompletedProcess(cmd, 1, stdout=b'', stderr=b'stub')
+
+ from helper import hil_util as _hu
+ patch(_hu, 'glob', types.SimpleNamespace(glob=lambda p: [str(dev)]))
+ # the blindness latch and the stranded memo are process-global: another class's
+ # wedged-FIFO test would otherwise make every read here answer SYSFS_UNKNOWN
+ patch(_hu, '_sysfs_stuck', 0)
+ patch(_hu, '_sysfs_stranded', {})
+ patch(hil_lock, 'usbtest_permit', contextmanager(_permit))
+ patch(hil_test, 'skip_flash', skip_flash)
+ patch(hil_test, '_current_fw', '/tmp/fw.elf')
+ patch(hil_util, 'run_cmd', fake_run)
+ with self.assertRaises(hil_test.TestFail):
+ hil_test.test_device_usbtest({'name': 'b', 'uid': 'UID1', 'flasher': flasher})
+ return seen
+
+ def _outer_flag(self, cmd):
+ toks = cmd.split()
+ self.assertIn('--outer-timeout', toks, 'usbtest reads a missing bound as UNLIMITED')
+ return int(toks[toks.index('--outer-timeout') + 1])
+
+ def test_a_recoverable_board_reserves_the_recovery_budget(self):
+ seen = self._invoke({'name': 'openocd', 'vid_pid': '0x1366 0x1024'})
+ want = hil_test.USBTEST_BATTERY_BUDGET + hil_test.USBTEST_RECOVERY_BUDGET
+ self.assertEqual(self._outer_flag(seen['cmd']), want)
+ self.assertEqual(seen['timeout'], want)
+
+ def test_a_board_with_no_recovery_does_not_pay_for_one(self):
+ seen = self._invoke({'name': 'stlink', 'uid': 'X'}) # never convoy_safe
+ outer = self._outer_flag(seen['cmd'])
+ self.assertEqual(seen['timeout'], outer, 'the two bounds disagree')
+ # It does not carry the RECOVERY reserve it cannot spend...
+ self.assertLess(outer, hil_test.USBTEST_BATTERY_BUDGET
+ + hil_test.USBTEST_RECOVERY_BUDGET)
+ # ...but it MUST still exceed the child's own --budget. The battery checks the
+ # budget before dispatching, so it can overshoot by one already-started case; an
+ # equal bound SIGKILLs it just as it goes to print, turning ~29 real per-case
+ # verdicts into "usbtest did not run" and re-paying the whole battery on retry.
+ toks = seen['cmd'].split()
+ budget = int(toks[toks.index('--budget') + 1])
+ case_timeout = int(toks[toks.index('--timeout') + 1])
+ self.assertGreaterEqual(outer - budget, case_timeout,
+ 'the outer kill can land mid-case, before the JSON')
+
+ def test_skip_flash_still_bounds_the_child(self):
+ seen = self._invoke({'name': 'openocd', 'vid_pid': '0x1366 0x1024'}, skip_flash=True)
+ self.assertEqual(self._outer_flag(seen['cmd']), seen['timeout'])
+
+
+class UsbtestRetryPolicy(unittest.TestCase):
+ """The pool guard bounds ONE battery; the retry loop multiplies it by max_retry.
+ So the loop must retry only what a retry can fix."""
+
+ def _patch(self, obj, name, value):
+ # addCleanup, not a finally: a failing assert must not leave the real module
+ # patched for whatever test runs next (max_retry only exists once main() ran,
+ # so restoring it means DELETING it again)
+ if hasattr(obj, name):
+ self.addCleanup(setattr, obj, name, getattr(obj, name))
+ else:
+ self.addCleanup(delattr, obj, name)
+ setattr(obj, name, value)
+
+ def _attempts(self, exc):
+ """How many times test_example runs the test fn before giving up."""
+ import hil_flash
+ calls = []
+
+ def fake_test(board):
+ calls.append(1)
+ raise exc
+
+ self._patch(hil_flash, 'find_firmware', lambda *a, **k: Path('/nonexistent/fw.elf'))
+ self._patch(hil_test, 'skip_flash', True) # no probe, no hardware
+ self._patch(hil_test, 'max_retry', 3)
+ self._patch(hil_test, 'log_line', lambda *a, **k: None)
+ hil_test.test_fake_example = fake_test
+ self.addCleanup(delattr, hil_test, 'test_fake_example')
+ hil_test.test_example({'name': 'b', 'uid': 'u', 'flasher': {'name': 'openocd'}},
+ 'v', 'fake/example')
+ return len(calls)
+
+ def test_a_per_case_verdict_is_not_retried(self):
+ # re-running the battery only re-observes a number the JSON already reported
+ self.assertEqual(self._attempts(hil_test.TestFail('29/30', parsed=True)), 1)
+
+ def test_a_transient_failure_is_retried(self):
+ self.assertEqual(self._attempts(hil_test.TestFail('usbtest did not run')), 3)
+
+
+class UsbtestOuterKillStaysRetryable(unittest.TestCase):
+ """rc 124 is run_cmd's timer expiring, NOT proof the DUT is wedged -- a healthy
+ battery can hit it under load. Suppressing the retry to save the budget also
+ suppresses the reflash test_example does before each attempt, which is the only
+ thing left to unpoison the DUT where usbtest's in-band recovery is off."""
+
+ def setUp(self):
+ from contextlib import contextmanager
+ from helper import hil_lock
+ self.td = TemporaryDirectory()
+ self.addCleanup(self.td.cleanup)
+ dev = Path(self.td.name) / 'dev1'
+ dev.mkdir()
+ # a real (readable) fake sysfs node, so the bounded reads run unmodified
+ for attr, val in (('serial', 'UID1'), ('idVendor', 'cafe'), ('idProduct', '4010')):
+ (dev / attr).write_text(val + '\n')
+ self.dev = dev
+
+ def patch(obj, name, value):
+ saved = getattr(obj, name)
+ self.addCleanup(setattr, obj, name, saved)
+ setattr(obj, name, value)
+
+ from helper import hil_util as _hu
+ patch(_hu, 'glob', types.SimpleNamespace(glob=lambda p: [str(dev)]))
+ def _permit(uid): # a real generator: a lambda returning an iterator has
+ yield # no .throw(), so any raise inside the `with` would
+ # surface as an AttributeError from contextlib instead
+ patch(hil_lock, 'usbtest_permit', contextmanager(_permit))
+ patch(hil_test, 'skip_flash', True)
+
+ def test_rc_124_stays_retryable(self):
+ import subprocess
+ from helper import hil_util
+ saved = hil_util.run_cmd
+ self.addCleanup(setattr, hil_util, 'run_cmd', saved)
+ hil_util.run_cmd = lambda *a, **k: subprocess.CompletedProcess(
+ 'usbtest', 124, stdout=b'', stderr=b'killed on the outer bound')
+ with self.assertRaises(hil_test.TestFail) as cm:
+ hil_test.test_device_usbtest({'name': 'b', 'uid': 'UID1',
+ 'flasher': {'name': 'openocd'}})
+ self.assertFalse(cm.exception.parsed,
+ 'the retry is the last reflash a poisoned DUT gets')
+
+ def test_a_crashed_tool_stays_retryable(self):
+ import subprocess
+ from helper import hil_util
+ saved = hil_util.run_cmd
+ self.addCleanup(setattr, hil_util, 'run_cmd', saved)
+ hil_util.run_cmd = lambda *a, **k: subprocess.CompletedProcess(
+ 'usbtest', 1, stdout=b'', stderr=b'ImportError: no module named usbtest')
+ with self.assertRaises(hil_test.TestFail) as cm:
+ hil_test.test_device_usbtest({'name': 'b', 'uid': 'UID1',
+ 'flasher': {'name': 'openocd'}})
+ self.assertFalse(cm.exception.parsed)
+
+
+class RemoteDirIsScreened(unittest.TestCase):
+ """REMOTE_DIR reaches the rig through `rm -rf`, an scp remote path and an rsync
+ remote path -- all re-split and expanded by the REMOTE shell, none of them
+ protectable by quoting the local variable. So the script screens the value once
+ instead: it must survive that re-split unchanged, and `~` must keep working."""
+
+ def _run(self, remote_dir, *args, keep_going=False):
+ import subprocess
+ with TemporaryDirectory() as td:
+ # real ssh/scp/rsync would reach the rig; these just record the argv. Exit 77
+ # unless the caller needs the script to run on to the second ssh.
+ rc = 0 if keep_going else 77
+ for tool in ('ssh', 'scp', 'rsync'):
+ write_script(Path(td) / tool, f'echo "stub-{tool} $*" >&2; exit {rc}')
+ env = {**os.environ, 'REMOTE_DIR': remote_dir, 'REMOTE': 'stub',
+ 'PATH': td + os.pathsep + os.environ['PATH']}
+ return subprocess.run(
+ ['bash', str(Path(TEST_DIR).parents[0] / 'hil_ci.sh'), *args],
+ capture_output=True, text=True, timeout=60, env=env)
+
+ def test_whitespace_is_refused(self):
+ # unscreened, the remote `rm -rf -- "$1"` gets a TRUNCATED path and deletes
+ # the wrong tree
+ r = self._run('/tmp/hil dir')
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('REMOTE_DIR', r.stderr)
+
+ def test_command_substitution_is_refused(self):
+ r = self._run('/tmp/$(touch pwned)')
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('REMOTE_DIR', r.stderr)
+
+ def test_bare_root_is_refused(self):
+ r = self._run('/')
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('REMOTE_DIR', r.stderr)
+
+ def test_a_tilde_path_is_accepted(self):
+ """The one override %q broke: `~` must reach the remote shell UNESCAPED or it
+ creates a literal '~' directory in the login dir."""
+ r = self._run('~/tinyusb-hil')
+ self.assertIn('~/tinyusb-hil', r.stderr) # got as far as the first ssh
+ self.assertNotIn('\\~', r.stderr) # %q escapes it; the remote shell won't
+
+ def test_paths_that_would_rm_rf_something_huge_are_refused(self):
+ """Passing the tilde through UNESCAPED is what makes this dangerous: the remote
+ shell expands `~/` to the login dir, so `rm -rf -- "$1"` takes out $HOME -- one
+ typo away from the documented REMOTE_DIR=~/dir override. A bare root, a
+ no-component path and a foreign ~user are the same class."""
+ for bad in ('~/', '~root/x', '~-', '//', '/.', '/tmp/hil/'):
+ with self.subTest(remote_dir=bad):
+ r = self._run(bad)
+ self.assertNotEqual(r.returncode, 0, f'{bad!r} was accepted')
+ self.assertIn('REMOTE_DIR', r.stderr)
+
+ def test_an_arg_containing_a_space_survives_the_remote_resplit(self):
+ """ssh joins its argv into ONE string the remote shell re-splits, so an unquoted
+ `-t 'host/cdc msc'` arrives as two arguments and hil_test.py sees a stray word
+ where it expects the config path."""
+ r = self._run('/tmp/tinyusb-hil', '-t', 'host/cdc msc', keep_going=True)
+ run_line = [l for l in r.stderr.splitlines() if 'bash -s --' in l][-1]
+ self.assertIn(r'host/cdc\ msc', run_line)
+
+
+class CaveatSurvivesAccumulate(unittest.TestCase):
+ """CI reruns with --accumulate: the sidecar keeps every earlier attempt's cells, but the
+ banner was recomputed per attempt. A first attempt on a degraded rig and a clean rerun
+ therefore published the degraded attempt's PASSES with no caveat on them -- and the
+ generated .failed spec reruns only failures, so those cells are never re-earned."""
+
+ def _rows(self, board, cell):
+ return [(board, 0, 0, [(board, {cell: 'OK'}, '1s')], 0)]
+
+ def test_an_earlier_attempts_caveat_is_still_on_the_report(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ banner = '> **Rig note.** 2 process(es) in D state at start.\n'
+
+ hil_test.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', banner)
+ self.assertIn('Rig note', (rd / hil_test.REPORT_MD).read_text())
+
+ # the rerun: clean rig, so this attempt contributes no banner of its own
+ md = hil_test.accumulate_report(self._rows('boardB', 'cdc_msc'), rd, False, '', '')
+ self.assertIn('boardA', md) # the earlier cells are kept ...
+ self.assertIn('Rig note', md,
+ 'the caveat the earlier cells were collected under was dropped')
+
+ def test_the_same_caveat_twice_is_not_stacked(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ banner = '> **Rig note.** 2 process(es) in D state at start.\n'
+ hil_test.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', banner)
+ md = hil_test.accumulate_report(self._rows('boardB', 'cdc_msc'), rd, False, '', banner)
+ self.assertEqual(md.count('Rig note'), 1)
+
+
+class BlindWorkerReachesTheReport(unittest.TestCase):
+ """A worker that exhausts its bounded-read budget answers SYSFS_UNKNOWN for every
+ attribute, so its "device not found" means "could not tell". That reached the log and
+ the per-cell failure text but NOT the table -- and the table is what gets pasted into
+ the PR. Seen live: run 31794359407 went blind in 4 workers and published 26 red cells
+ with no mention of it, several of them caused by the blindness rather than the board."""
+
+ def test_no_note_when_every_worker_could_see(self):
+ mret = [('boardA', 0, [], [], 1.0, False), ('boardB', 0, [], [], 1.0, False)]
+ self.assertEqual(hil_test._blind_note(mret), '')
+
+ def test_the_note_names_the_boards_whose_verdicts_are_not_evidence(self):
+ mret = [('boardA', 0, [], [], 1.0, True), ('boardB', 0, [], [], 1.0, False),
+ ('boardC', 1, [], [], 1.0, True)]
+ note = hil_test._blind_note(mret)
+ self.assertIn('boardA', note)
+ self.assertIn('boardC', note)
+ self.assertNotIn('boardB', note) # it could see; do not smear its result
+ self.assertTrue(note.endswith('\n'), 'banners are line-oriented')
+
+ def test_both_row_widths_survive_the_report_writers(self):
+ """The blindness flag widened the worker's result tuple to 6, but the pool-timeout
+ path still synthesises 5-field rows for boards that never reported and feeds them
+ to the same two writers. A fixed-width unpack in either one raises INSIDE the
+ containment path, which is where a raise costs every board's results."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ wide = ('boardA', 1, ['device/cdc_msc'], [('boardA', {'cdc_msc': '❌'}, '2s')], 2.0, True)
+ narrow = ('stuck', 1, [], None, 0) # what the timeout path builds
+ hil_test._write_failed_spec(rd / 'x.failed', rd, [wide, narrow])
+ md = hil_test.accumulate_report([wide], rd, True, '', hil_test._blind_note([wide]))
+ self.assertIn('boardA', md)
+ self.assertIn('not all verdicts are evidence', md.lower())
+
+ def test_the_stray_note_names_the_board_and_survives_narrow_rows(self):
+ """Survivors ride back on the result tuple because main()'s own sweep runs after
+ the report is written on both abort paths -- the banner appended there was
+ computed and discarded."""
+ wide = ('boardA', 0, [], [], 1.0, False, 2)
+ clean = ('boardB', 0, [], [], 1.0, False, 0)
+ note = hil_test._stray_note([wide, clean])
+ self.assertIn('boardA', note)
+ self.assertNotIn('boardB', note)
+ self.assertIn('2', note)
+ self.assertEqual(hil_test._stray_note([clean]), '')
+ self.assertEqual(hil_test._stray_note([('stuck', 1, [], None, 0)]), '')
+
+ def test_the_timeout_paths_synthetic_rows_do_not_crash_it(self):
+ """The pool-timeout path builds (name, 1, [], None, 0) for boards that never
+ reported -- five fields, no blindness to report -- and hands those around."""
+ self.assertEqual(hil_test._blind_note([('stuck', 1, [], None, 0)]), '')
+
+
+class PoolGuardKeepsWhatFinished(unittest.TestCase):
+ """The guard's 30-minute predecessor fired on 5 of the last 8 HIL jobs, so this is the
+ common failure, not an edge case: map_async discarded every board that had finished and
+ left the re-run spec unwritten, so CI re-tested all ~26 to find the one that wedged.
+
+ Calls hil_test.drain_pool -- the loop main() actually runs. The predecessor of this test
+ built its own ThreadPool and its own drain loop and asserted on those, so deleting the
+ production drain outright left it green."""
+
+ class _It:
+ """Stands in for imap_unordered: yields, then blocks past any deadline."""
+
+ def __init__(self, ready):
+ self.ready, self.i = ready, 0
+
+ def next(self, timeout=None):
+ if self.i < len(self.ready):
+ self.i += 1
+ return self.ready[self.i - 1]
+ raise MpTimeoutError
+
+ def test_finished_rows_survive_a_guard_expiry(self):
+ boards = [{'name': 'fast1'}, {'name': 'fast2'}, {'name': 'wedged'}]
+ rows = [('fast1', 0, [], [], 1.0, False), ('fast2', 0, [], [], 1.0, False)]
+ with self.assertRaises(hil_test.PoolDrainTimeout) as cm:
+ hil_test.drain_pool(self._It(rows), boards, time.monotonic() + 5)
+ self.assertEqual([r[0] for r in cm.exception.finished], ['fast1', 'fast2'])
+
+ def test_an_expired_deadline_stops_before_asking_for_more(self):
+ """Left <= 0 must not be handed to it.next() as a zero/negative timeout."""
+ boards = [{'name': 'a'}, {'name': 'b'}]
+ it = self._It([('a', 0, [], [], 1.0, False)])
+ with self.assertRaises(hil_test.PoolDrainTimeout) as cm:
+ hil_test.drain_pool(it, boards, time.monotonic() - 1) # already past
+ self.assertEqual(cm.exception.finished, [])
+ self.assertEqual(it.i, 0, 'asked the pool for a result after the deadline')
+
+ def test_rows_collected_before_the_deadline_expires_are_kept_too(self):
+ """The OTHER raise site: boards finish, then the clock runs out between results.
+ Both sites must carry the rows -- a bare raise here loses a worker-width of rig
+ time just as map_async did, and the it.next() path alone does not prove it."""
+ class Slow(self._It):
+ def next(self, timeout=None):
+ time.sleep(0.2) # each result eats into the deadline
+ return super().next(timeout)
+
+ boards = [{'name': n} for n in ('a', 'b', 'c', 'd')]
+ rows = [(n, 0, [], [], 1.0, False) for n in ('a', 'b', 'c', 'd')]
+ with self.assertRaises(hil_test.PoolDrainTimeout) as cm:
+ hil_test.drain_pool(Slow(rows), boards, time.monotonic() + 0.3)
+ self.assertTrue(cm.exception.finished, 'rows collected before the expiry were lost')
+
+ def test_every_board_finishing_returns_them_all(self):
+ boards = [{'name': 'a'}, {'name': 'b'}]
+ rows = [('a', 0, [], [], 1.0, False), ('b', 1, [], [], 2.0, False)]
+ got = hil_test.drain_pool(self._It(rows), boards, time.monotonic() + 5)
+ self.assertEqual(got, rows)
+
+
+class WedgedBoardCosts(unittest.TestCase):
+ """Two decisions the containment latch makes, tested as decisions rather than through
+ test_board's loop -- the loop-level predecessor of these tests reimplemented that loop
+ and asserted on its own copy, which is how both defects survived it."""
+
+ def setUp(self):
+ self.addCleanup(setattr, hil_test, 'board_wedged', hil_test.board_wedged)
+
+ def test_a_board_that_wedged_still_counts_as_an_error(self):
+ """It rendered a red cell but returned err_count 0, so main()'s sys.exit(err_count)
+ reported success and _write_failed_spec (`if err > 0`) left the board out of the
+ re-run entirely: a rig holding a D-state process published as a clean pass."""
+ hil_test.board_wedged = 'usbtest HUNG'
+ # no real flasher: skip_flash isolates the accounting from hil_flash
+ self.addCleanup(setattr, hil_test, 'skip_flash', hil_test.skip_flash)
+ hil_test.skip_flash = True
+ # a firmware path must resolve or test_example returns 'skip (no binary)' before
+ # ever reaching the retry loop this is about
+ self.addCleanup(setattr, hil_flash, 'find_firmware', hil_flash.find_firmware)
+ hil_flash.find_firmware = lambda *a, **k: Path('fw.elf')
+
+ def boom(*a, **k):
+ raise hil_test.TestFail('usbtest did not run') # unparsed: retryable
+
+ self.addCleanup(setattr, hil_test, 'test_device_usbtest', hil_test.test_device_usbtest)
+ hil_test.test_device_usbtest = boom
+ board = {'name': 'b', 'uid': 'U', 'flasher': {'name': 'openocd'}, 'tests': []}
+ err, _status, _metric = hil_test.test_example(board, 'b', 'device/usbtest')
+ self.assertEqual(err, 1, 'a wedged board contributed nothing to the exit status')
+
+ def test_the_teardown_park_does_not_flash_a_wedged_board(self):
+ """The park is a flash like any other: on a D-state-held node it blocks, survives
+ SIGKILL and leaves a stray -- added by the path that just declared the board wedged
+ and skipped every test for exactly that reason."""
+ hil_test.board_wedged = ''
+ self.assertTrue(hil_test._should_park(False), 'a healthy board must still park')
+ hil_test.board_wedged = 'usbtest HUNG'
+ self.assertFalse(hil_test._should_park(False),
+ 'the teardown park would flash through the poisoned node')
+ self.assertFalse(hil_test._should_park(True), '--skip-flash must still suppress it')
+
+
+class WedgeVerdictReachesTheLatch(unittest.TestCase):
+ """usbtest computes `unrecovered_hang` but never reported it, so hil_test inferred the
+ latch from `not recovery and 'HUNG' in out` and missed three cases: recovery ran and
+ FAILED (convoy-safe boards -- max32666fthr HUNG in the 08-14 run), the `inconclusive`
+ abort (which sets the flag but leaves no case at status HUNG), and an unparsable JSON,
+ which is the outer-timeout kill and the case where a wedge is most likely."""
+
+ def setUp(self):
+ self.addCleanup(setattr, hil_test, 'board_wedged', hil_test.board_wedged)
+ hil_test.board_wedged = ''
+
+ def _run(self, stdout, rc=0):
+ from helper import hil_lock, hil_util
+ class R:
+ returncode = rc
+ stderr = b''
+ R.stdout = stdout.encode()
+ self.addCleanup(setattr, hil_util, 'run_cmd', hil_util.run_cmd)
+ hil_util.run_cmd = lambda *a, **k: R()
+ # usbtest_enumerated is nested in test_device_usbtest, so stub what it calls
+ self.addCleanup(setattr, hil_util, 'usb_scan', hil_util.usb_scan)
+ hil_util.usb_scan = lambda **k: ([{'busport': '1-1', 'dir': '/x', 'vid': 'cafe',
+ 'pid': '4010', 'serial': 'U'}], False)
+ self.addCleanup(setattr, hil_lock, 'usbtest_permit', hil_lock.usbtest_permit)
+ from contextlib import contextmanager
+ hil_lock.usbtest_permit = contextmanager(lambda uid: iter([None]))
+ board = {'name': 'b', 'uid': 'U', 'flasher': {'name': 'openocd', 'vid_pid': '0x1 0x2'}}
+ try:
+ hil_test.test_device_usbtest(board)
+ except Exception:
+ pass
+ return hil_test.board_wedged
+
+ def test_a_reported_wedge_latches_even_when_recovery_ran(self):
+ """`recovery` True means the flags were PASSED, not that they worked."""
+ js = '{"serial":"U","speed":"480","tier":1,"passed":1,"failed":1,"notrun":0,' '"wedged":true,"cases":[{"num":1,"status":"FAIL"}]}'
+ self.assertTrue(self._run(js), 'a reported wedge did not latch')
+
+ def test_no_wedge_reported_does_not_latch(self):
+ js = '{"serial":"U","speed":"480","tier":1,"passed":2,"failed":0,"notrun":0,' '"wedged":false,"cases":[]}'
+ self.assertFalse(self._run(js))
+
+ def test_an_unparseable_battery_that_mentions_HUNG_still_latches(self):
+ """rc 124 mid-print: no JSON to read, and this is the likeliest real wedge."""
+ self.assertTrue(self._run('TEST 10 HUNG: device wedged mid-transfer', rc=124))
+
+
+class WedgedBoardCannotReportAPass(unittest.TestCase):
+ """The latch alone is not enough: it is set BEFORE the pass return, so an all-green
+ battery that still wedged returned `PASS 30/30`. That board then contributes 0 to
+ err_count, is omitted from the .failed re-run spec (which keys on err > 0), and the job
+ exits 0 with a D-state holder on the rig -- the exact silence this branch exists to end.
+ usbtest's `inconclusive` and `ambiguous` aborts fire AFTER the last case, so nothing
+ back-fills a BUDGET entry to make failed/notrun non-zero."""
+
+ def setUp(self):
+ self.addCleanup(setattr, hil_test, 'board_wedged', hil_test.board_wedged)
+ hil_test.board_wedged = ''
+
+ def _cell(self, js):
+ """Returns ('pass', cell) or ('fail', message)."""
+ from helper import hil_lock, hil_util
+ class R:
+ returncode = 0
+ stderr = b''
+ R.stdout = js.encode()
+ self.addCleanup(setattr, hil_util, 'run_cmd', hil_util.run_cmd)
+ hil_util.run_cmd = lambda *a, **k: R()
+ self.addCleanup(setattr, hil_util, 'usb_scan', hil_util.usb_scan)
+ hil_util.usb_scan = lambda **k: ([{'busport': '1-1', 'dir': '/x', 'vid': 'cafe',
+ 'pid': '4010', 'serial': 'U'}], False)
+ self.addCleanup(setattr, hil_lock, 'usbtest_permit', hil_lock.usbtest_permit)
+ from contextlib import contextmanager
+ hil_lock.usbtest_permit = contextmanager(lambda uid: iter([None]))
+ board = {'name': 'b', 'uid': 'U', 'flasher': {'name': 'openocd', 'vid_pid': '0x1 0x2'}}
+ try:
+ return ('pass', hil_test.test_device_usbtest(board))
+ except hil_test.TestFail as e:
+ return ('fail', str(e))
+
+ def test_an_all_pass_battery_that_wedged_is_not_a_pass(self):
+ kind, detail = self._cell('{"serial":"U","speed":"480","tier":1,"passed":30,'
+ '"failed":0,"notrun":0,"wedged":true,"cases":[]}')
+ self.assertEqual(kind, 'fail', f'a wedged board reported a green cell: {detail}')
+ self.assertIn('wedged', detail)
+
+ def test_an_all_pass_battery_that_did_not_wedge_is_still_a_pass(self):
+ """The guard must key on the latch, not merely on having parsed a battery."""
+ kind, cell = self._cell('{"serial":"U","speed":"480","tier":1,"passed":30,'
+ '"failed":0,"notrun":0,"wedged":false,"cases":[]}')
+ self.assertEqual(kind, 'pass', f'a healthy board was failed: {cell}')
+ self.assertIn('30/30', cell)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/hil/test/test_hil_health.py b/test/hil/test/test_hil_health.py
new file mode 100644
index 000000000..5695cad6d
--- /dev/null
+++ b/test/hil/test/test_hil_health.py
@@ -0,0 +1,636 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for hil_health.py — pure logic against a synthetic /proc, no hardware. A real
+# wedge cannot be manufactured on demand, so the detectors are exercised against fabricated
+# inputs. hil_health is stdlib-only on purpose, so all of this runs on a bare CI runner
+# with nothing skipped. Run directly:
+# python3 test/hil/test/test_hil_health.py
+import os
+import signal
+import sys
+import threading
+import time
+import subprocess
+import unittest
+from multiprocessing import Pool
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+# the module under test lives in the parent dir (test/hil), not here
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from helper import hil_health
+
+REAL_PROC = hil_health.PROC
+
+
+def make_proc(root: Path, procs: dict, with_pid1: bool = True) -> None:
+ """Build a synthetic /proc. `procs` maps pid -> (comm, state, cmdline); a None comm or
+ cmdline omits that file. `with_pid1=False` simulates a restricted /proc (hidepid=2),
+ where an empty scan must not be read as an all-clear."""
+ for pid, (comm, state, cmdline) in procs.items():
+ d = root / str(pid)
+ d.mkdir()
+ if comm is not None:
+ (d / 'comm').write_text(comm + '\n')
+ if cmdline is not None:
+ (d / 'cmdline').write_bytes(cmdline)
+ # field 2 is comm in parens; the state letter follows it. Deliberately use a comm
+ # containing ')' so a naive split() would pick the wrong field.
+ (d / 'stat').write_text(f'{pid} (we)ird) {state} 1 1 0 0 -1 0 0\n')
+ if with_pid1 and 1 not in procs:
+ d = root / '1'
+ d.mkdir()
+ (d / 'comm').write_text('systemd\n')
+ (d / 'cmdline').write_bytes(b'/sbin/init\0')
+ (d / 'stat').write_text('1 (systemd) S 0 1 0 0 -1 0 0\n')
+ (root / 'not-a-pid').mkdir()
+
+
+class PatchCase(unittest.TestCase):
+ """For classes that patch PROCESS-GLOBAL state (os.kill, time.sleep, subprocess.Popen).
+
+ addCleanup, never tearDown: tearDown does NOT run when setUp raises, so a no-op
+ os.kill or time.sleep would survive into every later test in this blocking pre-commit
+ suite -- turning one setUp failure into a cascade of nonsense results."""
+
+ def patch(self, obj, name, value):
+ self.addCleanup(setattr, obj, name, getattr(obj, name))
+ setattr(obj, name, value)
+
+ def restore(self, obj, name):
+ """Same guarantee for state a TEST BODY assigns directly: register the restore
+ from setUp so it holds even when the assert between fails."""
+ self.addCleanup(setattr, obj, name, getattr(obj, name))
+
+
+class ProcCase(unittest.TestCase):
+ """Every subclass repoints hil_health.PROC at a temp tree; restore it so a later test
+ cannot silently keep scanning a deleted directory."""
+
+ def tearDown(self):
+ hil_health.PROC = REAL_PROC
+
+
+class ShutdownPool(unittest.TestCase):
+ def test_returns_true_when_the_pool_terminates(self):
+ pool = Pool(processes=1)
+ try:
+ self.assertTrue(hil_health.shutdown_pool(pool, grace=30))
+ finally:
+ pool.terminate()
+
+ def test_returns_false_instead_of_blocking_forever(self):
+ """The real failure is a worker in uninterruptible sleep, which cannot be created
+ from userspace. What matters is that shutdown_pool gives up on the deadline rather
+ than hanging, because the caller must then abandon the pool to free the job slot."""
+ # Cancellable, not time.sleep(3600): shutdown_pool returns while its daemon thread
+ # is still inside terminate(), and an uninterruptible sleep there outlives the test.
+ # The next test alphabetically forks a real Pool, so the leaked thread made it
+ # fork-from-multithreaded ('DeprecationWarning: ... may lead to deadlocks in the
+ # child') and its result order-dependent. addCleanup releases it either way.
+ release = threading.Event()
+ self.addCleanup(release.set)
+
+ class NeverDies:
+ def terminate(self):
+ release.wait(3600)
+
+ start = time.monotonic()
+ self.assertFalse(hil_health.shutdown_pool(NeverDies(), grace=0.5))
+ self.assertLess(time.monotonic() - start, 10)
+
+ def test_a_raising_terminate_counts_as_failure(self):
+ """The thread dies on the exception, so is_alive() goes False -- which would report
+ success for a pool that is just as alive as if terminate() had hung."""
+ class Explodes:
+ def terminate(self):
+ raise RuntimeError('boom')
+
+ self.assertFalse(hil_health.shutdown_pool(Explodes(), grace=5))
+
+
+class ChildProcs(ProcCase):
+ """A pool worker's own group is OUR group (multiprocessing never setpgid's), so its
+ children can only be found by walking ppid -> pgrp in /proc."""
+
+ def test_grandchildren_are_swept_too(self):
+ """usbtest.py (child, own session) spawns its recovery reflash via run_cmd (own
+ session again): the flasher is a GRANDCHILD no direct-child walk covers, and a
+ pool-guard kill mid-recovery would orphan it on the probe."""
+ got = self.scan([100], {
+ 100: ('worker', 1, 4242),
+ 200: ('usbtest.py', 100, 200), # child, own session
+ 300: ('openocd', 200, 300), # grandchild flasher, own session
+ 999: ('unrelated', 1, 999),
+ })
+ self.assertEqual(sorted(got.get(100, [])), [(200, 200), (300, 300)])
+
+ def scan(self, pids, procs):
+ """`procs` maps pid -> (comm, ppid, pgrp); a None comm omits the stat file."""
+ with TemporaryDirectory() as td:
+ root = Path(td)
+ for p, (comm, ppid, pgrp) in procs.items():
+ d = root / str(p)
+ d.mkdir()
+ if comm is not None:
+ (d / 'stat').write_text(f'{p} ({comm}) S {ppid} {pgrp} 0 0 -1 0 0\n')
+ (root / 'not-a-pid').mkdir()
+ hil_health.PROC = root
+ return hil_health.child_procs(pids)
+
+ def test_finds_direct_children_only(self):
+ got = self.scan([100], {
+ 100: ('python3', 1, 4242), # the worker itself
+ 201: ('openocd', 100, 201), # its detached flasher
+ 202: ('usbtest.py', 100, 202), # a second detached session
+ 303: ('unrelated', 7, 303), # someone else's child
+ })
+ self.assertEqual({100: [(201, 201), (202, 202)]},
+ {k: sorted(v) for k, v in got.items()})
+
+ def test_covers_every_parent_in_one_walk(self):
+ """One pass for all workers, not one pass each: this runs on the free-the-runner
+ path, and per-parent walks would also see different snapshots."""
+ got = self.scan([100, 101], {
+ 201: ('openocd', 100, 201),
+ 202: ('JLinkExe', 101, 202),
+ })
+ self.assertEqual(got, {100: [(201, 201)], 101: [(202, 202)]})
+
+ def test_parses_a_comm_containing_spaces_and_parens(self):
+ """A naive split() on the whole line would read the wrong fields."""
+ got = self.scan([100], {500: ('we ) ird', 100, 500)})
+ self.assertEqual(got, {100: [(500, 500)]})
+
+ def test_reports_a_child_that_shares_our_group(self):
+ """subprocess.run children (arecord, iperf) get no new session, so they land in
+ our group. They must still be REPORTED -- kill_pool_children signals them by pid,
+ since killpg on that group would take down the run itself."""
+ got = self.scan([100], {201: ('arecord', 100, 4242)})
+ self.assertEqual(got, {100: [(201, 4242)]})
+
+ def test_tolerates_unreadable_and_truncated_entries(self):
+ got = self.scan([100], {
+ 201: (None, 0, 0), # stat missing (exited mid-scan)
+ 202: ('openocd', 100, 202), # still found
+ })
+ self.assertEqual(got, {100: [(202, 202)]})
+
+ def test_returns_empty_when_proc_is_unreadable(self):
+ hil_health.PROC = Path('/nonexistent-proc-for-test')
+ self.assertEqual(hil_health.child_procs([100]), {})
+
+
+class FakeProc:
+ """Stands in for a multiprocessing worker: kill_pool_children goes through
+ is_alive() and Process.kill(), whose internal returncode guard is what protects
+ against signalling a recycled pid."""
+
+ def __init__(self, pid, alive=True, wedged=False):
+ self.pid = pid
+ self._alive = alive
+ self._wedged = wedged # D state: ignores SIGKILL, so is_alive() stays True
+ self.killed = False
+
+ def is_alive(self):
+ return self._alive
+
+ def kill(self):
+ self.killed = True
+ # A signalled worker DIES unless it is wedged. Modelling every worker as an
+ # unkillable survivor sent all of them down the confirm/sudo ladder, which is
+ # what let literal pids reach the real os.kill.
+ if not self._wedged:
+ self._alive = False
+
+
+class KillWorkerChildren(PatchCase):
+ # os.getpgid/killpg are stubbed for the whole class: FakeProc pids are literals like
+ # 101, which are live pids on a real machine, so an unstubbed killpg SIGKILLs a real
+ # process GROUP. That happened while writing this and killed the test run itself.
+ """What the workers spawned, killed while their parents are still alive.
+
+ Verified premise: Pool.terminate() reaps a worker that is merely waiting in
+ communicate() on a wedged flasher, reparenting that flasher to init -- so this must
+ run BEFORE shutdown_pool(), or the ppid link is gone and a successful terminate()
+ skips the cleanup entirely."""
+
+ OWN_PGID = 4242
+
+ def setUp(self):
+ # _kill_and_confirm's grace poll must not touch the real /proc: fake pid
+ # 900 can be a live process on the host, which stalls the poll for the full grace
+ # and prints a false survivor warning into the blocking pre-commit hook.
+ self.proc_tmp = TemporaryDirectory()
+ self.addCleanup(self.proc_tmp.cleanup)
+ self.patch(hil_health, 'PROC', Path(self.proc_tmp.name)) # empty: unreadable -> gone
+ self.patch(hil_health, 'CONFIRM_KILL_GRACE', 0.05)
+ # the sweep runs two passes with a real gap; the fakes never respawn, so
+ # stub the wait rather than pay it in every test
+ self.patch(hil_health.time, 'sleep', lambda _s: None)
+ self.groups, self.pids = [], []
+ self.children = {} # worker pid -> [(pid, pgid), ...]
+ self.patch(hil_health, 'child_procs', lambda pids: self.children)
+ self.patch(os, 'killpg', lambda pgid, sig: self.groups.append((pgid, sig)))
+ self.patch(os, 'kill', lambda pid, sig: self.pids.append((pid, sig)))
+ self.patch(os, 'getpgid', lambda pid: self.OWN_PGID)
+ # overwritten directly by some test bodies below (eperm/boom fakes)
+ self.restore(hil_health, '_kill_and_confirm')
+
+ def test_kills_a_detached_child_by_group(self):
+ """Flashers are spawned with start_new_session=True, so one killpg also reaps
+ whatever they spawned; a plain kill would leave them holding the probe with no
+ timeout enforcer left alive."""
+ w = FakeProc(101)
+ self.children = {101: [(900, 900)]}
+
+ class FakePool:
+ _pool = [w]
+ self.assertEqual(hil_health.kill_worker_children(FakePool()), 0) # none survived
+ # by GROUP, so whatever the flasher spawned dies with it
+ self.assertEqual(self.groups, [(900, signal.SIGKILL)])
+ # and then confirmed by pid: killpg reports success when it reached ANY member,
+ # so the group kill alone is not evidence this one died
+ self.assertIn((900, 0), self.pids)
+ self.assertFalse(w.killed) # the WORKER is not this one's job
+
+ def test_a_root_owned_group_is_still_confirmed_and_reported(self):
+ """killpg on an all-root session raises EPERM: the sudo wrapper died and only its
+ root members remain. That is the one case this handler exists for, so it must
+ still reach the confirm step -- otherwise the holder that strands the NEXT job is
+ the one holder the report never names."""
+ w = FakeProc(101)
+ self.children = {101: [(900, 900)]}
+
+ def eperm(pgid, sig):
+ raise PermissionError
+ os.killpg = eperm
+
+ class FakePool:
+ _pool = [w]
+ hil_health.kill_worker_children(FakePool())
+ # confirmed by pid: a liveness probe on the member killpg could not touch
+ self.assertIn((900, 0), self.pids)
+
+ def test_kills_a_same_group_child_by_pid(self):
+ """arecord/iperf/gio go through plain subprocess.run and stay in OUR group, where
+ killpg would take down the run itself -- but they must still die, or a blocked
+ arecord keeps holding the wedged device."""
+ w = FakeProc(101)
+ self.children = {101: [(900, self.OWN_PGID)]}
+
+ class FakePool:
+ _pool = [w]
+ hil_health.kill_worker_children(FakePool())
+ self.assertEqual(self.groups, []) # never our own group
+ # SIGKILL, then a (pid, 0) probe: signalling is not dying, so the kill is always
+ # confirmed -- see _kill_and_confirm.
+ self.assertIn((900, signal.SIGKILL), self.pids)
+ self.assertIn((900, 0), self.pids)
+
+
+ def test_signals_pids_only_when_our_group_is_unknown(self):
+ """If getpgid(0) fails we cannot tell our group from a detached one, so killpg is
+ never safe -- fall back to per-pid signals rather than guessing."""
+ def boom(pid):
+ raise OSError('no pgid')
+ os.getpgid = boom
+ w = FakeProc(101)
+ self.children = {101: [(900, 900)]}
+
+ class FakePool:
+ _pool = [w]
+ hil_health.kill_worker_children(FakePool())
+ self.assertEqual(self.groups, [])
+ self.assertIn((900, signal.SIGKILL), self.pids)
+
+ def test_covers_a_dead_workers_orphans(self):
+ """A worker reaped between the snapshot and now leaves its flasher running. The
+ children are keyed off the snapshot, not off is_alive(), so they still die."""
+ w = FakeProc(101, alive=False)
+ self.children = {101: [(900, 900)]}
+
+ class FakePool:
+ _pool = [w]
+ self.assertEqual(hil_health.kill_worker_children(FakePool()), 0) # none survived
+ self.assertEqual(self.groups, [(900, signal.SIGKILL)])
+
+ def test_a_survivor_is_returned_so_the_report_can_say_the_rig_is_dirty(self):
+ """A stray that ignores SIGKILL is in D state on a usbfs node or holds a probe, and
+ it persists into the NEXT job. The count used to be discarded by the caller (the
+ return was the signalled-child count, which nothing read), so the only trace was a
+ line in the log -- and the run still published a table that looks clean."""
+ w = FakeProc(101)
+ # TWO strays, only ONE unkillable: signalled=2, survivors=1, so this cannot pass
+ # by accident on the old return value
+ self.children = {101: [(900, 900), (901, 901)]}
+ self.patch(hil_health, '_kill_and_confirm', lambda pids: [p for p in pids if p == 901])
+
+ class FakePool:
+ _pool = [w]
+ self.assertEqual(hil_health.kill_worker_children(FakePool()), 1)
+
+ def test_no_signal_when_the_workers_spawned_nothing(self):
+ w = FakeProc(101) # no self.children entry
+
+ class FakePool:
+ _pool = [w]
+ self.assertEqual(hil_health.kill_worker_children(FakePool()), 0)
+ self.assertEqual((self.groups, self.pids), ([], []))
+
+ def test_includes_the_managers_children(self):
+ mgr_proc = FakeProc(402)
+ self.children = {402: [(900, 900)]}
+
+ class FakePool:
+ _pool = []
+
+ class FakeManager:
+ _process = mgr_proc
+ self.assertEqual(hil_health.kill_worker_children(FakePool(), FakeManager()), 0)
+
+
+class ConfirmTailIsOneGrace(PatchCase):
+ """The grace is ONE window for the whole set, not one per pid. Paid serially it
+ scaled with stray count: 30 strays x 16 workers spent ~154s inside the path whose
+ only job is to free the runner's single job slot -- which is exactly the
+ 'multi-stray convoy tail is minutes' the CI ceilings budget +30 min for."""
+
+ def setUp(self):
+ self.proc_tmp = TemporaryDirectory()
+ self.addCleanup(self.proc_tmp.cleanup)
+ root = Path(self.proc_tmp.name)
+ # every fake pid is alive and NOT a zombie, so all of them outlast the grace
+ make_proc(root, {900 + i: ('flasher', 'D', b'openocd\x00') for i in range(20)})
+ self.patch(hil_health, 'PROC', root)
+ self.patch(hil_health, 'CONFIRM_KILL_GRACE', 0.3)
+ self.patch(os, 'kill', lambda pid, sig: None) # never signal a real pid
+
+ def test_twenty_survivors_cost_one_grace_not_twenty(self):
+ pids = [900 + i for i in range(20)]
+ t0 = time.monotonic()
+ still = hil_health._kill_and_confirm(pids)
+ elapsed = time.monotonic() - t0
+ self.assertEqual(sorted(still), pids) # all reported, none lost
+ self.assertLess(elapsed, 0.3 * 4,
+ f'the grace is paid per pid ({elapsed:.2f}s for 20)')
+
+
+class KillPoolChildren(PatchCase):
+ """The worker processes themselves.
+
+ Verified premise: an orphaned pool worker keeps the CI runner's stdout pipe open, so a
+ reader never sees EOF even after the parent exits.
+
+ Fakes throughout: FakeProc.kill() only sets a flag, so nothing here can signal a real
+ process. That matters historically -- an earlier revision drove this through
+ os.pidfd_open with literal pids (101, 102), which exist on a real machine, so the suite
+ was asking the kernel to signal unrelated system processes and was saved only by EPERM.
+ Keep the fake in charge of kill(); never let a test reach os.kill/os.killpg with a
+ live pid. FakeProc.kill() alone is NOT enough for that: it leaves is_alive() True, so
+ the pid reaches the confirm/sudo ladder, which signals for real. Stub that too."""
+
+ def setUp(self):
+ # Pids 101/102/201 are ordinary user processes on a container or a fresh runner --
+ # and pre-commit.yml runs this suite on GitHub's. Unstubbed, the ladder ran
+ # os.kill(101, SIGKILL) and forked `sudo -n kill -9 101` on an account with
+ # passwordless sudo, and the assertions passed only because those pids happen to
+ # be unkillable kernel threads here.
+ self.signals = []
+ self.patch(os, 'kill', lambda pid, sig: self.signals.append((pid, sig)))
+ self.patch(os, 'killpg', lambda pgid, sig: self.signals.append((pgid, sig)))
+ self.proc_tmp = TemporaryDirectory()
+ self.addCleanup(self.proc_tmp.cleanup)
+ self.patch(hil_health, 'PROC', Path(self.proc_tmp.name)) # empty: unreadable -> gone
+ self.patch(hil_health, 'CONFIRM_KILL_GRACE', 0.05)
+
+ def test_a_healthy_worker_never_reaches_the_signalling_ladder(self):
+ """The premise every assertion below rests on. Process.kill() is the fake's job;
+ only a worker that SURVIVES it goes on to raw os.kill/sudo, and these pids are
+ literals that belong to somebody else."""
+ a, b = FakeProc(101), FakeProc(102)
+
+ class FakePool:
+ _pool = [a, b]
+ hil_health.kill_pool_children(FakePool())
+ self.assertEqual(self.signals, [], 'a literal pid reached the raw-signal ladder')
+
+ def test_signals_every_live_worker(self):
+ a, b = FakeProc(101), FakeProc(102)
+
+ class FakePool:
+ _pool = [a, b]
+ # 0, not 2: the RETURN is confirmed survivors, and workers that die to SIGKILL are
+ # not survivors. The operator verdict ("power-cycle the host") hangs off this.
+ self.assertEqual(hil_health.kill_pool_children(FakePool()), 0)
+ self.assertTrue(a.killed and b.killed)
+
+ def test_a_wedged_worker_is_reported_as_a_survivor(self):
+ """The number the power-cycle verdict is worded on."""
+ make_proc(Path(self.proc_tmp.name), {301: ('python3', 'D', b'python3 hil_test.py\x00')})
+ wedged = FakeProc(301, wedged=True)
+
+ class FakePool:
+ _pool = [wedged]
+ self.assertEqual(hil_health.kill_pool_children(FakePool()), 1)
+
+ def test_skips_a_reaped_worker(self):
+ """Process.kill() re-checks returncode internally, but skipping a dead child keeps
+ the harness from signalling a pid the OS may have recycled."""
+ live, dead = FakeProc(201), FakeProc(202, alive=False)
+
+ class FakePool:
+ _pool = [live, dead]
+ self.assertEqual(hil_health.kill_pool_children(FakePool()), 0)
+ self.assertTrue(live.killed)
+ self.assertFalse(dead.killed)
+
+ def test_also_kills_the_manager(self):
+ """Manager() is a separate child holding the same descriptors, and os._exit skips
+ its finalizer, so leaving it behind defeats the whole purpose. The RETURN is the
+ confirmed-survivor count (the caller words a power-cycle verdict on it), so a
+ clean kill of both reports 0."""
+ worker, mgr_proc = FakeProc(401), FakeProc(402)
+
+ class FakePool:
+ _pool = [worker]
+
+ class FakeManager:
+ _process = mgr_proc
+ self.assertEqual(hil_health.kill_pool_children(FakePool(), FakeManager()), 0)
+ self.assertTrue(worker.killed and mgr_proc.killed)
+ self.assertTrue(mgr_proc.killed)
+
+ def test_tolerates_a_pool_without_workers(self):
+ class NoPool:
+ _pool = None
+ self.assertEqual(hil_health.kill_pool_children(NoPool()), 0)
+
+
+class WriteTimeoutReport(unittest.TestCase):
+ def test_prefix_carries_the_preflight_diagnosis(self):
+ """The timeout aborts before accumulate_report, so without the prefix the artifact
+ and the PR comment lose the one line saying WHY the pool never finished."""
+ with TemporaryDirectory() as td:
+ d = Path(td)
+ hil_health.write_timeout_report(d, [{'name': 'b1'}], 4200, 'r.md',
+ prefix='> **wedged usb_hub_wq worker.**\n')
+ out = (d / 'r.md').read_text()
+ self.assertTrue(out.startswith('> **wedged usb_hub_wq worker.**'))
+ self.assertIn('timed out after 4200s', out)
+ self.assertIn('- b1', out)
+
+ def test_writes_a_report_where_there_would_be_none(self):
+ with TemporaryDirectory() as td:
+ hil_health.write_timeout_report(Path(td), [{'name': 'ra6m5_ek'}], 4200,
+ 'hil_report.md')
+ md = (Path(td) / 'hil_report.md').read_text()
+ self.assertIn('4200s', md)
+ self.assertIn('ra6m5_ek', md)
+
+ def test_keeps_a_previous_attempts_table(self):
+ with TemporaryDirectory() as td:
+ path = Path(td) / 'hil_report.md'
+ path.write_text('| board | cdc_msc |\n')
+ hil_health.write_timeout_report(Path(td), [{'name': 'b1'}], 4200, 'hil_report.md')
+ md = path.read_text()
+ self.assertIn('abandoned', md)
+ self.assertIn('| board | cdc_msc |', md)
+ self.assertLess(md.index('abandoned'), md.index('| board |'))
+
+ def test_custom_banner_is_used(self):
+ with TemporaryDirectory() as td:
+ hil_health.write_timeout_report(Path(td), [], 0, 'hil_report.md',
+ banner='**refused to start.**\n')
+ self.assertIn('refused to start', (Path(td) / 'hil_report.md').read_text())
+
+ def test_unwritable_dir_does_not_raise(self):
+ """The caller may be about to os._exit; losing the report must not also lose the
+ exit path."""
+ hil_health.write_timeout_report(Path('/proc/nonexistent/nope'), [], 0, 'x.md')
+
+
+class WorkerSweepsItsOwnChildren(unittest.TestCase):
+ """maxtasksperchild=1 makes a worker exit the moment its task returns, so by the time
+ main()'s finally sweeps, the strays have been reparented to init and are off the pool's
+ ppid tree entirely. Measured over 4 tasks: pool._pool held two FRESH workers with zero
+ overlap with the four that ran, child_procs() returned {}, the sweep reported 0, and all
+ four strays were alive. Inside the worker the ppid link is still there."""
+
+ def test_a_detached_child_is_killed_and_confirmed(self):
+ kid = subprocess.Popen(['sleep', '120'], start_new_session=True)
+ self.addCleanup(lambda: kid.poll() is None and kid.kill())
+ time.sleep(0.3) # let it appear in /proc
+
+ stray = hil_health.kill_own_children()
+
+ self.assertEqual(stray, 0, 'a killable stray was reported as a survivor')
+ kid.wait(timeout=5) # TimeoutExpired here means it outlived us
+ self.assertIsNotNone(kid.poll())
+
+ def test_no_children_is_not_an_error(self):
+ self.assertEqual(hil_health.kill_own_children(), 0)
+
+
+class PermitReleasesOnlyWhatItTook(unittest.TestCase):
+ """The bounded acquire skips a slot it could not get ('proceeding over-subscribed') and
+ deliberately leaves it out of `taken`, but __exit__ released every slot in self.slots.
+ multiprocessing.Semaphore is unbounded, so each timeout permanently widened that
+ controller's permit -- the throttle this branch NARROWED (FLASH_PARALLEL 8->4,
+ USBTEST_PARALLEL 4->2) for xHCI bandwidth margin."""
+
+ def test_a_timed_out_slot_is_not_released_on_exit(self):
+ from helper import hil_lock
+ import multiprocessing
+
+ sems = [multiprocessing.Semaphore(1)]
+ sems[0].acquire() # width 1, already held: the next wait times out
+ self.addCleanup(setattr, hil_lock, 'PERMIT_TIMEOUT', hil_lock.PERMIT_TIMEOUT)
+ hil_lock.PERMIT_TIMEOUT = 0.1
+
+ permit = hil_lock.controller_permit(sems, 'UID')
+ permit.slots = [0]
+ with permit:
+ pass
+
+ # one holder still holds it, so a correct exit leaves it unavailable
+ self.assertFalse(sems[0].acquire(timeout=0.1),
+ 'the permit released a slot it never acquired: width grew')
+
+
+class RecoveryPrefersResetOverReflash(unittest.TestCase):
+ """Probe reset is the preferred cure: non-destructive (the wedged firmware survives for
+ autopsy), no flash wear, no risk of a bad park image (a wfe/wfi park has bricked SWD on
+ mimxrt1064_evk and max32666fthr through a power cycle), and measured at 128-129 ms
+ against a full erase+program. It also fits in budgets a reflash does not."""
+
+ def setUp(self):
+ import usbtest # test/hil is already on sys.path (see top of file)
+ self.u = usbtest
+
+ def test_reset_is_attempted_before_the_reflash(self):
+ steps = self.u.recovery_steps('openocd', time_left=600)
+ self.assertEqual([s[0] for s in steps], ['reset', 'flash'])
+
+ def test_a_budget_too_small_to_reflash_still_gets_the_reset(self):
+ """The old gate skipped recovery whole when a reflash did not fit, leaving the
+ holder in place; a reset needs a fraction of the budget."""
+ steps = self.u.recovery_steps('openocd', time_left=self.u.RECOVER_FLASH_TIMEOUT - 1)
+ self.assertEqual([s[0] for s in steps], ['reset'])
+
+ def test_no_budget_at_all_yields_nothing(self):
+ self.assertEqual(self.u.recovery_steps('openocd', time_left=1), [])
+
+ def test_a_flasher_with_no_reset_primitive_goes_straight_to_reflash(self):
+ steps = self.u.recovery_steps('nosuchflasher', time_left=600)
+ self.assertEqual([s[0] for s in steps], ['flash'])
+
+
+class RecoveryDoesNotClaimAResetItDidNotDo(unittest.TestCase):
+ """reset_esptool and reset_lm4flash return rc 0 without resetting anything, so a plan
+ that includes them makes the log say "resetting <board> via <flasher>" for a step that
+ did nothing. wedged_pids() arbitrates, so behaviour was already right -- the record was
+ not, and a false record is what this branch keeps having to unpick."""
+
+ def setUp(self):
+ import usbtest
+ self.u = usbtest
+
+ def test_a_no_op_reset_primitive_is_not_scheduled(self):
+ self.assertEqual([k for k, _ in self.u.recovery_steps('esptool', 600)], ['flash'])
+ self.assertEqual([k for k, _ in self.u.recovery_steps('lm4flash', 600)], ['flash'])
+
+ def test_a_real_reset_primitive_still_is(self):
+ self.assertEqual([k for k, _ in self.u.recovery_steps('openocd', 600)],
+ ['reset', 'flash'])
+
+
+class SudoSoftNeverRaises(unittest.TestCase):
+ """Two of its four call sites are inside run_case's timeout handler, where ANY raise
+ costs the HUNG verdict, the recovery and the JSON report -- and sudo() sys.exit()s on
+ 'a password is required', which is a raise like any other."""
+
+ def setUp(self):
+ import usbtest
+ self.u = usbtest
+ self.addCleanup(setattr, usbtest, 'sudo', usbtest.sudo)
+
+ def _check(self, exc):
+ def boom(*a, **k):
+ raise exc
+ self.u.sudo = boom
+ r = self.u._sudo_soft(['dmesg']) # must not propagate
+ self.assertEqual(r.returncode, 1)
+
+ def test_systemexit_from_a_password_prompt_is_contained(self):
+ self._check(SystemExit('sudo needs a password'))
+
+ def test_oserror_is_contained(self):
+ self._check(OSError('no such binary'))
+
+ def test_subprocess_error_is_contained(self):
+ self._check(subprocess.SubprocessError('timed out'))
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=1)
diff --git a/test/hil/test_hil_select.py b/test/hil/test/test_hil_select.py
index 5e6b16759..9a1261878 100644
--- a/test/hil/test_hil_select.py
+++ b/test/hil/test/test_hil_select.py
@@ -1,19 +1,28 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Unit tests for hil_select.py — pure logic, no hardware, no git. Run directly:
-# python3 test/hil/test_hil_select.py
+# python3 test/hil/test/test_hil_select.py
+#
+# Imports stay stdlib + hil_select/hil_util/hil_flash ONLY: the pre-commit hil-test
+# hook runs this suite, on GitHub's bare runner in the pre-commit workflow as well as
+# locally, and that runner has no pyserial/pymtp. hil_flash is admissible because it
+# is stdlib + hil_util only (test_hil_util.BottomLayer enforces the stdlib closure of
+# both) and the roster-dispatch tests need its flash_* table; never import hil_test,
+# which pulls pyserial.
import glob
import json
import os
import sys
import unittest
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+# the modules under test live in the parent dir (test/hil), not here
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import hil_flash
-import hil_select
-from hil_examples import device_tests, dual_tests, host_test
+from helper import hil_select
+from helper.hil_util import device_tests, dual_tests
-REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))))
def real_rosters():
@@ -42,9 +51,14 @@ def roster_flashers():
def on_roster(tc, *names):
"""The subset of `names` currently in the live rig rosters, skipping the test
- when none are. Parking/unparking a board is routine rig maintenance and must not
- fail this suite: CI runs it right before the selector and treats a failure as
- 'selector unusable', dropping PR scoping and annotating the run."""
+ when none are, because parking/unparking a board is routine rig maintenance.
+
+ That skip now matters MORE than it used to, not less: this suite is a blocking
+ pre-commit hook AND build.yml's selector steps gate on it (a failing suite falls
+ open to the full matrix), so an assertion that depends on a specific board being
+ present goes red on every PR -- including src/-only ones that never touched the
+ rig -- until someone fixes the roster. Keep roster-dependent assertions behind
+ on_roster."""
have = {b['name'] for _, boards in real_rosters() for b in boards}
got = [n for n in names if n in have]
if not got:
@@ -275,7 +289,7 @@ class TestArgsEmission(unittest.TestCase):
with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
f.write('src/class/cdc/cdc_device.c\n')
path = f.name
- r = subprocess.run([sys.executable, os.path.join(REPO, 'test/hil/hil_select.py'),
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'test/hil/helper/hil_select.py'),
'--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
@@ -283,6 +297,12 @@ class TestArgsEmission(unittest.TestCase):
self.assertFalse(out['full'])
self.assertIn('tinyusb.json', out['args'])
self.assertTrue(any('cdc_device' in line for line in out['reasons']))
+ # A core-class diff must select boards THROUGH THE CLI: the in-process tests
+ # inject their own repo root, so only this subprocess path catches a broken
+ # repo_root derivation -- which once made every repo-relative glob match
+ # nothing and turned this exact diff into a silent full-HIL skip.
+ self.assertTrue(out['boards'],
+ 'CLI selected zero boards for a src/class change: repo_root broken?')
os.unlink(path)
@@ -439,7 +459,7 @@ class TestPortFamiliesCoverage(unittest.TestCase):
class TestRealRosterOnlyListTests(unittest.TestCase):
"""Regression for roster-only-list tests (e.g. espressif's hid_composite_freertos)
- being invisible to the selector because it only knew the shared hil_examples lists."""
+ being invisible to the selector because it only knew the shared hil_util lists."""
def test_only_list_example_change_selects_it(self):
boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
s = hil_select.classify(['examples/device/hid_composite_freertos/src/main.c'], REPO, real_rosters())
@@ -552,6 +572,63 @@ class TestPortWithoutFamilyIsFull(unittest.TestCase):
self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
+class TestOpenocdVidPid(unittest.TestCase):
+ """The roster's optional flasher `vid_pid` field (openocd-verbatim, e.g.
+ "0x1a86 0x8010", more pairs appended) pins openocd's probe discovery so it
+ never opens foreign usbfs nodes. It must be emitted BEFORE the args: the
+ rescue cfgs run `init` internally (rp2350-rescue.cfg errors on any
+ config-stage command after its init; rp2040.cfg under RESCUE scans before a
+ trailing flag is even parsed), and no rig cfg sets a competing list
+ (the 2026-08-10 convoy mechanism)."""
+
+ def test_vid_pid_flag_precedes_args(self):
+ cmd = hil_flash._openocd_cmd_base(
+ {'uid': 'S1', 'args': '-f target/wch-riscv.cfg', 'vid_pid': '0x1a86 0x8010'})
+ self.assertIn('-c "adapter usb vid_pid 0x1a86 0x8010" -f target/wch-riscv.cfg', cmd)
+ self.assertTrue(cmd.endswith('-f target/wch-riscv.cfg'), cmd)
+
+ def test_rescue_cfg_command_keeps_vid_pid_before_init(self):
+ """rescue_openocd swaps the target cfg for one that runs `init` internally;
+ a vid_pid flag after the args would error there (rp2350) or be skipped
+ (rp2040) -- in exactly the wedged-rig scenario the pin exists for."""
+ flasher = {'name': 'openocd', 'uid': 'S1', 'vid_pid': '0x2e8a 0x000c',
+ 'args': '-c "set RESCUE 1" -f target/rp2040.cfg'}
+ cmd = hil_flash._openocd_cmd_base(flasher)
+ self.assertLess(cmd.index('adapter usb vid_pid'), cmd.index('-f target/'), cmd)
+
+ def test_vid_pid_multiple_pairs(self):
+ cmd = hil_flash._openocd_cmd_base(
+ {'uid': 'S1', 'args': '-f i.cfg', 'vid_pid': '0x2e8a 0x000c 0x2e8a 0x000d'})
+ self.assertIn('-c "adapter usb vid_pid 0x2e8a 0x000c 0x2e8a 0x000d"', cmd)
+
+ def test_no_field_no_flag_but_warns(self):
+ # the roster lint only covers the committed rosters; a dev PC's local.json entry
+ # without the field must at least say what it is giving up -- on STDERR, since
+ # hil_test captures stdout per test and would swallow it on a passing run
+ import io
+ from contextlib import redirect_stderr
+ hil_flash._VID_PID_WARNED.discard('S-warn')
+ cap = io.StringIO()
+ with redirect_stderr(cap):
+ cmd = hil_flash._openocd_cmd_base({'uid': 'S-warn', 'args': '-f i.cfg'})
+ self.assertNotIn('vid_pid', cmd)
+ self.assertIn('vid_pid', cap.getvalue())
+
+ def test_roster_openocd_entries_all_pin_vid_pid(self):
+ # every openocd probe on the rig has a known VID/PID; a new entry without the
+ # pin silently reintroduces open-everything discovery
+ for path, board in roster_flashers():
+ f = board['flasher']
+ # tinyusb.json only: hfp.json is the hifiphile rig owner's file, and a
+ # blocking repo-wide lint over someone else's roster would red every PR the
+ # moment they add an openocd board (hil_flash treats the field as optional)
+ if f['name'] == 'openocd' and path.endswith('tinyusb.json'):
+ self.assertIn('vid_pid', f,
+ f"{path}: {board['name']} openocd flasher lacks vid_pid")
+ self.assertNotIn('vid_pid', f.get('args', ''),
+ f"{path}: {board['name']} packs vid_pid into args; use the field")
+
+
class TestRosterFlashersDispatch(unittest.TestCase):
"""hil_test and hil_pool_check resolve a board's flasher with a bare
getattr(hil_flash, f'flash_{name}'), and hil_test does it inside a redirect_stdout —
@@ -577,5 +654,36 @@ class TestRosterFlashersDispatch(unittest.TestCase):
f'with no hil_flash.FLASHER_SUFFIX entry')
+class FlasherRecoverEntry(unittest.TestCase):
+ """Optional roster key: a SECOND flasher used only to deliver recovery while a usbfs
+ node is poisoned. Boards whose primary flasher cannot get past a convoy (jlink,
+ stlink, lm4flash) name an openocd entry here instead of changing how they are
+ normally flashed."""
+
+ def test_recover_flasher_prefers_the_optional_entry(self):
+ prim = {'name': 'jlink', 'uid': 'X', 'args': '-device MIMXRT1064xxx6A'}
+ rec = {'name': 'openocd', 'uid': 'X', 'args': '-f interface/jlink.cfg -f target/foo.cfg'}
+ self.assertEqual(hil_flash.recover_flasher({'flasher': prim, 'flasher_recover': rec}), rec)
+ self.assertEqual(hil_flash.recover_flasher({'flasher': prim}), prim)
+
+ def test_openocd_over_jlink_is_convoy_safe_without_a_pin(self):
+ """libjaylink discovery returns early unless idVendor == 0x1366 (SEGGER) and the PID
+ is in its table, and only THEN calls libusb_open (discovery_usb.c) -- it never opens
+ a foreign node. `adapter usb vid_pid` is a no-op for this driver: jlink.c reads
+ adapter_serial / usb address / usb location, never the vid/pid."""
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd', 'args': '-f interface/jlink.cfg -f target/stm32f4x.cfg'}))
+
+ def test_openocd_with_neither_a_pin_nor_jlink_is_not_safe(self):
+ self.assertFalse(hil_flash.convoy_safe(
+ {'name': 'openocd', 'args': '-f interface/stlink.cfg -f target/stm32h7x.cfg'}))
+
+ def test_the_existing_rules_are_unchanged(self):
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd', 'vid_pid': '0x2e8a 0x000c', 'args': '-f interface/cmsis-dap.cfg'}))
+ self.assertFalse(hil_flash.convoy_safe({'name': 'jlink', 'uid': 'X'}))
+ self.assertTrue(hil_flash.convoy_safe({'name': 'esptool'}))
+
+
if __name__ == '__main__':
unittest.main(verbosity=1)
diff --git a/test/hil/test/test_hil_util.py b/test/hil/test/test_hil_util.py
new file mode 100644
index 000000000..9c3d5edef
--- /dev/null
+++ b/test/hil/test/test_hil_util.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for hil_util.run_cmd's binary/split_stderr/quiet modes — real subprocesses, no
+# hardware. Stdlib + hil_util only (hil_util is stdlib-only), so the pre-commit hil-test
+# hook can run this on GitHub's bare runner. Run directly:
+# python3 test/hil/test/test_hil_util.py
+import io
+import os
+import shutil
+import tempfile
+import sys
+import time
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+
+# the module under test lives in the parent dir's helper/ package
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from helper import hil_util
+
+
[email protected](os.name == 'nt', 'POSIX shell commands')
+class RunCmdModes(unittest.TestCase):
+ def test_default_mode_unchanged(self):
+ r = hil_util.run_cmd('printf out; printf err >&2')
+ self.assertEqual(r.returncode, 0)
+ self.assertIsInstance(r.stdout, str)
+ # stderr merged into stdout, as every existing caller expects
+ self.assertIn('out', r.stdout)
+ self.assertIn('err', r.stdout)
+
+ def test_binary_stdout_is_exact_bytes(self):
+ # \xff is not valid UTF-8: text mode would mangle it via errors='replace'
+ r = hil_util.run_cmd(r"printf 'a\377\000b'", binary=True)
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(r.stdout, b'a\xff\x00b')
+
+ def test_split_stderr_keeps_stdout_clean(self):
+ r = hil_util.run_cmd('printf out; printf err >&2', split_stderr=True)
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(r.stdout, 'out')
+ self.assertEqual(r.stderr, 'err')
+
+ def test_binary_split_stderr_timeout_returns_124(self):
+ t0 = time.monotonic()
+ r = hil_util.run_cmd(r"printf 'p\377re'; printf warn >&2; sleep 30",
+ binary=True, split_stderr=True, timeout=1)
+ self.assertEqual(r.returncode, 124)
+ # killpg + bounded communicate: well under sleep 30
+ self.assertLess(time.monotonic() - t0, 15)
+ self.assertIn(b'p\xffre', r.stdout or b'')
+ # stderr collected before the timeout must survive the kill
+ self.assertIn(b'warn', r.stderr or b'')
+
+ def test_text_mode_timeout_stdout_stays_str(self):
+ r = hil_util.run_cmd('sleep 30', timeout=1)
+ self.assertEqual(r.returncode, 124)
+ # a text-mode caller must never get bytes back, even empty
+ self.assertIsInstance(r.stdout, str)
+
+ def test_failed_banner_includes_split_stderr(self):
+ # with split_stderr the diagnostic is in .stderr; the banner must not go blank.
+ # The text travels via env, not the command string — the banner title echoes the
+ # command, which would make a literal assertion pass vacuously.
+ os.environ['RUN_CMD_TEST_ERR'] = 'diagnostic-xyzzy'
+ self.addCleanup(os.environ.pop, 'RUN_CMD_TEST_ERR', None)
+ cap = io.StringIO()
+ with redirect_stdout(cap):
+ r = hil_util.run_cmd('printf "$RUN_CMD_TEST_ERR" >&2; exit 3', split_stderr=True)
+ self.assertEqual(r.returncode, 3)
+ self.assertIn('COMMAND FAILED', cap.getvalue())
+ self.assertIn('diagnostic-xyzzy', cap.getvalue())
+
+ def test_no_group_markers_when_stdout_is_captured(self):
+ # GitHub folds ::group:: only at line start of the JOB's real stdout. Pool
+ # workers run tests under redirect_stdout and compact the capture into one
+ # row line, where the markers land mid-line and render as literal noise.
+ saved_ci = os.environ.get('CI') # pre-exists on GitHub runners: restore, not pop
+ os.environ['CI'] = '1'
+ self.addCleanup(lambda: os.environ.update({'CI': saved_ci}) if saved_ci is not None
+ else os.environ.pop('CI', None))
+ cap = io.StringIO()
+ with redirect_stdout(cap):
+ r = hil_util.run_cmd('printf boom; exit 3')
+ self.assertEqual(r.returncode, 3)
+ self.assertIn('COMMAND FAILED', cap.getvalue())
+ self.assertNotIn('::group::', cap.getvalue())
+ self.assertNotIn('::endgroup::', cap.getvalue())
+
+ def test_quiet_suppresses_failed_banner(self):
+ # retry-loop callers report failures themselves; per-poll banners are noise
+ cap = io.StringIO()
+ with redirect_stdout(cap):
+ r = hil_util.run_cmd('printf boom >&2; exit 3', quiet=True)
+ self.assertEqual(r.returncode, 3)
+ self.assertNotIn('COMMAND FAILED', cap.getvalue())
+
+
+class BottomLayer(unittest.TestCase):
+ def test_bad_timeout_env_falls_back(self):
+ # hil_select (the PR-diff selector) imports hil_util for the example rosters;
+ # a malformed HIL_CMD_TIMEOUT must not crash the selector at import and knock
+ # CI back to the full-matrix fallback
+ import subprocess
+ r = subprocess.run(
+ [sys.executable, '-c', 'from helper import hil_util; print(hil_util.CMD_TIMEOUT)'],
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ env={**os.environ, 'HIL_CMD_TIMEOUT': 'bogus'},
+ capture_output=True, text=True, timeout=30)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ # the warning must NOT be on stdout: hil_select's stdout is machine-read JSON
+ self.assertEqual(r.stdout.strip(), '180')
+ self.assertIn('warning', r.stderr) # but a silent fallback hides the misconfiguration
+
+ def test_tinyusb_root_is_the_repo_root(self):
+ # the constant is derived from __file__ parents[N]; moving hil_util.py without
+ # adjusting N silently re-points every firmware/build path (it happened)
+ self.assertTrue((hil_util.TINYUSB_ROOT / 'examples').is_dir(), hil_util.TINYUSB_ROOT)
+ self.assertTrue((hil_util.TINYUSB_ROOT / 'test' / 'hil').is_dir(), hil_util.TINYUSB_ROOT)
+
+ def test_hil_util_is_a_single_module_instance(self):
+ # helper modules must be imported via the helper package everywhere: a plain
+ # `import hil_util` from inside helper/ creates a SECOND module object, and
+ # state like `verbose` set on one copy never reaches the other
+ import hil_flash
+ from helper import hil_pool_check
+ self.assertIs(hil_flash.hil_util, hil_util)
+ self.assertIs(hil_pool_check.hil_util, hil_util)
+ self.assertIs(hil_pool_check.hil_flash, hil_flash)
+
+ def test_bare_runner_modules_stay_stdlib_only(self):
+ # hil_examples.py used to make this structural (a list of strings cannot grow a
+ # dependency); with the rosters folded into hil_util the invariant needs teeth:
+ # everything the bare GitHub runner imports (selector + this suite) must stay
+ # stdlib + local. Adding pyserial/pymtp here breaks hil_select on CI.
+ import ast
+ hil_dir = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ # ONLY the modules the bare runner can import -- not every stem in the tree.
+ # Globbing the directory allowed `import pymtp` (and hil_test, usbtest,
+ # mtp_test) through, so the pymtp case this test names could never fail: that
+ # module runs ctypes.CDLL(find_library('mtp')) at import and raises where there
+ # is no libmtp, taking hil_select down with it.
+ local = {'helper', 'hil_util', 'hil_select', 'hil_flash',
+ 'hil_health', 'hil_lock', 'hil_pool_check'}
+ allowed = set(sys.stdlib_module_names) | local
+ # hil_pool_check included: test_hil_util_is_a_single_module_instance imports it
+ # on the bare runner, and its `import serial` is function-local for exactly
+ # this reason -- hoisting it must fail HERE, not on every PR's pre-commit CI
+ for mod in ('helper/hil_util', 'hil_flash', 'helper/hil_select',
+ 'helper/hil_health', 'helper/hil_lock', 'helper/hil_pool_check'):
+ tree = ast.parse((hil_dir / f'{mod}.py').read_text())
+ # module level only: a deferred import inside a function cannot break
+ # importability (hil_pool_check keeps `import serial` function-local
+ # for exactly that reason)
+ for node in tree.body:
+ roots = []
+ if isinstance(node, ast.Import):
+ roots = [a.name.split('.')[0] for a in node.names]
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ roots = [node.module.split('.')[0]]
+ for root in roots:
+ self.assertIn(root, allowed,
+ f'{mod}.py imports {root}, not stdlib/local - breaks the bare CI runner')
+
+
+class BoundedReadBookkeeping(unittest.TestCase):
+ """Two ways the strand accounting lied, both of which cost a blindness credit -- and
+ the process goes blind after four."""
+
+ def test_a_value_that_arrived_at_the_deadline_is_not_a_strand(self):
+ """join() returns, is_alive() is still True, but the reader HAS deposited its
+ value. read_sysfs booked a strand from is_alive() alone, so a merely-slow healthy
+ read was memoised as unreadable forever. bounded_open already gets this right."""
+ import threading, time as _t
+ before = hil_util._sysfs_stuck
+ self.addCleanup(setattr, hil_util, '_sysfs_stuck', before)
+ real_thread = threading.Thread
+
+ class Lingering(real_thread):
+ """Deposits the value, then outlives the join by a hair."""
+ def run(self):
+ super().run()
+ _t.sleep(0.6) # still alive when join(grace) returns
+
+ self.addCleanup(setattr, threading, 'Thread', real_thread)
+ threading.Thread = Lingering
+ with tempfile.NamedTemporaryFile('w', suffix='_attr', delete=False) as fh:
+ fh.write('cafe\n')
+ path = fh.name
+ self.addCleanup(os.unlink, path)
+ hil_util.read_sysfs(path, grace=0.2)
+ self.assertEqual(hil_util._sysfs_stuck, before,
+ 'a value that arrived was still counted as a strand')
+
+ def test_bounded_open_does_not_re_strand_a_known_path(self):
+ """Same rule read_sysfs has: re-opening a path known to hang costs another thread,
+ another fd and another blindness credit to learn what we already know. The printer
+ test re-opens ONE lp node on every retry."""
+ d = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, d, True)
+ fifo = os.path.join(d, 'lp0')
+ os.mkfifo(fifo) # open() blocks: no writer, ever
+ self.addCleanup(setattr, hil_util, '_sysfs_stuck', hil_util._sysfs_stuck)
+ self.addCleanup(setattr, hil_util, '_sysfs_stranded', dict(hil_util._sysfs_stranded))
+ before = hil_util._sysfs_stuck
+ for _ in range(3):
+ hil_util.bounded_open(fifo, os.O_WRONLY, 0.3)
+ self.assertLessEqual(hil_util._sysfs_stuck - before, 1,
+ 'each retry spent another blindness credit on the same path')
+
+
+class RunAlongsideKeepsStderrOffThePayload(unittest.TestCase):
+ """test_device_printer_to_cdc byte-compares run_alongside's stdout against the payload
+ it wrote. Merging stderr into that stream turns any stray child stderr byte -- a
+ PYTHONWARNINGS chirp, a sitecustomize print, a venv .pth deprecation -- into
+ 'CDC->Printer wrong data', sending a maintainer after the printer class driver for an
+ interpreter warning. hil_ci.sh runs python3 with no isolating flags."""
+
+ def test_child_stderr_does_not_contaminate_stdout(self):
+ from helper import hil_util
+ argv = [sys.executable, '-c',
+ 'import sys; sys.stderr.write("noise\\n"); sys.stdout.write("PAYLOAD")']
+ r = hil_util.run_alongside(argv, lambda: time.sleep(0.2), timeout=20)
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(r.stdout, b'PAYLOAD',
+ 'child stderr leaked into the payload stream')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json
index c9b38992c..549a17cd0 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -149,6 +149,7 @@
"flasher": {
"name": "openocd",
"uid": "E6614C311B597D32",
+ "vid_pid": "0x2e8a 0x000c",
"args": "-f interface/cmsis-dap.cfg -f target/max32665.cfg",
"verify": true
}
@@ -240,6 +241,7 @@
"flasher": {
"name": "openocd",
"uid": "E6614103E72C1D2F",
+ "vid_pid": "0x2e8a 0x000c",
"args": "-f interface/cmsis-dap.cfg -f target/rp2040.cfg -c \"adapter speed 5000\"",
"verify": true
}
@@ -270,6 +272,7 @@
"flasher": {
"name": "openocd",
"uid": "E6633861A3819D38",
+ "vid_pid": "0x2e8a 0x000c",
"args": "-f interface/cmsis-dap.cfg -f target/rp2040.cfg -c \"adapter speed 5000\"",
"verify": true
},
@@ -296,6 +299,7 @@
"flasher": {
"name": "openocd",
"uid": "E6633861A3978538",
+ "vid_pid": "0x2e8a 0x000c",
"args": "-f interface/cmsis-dap.cfg -f target/rp2350.cfg -c \"adapter speed 5000\"",
"verify": true
}
@@ -326,6 +330,7 @@
"flasher": {
"name": "openocd",
"uid": "E663AC91D3359B38",
+ "vid_pid": "0x2e8a 0x000c",
"args": "-f interface/cmsis-dap.cfg -f target/rp2350.cfg -c \"adapter speed 5000\"",
"verify": true
}
@@ -407,9 +412,8 @@
"dual": false
},
"flasher": {
- "name": "openocd",
+ "name": "stlink",
"uid": "004C00343137510F39383538",
- "args": "-f interface/stlink.cfg -f target/stm32h7x.cfg",
"verify": true
}
},
@@ -422,9 +426,8 @@
"dual": false
},
"flasher": {
- "name": "openocd",
+ "name": "stlink",
"uid": "066FFF495087534867063844",
- "args": "-f interface/stlink.cfg -f target/stm32g0x.cfg",
"verify": true
},
"comment": "32-bit scheme, 2KB USB SRAM"
@@ -472,6 +475,7 @@
"flasher": {
"name": "openocd",
"uid": "A76D8F062C2A",
+ "vid_pid": "0x1a86 0x8010",
"args": "-f target/wch-riscv.cfg",
"verify": false
}
@@ -488,6 +492,7 @@
"flasher": {
"name": "openocd",
"uid": "BC4954081051",
+ "vid_pid": "0x1a86 0x8010",
"args": "-f target/wch-riscv.cfg",
"verify": false
}
@@ -508,6 +513,7 @@
"flasher": {
"name": "openocd",
"uid": "BC5DA47360D0",
+ "vid_pid": "0x1a86 0x8010",
"args": "-f target/wch-riscv.cfg",
"verify": false
}
@@ -524,6 +530,7 @@
"flasher": {
"name": "openocd",
"uid": "57468F06DC03",
+ "vid_pid": "0x1a86 0x8010",
"args": "-f target/wch-riscv.cfg",
"verify": false
}
diff --git a/test/hil/usbtest.py b/test/hil/usbtest.py
index 83ea3e24c..485e9e0e4 100755
--- a/test/hil/usbtest.py
+++ b/test/hil/usbtest.py
@@ -25,7 +25,9 @@ capability flags only unlock cases, they don't require the endpoints to exist.
import argparse
import json
+from contextlib import redirect_stdout
import os
+import pathlib
import re
import shutil
import subprocess
@@ -33,16 +35,47 @@ import sys
import time
from pathlib import Path
+sys.path.append(os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it
+
VID = 'cafe'
PID = '4010'
GZ_REF = '0525 a4a0' # copy Gadget Zero's capability profile (ctrl_out+iso+intr)
SYS_USB = Path('/sys/bus/usb/devices')
DRIVER = Path('/sys/bus/usb/drivers/usbtest')
-USB_RECOVER = Path(__file__).resolve().parents[2] / '.claude/skills/usb-kernel-recover/scripts/usb_recover.sh'
PATTERN_PARAM = Path('/sys/module/usbtest/parameters/pattern')
+RECOVER_FLASH_TIMEOUT = 90 # bound on the post-hang reflash; typical flash is 10-20s
+RECOVER_RESET_TIMEOUT = 30 # bound on the post-hang probe reset; ResetTarget measures ~130ms
+
+
+def recovery_steps(flasher_name: str, time_left: float) -> list:
+ """Ordered (kind, bound) recovery attempts that fit in `time_left`.
+
+ RESET FIRST, reflash second. A probe reset fails the in-flight URB at the source just
+ as a park-flash does, but it is non-destructive -- the firmware under test survives, so
+ the wedge can still be autopsied -- writes no flash, and cannot brick SWD the way a bad
+ park image has on mimxrt1064_evk and max32666fthr (survived a power cycle). Measured
+ 128-129 ms against a full erase+program, and it works on i.MX RT and on DWC2 alike
+ (stm32f407disco, 2026-08-16: `r; g` -> USB disconnect, re-enumerated 325 ms later).
-# Battery per tier, in run order: control sanity first, then simple bulk,
-# queued, unaligned, unlink, halt/toggle, throughput last.
+ The reset also fits budgets a reflash does not: the old gate skipped recovery entirely
+ when RECOVER_FLASH_TIMEOUT did not fit, which left the holder in place for the next
+ job. Whether either worked is decided by wedged_pids(), never by the exit code -- a
+ clean flash only proves the probe wrote the MCU.
+ """
+ import hil_flash
+ steps = []
+ reset_fn = getattr(hil_flash, f'reset_{flasher_name.lower()}', None)
+ if getattr(reset_fn, 'no_op', False):
+ reset_fn = None # a stub that returns rc 0 without resetting: do not claim it
+ if reset_fn and time_left >= RECOVER_RESET_TIMEOUT:
+ steps.append(('reset', RECOVER_RESET_TIMEOUT))
+ if time_left >= RECOVER_FLASH_TIMEOUT:
+ steps.append(('flash', RECOVER_FLASH_TIMEOUT))
+ return steps
+HELPER_TIMEOUT = 30 # default bound for sudo helpers (dmesg/modprobe/setpci/tee)
+
+# Battery per tier, in run order: control sanity, simple bulk, queued, unaligned, unlink,
+# halt/toggle, throughput last.
TIER_CASES = {
1: [0, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 17, 18, 19, 20, 11, 12, 24, 13, 29, 27, 28],
2: [14, 21],
@@ -50,10 +83,10 @@ TIER_CASES = {
4: [15, 16, 22, 23],
}
-# Per-case testusb parameters (full speed / high speed). All -s/-v values are
-# multiples of 512 so transfers stay packet-aligned at both speeds: the device
-# streams whole max-size packets and a non-aligned IN length would babble.
-# 14/21 must never run with defaults (vary >= length is -EINVAL in the kernel).
+# Per-case testusb parameters (full speed / high speed). All -s/-v values are multiples
+# of 512 so transfers stay packet-aligned at both speeds: the device streams whole max-size
+# packets and a non-aligned IN length would babble. 14/21 must never run with defaults
+# (vary >= length is -EINVAL in the kernel).
PARAMS = {
0: ('-c 1', '-c 1'),
9: ('-c 256', '-c 1000'),
@@ -88,9 +121,40 @@ RE_FAIL = re.compile(r'test (\d+) --> (\d+) \((.*)\)')
def run(cmd, **kw):
- kw.setdefault('capture_output', True)
+ # NOT subprocess.run(timeout=): CPython's post-timeout path is an UNBOUNDED wait() that
+ # never returns on a D-state child -- the hang sysfs_write's timeout exists to catch.
+ timeout = kw.pop('timeout', None)
+ data = kw.pop('input', None) # subprocess.run-only kwarg; Popen takes stdin
+ kw.pop('capture_output', None) # ditto: expressed by the PIPEs below
kw.setdefault('text', True)
- return subprocess.run(cmd, **kw)
+ kw.setdefault('encoding', 'utf-8')
+ kw.setdefault('errors', 'replace') # strict decode would raise out of _sudo_soft
+ # NO start_new_session: these helpers (dmesg, modprobe, setpci, tee) must stay in our
+ # process group so hil_test's outer killpg reaps them with us.
+ timeout = timeout if timeout is not None else HELPER_TIMEOUT
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if data is not None else None,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw)
+ try:
+ out, err = proc.communicate(input=data, timeout=timeout)
+ return subprocess.CompletedProcess(cmd, proc.returncode, out, err)
+ except subprocess.TimeoutExpired:
+ # Under sudo our child is only the wrapper; the root grandchild survives this and
+ # is left for the report and hil_pool_check to name. Close our pipe ends so an
+ # abandoned child costs no fds.
+ try:
+ proc.kill() # same group as us: never killpg, that would kill us too
+ except OSError:
+ pass
+ try:
+ proc.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ for pipe in (proc.stdout, proc.stderr, proc.stdin):
+ try:
+ if pipe is not None:
+ pipe.close()
+ except OSError:
+ pass # unkillable: abandon it, the caller reports the timeout
+ raise
def sudo(cmd, **kw):
@@ -104,29 +168,106 @@ def sudo(cmd, **kw):
def sysfs_write(path, data, check=True):
- # A driver-registry write (new_id/remove_id/bind) blocks in D state when a wedged device
- # holds its lock (driver_attach walks the bus): fail fast and loud instead of piling up
- # unkillable writers and hanging the whole run -- the rig needs USB recovery first.
+ # A driver-registry write (new_id/remove_id/bind) blocks in D state when a wedged
+ # device holds its lock: fail fast instead of piling up unkillable writers -- the rig
+ # needs USB recovery first.
+ #
+ # Verified in v6.12.96: unbind_store -> device_driver_detach ->
+ # device_release_driver_internal -> __device_driver_lock (drivers/base/dd.c), which
+ # takes device_lock() -- the UNINTERRUPTIBLE variant, unlike the sysfs read path -- and
+ # ALSO device_lock(parent), because usb_bus_type sets need_parent_lock = true
+ # (drivers/usb/core/driver.c:2048). So one such write against a wedged device blocks
+ # unkillably while holding the HUB's lock: that is the mechanism by which a single
+ # wedged port takes its whole bus down, and why this fails fast instead.
try:
r = sudo(['tee', str(path)], input=data, timeout=15)
except subprocess.TimeoutExpired:
sys.exit(f'write "{data}" > {path} blocked >15s: USB subsystem is wedged '
- '(a D-state device lock exists). Recover the rig (usb_recover.sh) '
+ '(a D-state device lock exists). Recover the rig (usb-kernel-recover skill) '
'before running batteries.')
if check and r.returncode != 0:
sys.exit(f'write "{data}" > {path} failed: {r.stderr.strip()}')
return r.returncode == 0
+def _read_sysfs_bounded(path, grace=1.0):
+ """Bounded sysfs attribute read. The value, or None, or hil_util.SYSFS_UNKNOWN.
+
+ Delegates to hil_util.read_sysfs (imported here, like every helper import in this
+ file) so both properties hold: the strand cap -- find_device re-scans after EVERY
+ case, so a 30-case battery against a wedged peer would otherwise strand dozens of
+ threads and fds -- and UNKNOWN kept distinct from None. Folding UNKNOWN into None made
+ a blinded scan read as "device dropped off the bus", which aborts down a path that
+ skips the HUNG recovery entirely.
+ """
+ from helper import hil_util
+ return hil_util.read_sysfs(str(path), grace)
+
+
+_DEV_CACHE: dict = {} # serial -> sysname, see find_device
+
+
+def _reread(sysname, serial):
+ """Re-describe an already-resolved device, CONFIRMING its serial.
+
+ idVendor/idProduct/busnum/devnum/speed are lock-free (sysfs.c:688-705), so they cannot
+ block on a wedged peer -- but every identical board answers them the same, so they
+ prove nothing about identity. `serial` does, at one bounded read: a sysname is a
+ topology path, and after a renumber (controller reset, reboot) it can name a DIFFERENT
+ cafe:4010 board whose verdicts would be filed under this one. Returns None when the
+ serial is gone, mismatched or unconfirmed -- caller falls back to a full scan.
+ """
+ d = SYS_USB / sysname
+ try:
+ if ((d / 'idVendor').read_text().strip() != VID
+ or (d / 'idProduct').read_text().strip() != PID):
+ return None
+ dev_serial = _read_sysfs_bounded(d / 'serial')
+ if not isinstance(dev_serial, str) or dev_serial.lower() != serial.lower():
+ return None # gone, mismatched, or unconfirmable -> full scan decides
+ return {
+ 'sysname': sysname,
+ 'serial': dev_serial,
+ 'node': '/dev/bus/usb/%03d/%03d' % (int((d / 'busnum').read_text()),
+ int((d / 'devnum').read_text())),
+ 'speed': (d / 'speed').read_text().strip(),
+ 'tier': int((d / 'bcdDevice').read_text().strip()[-2:], 16),
+ }
+ except (OSError, ValueError):
+ return None
+
+
def find_device(serial, first=False):
- """Locate the usbtest device in sysfs, return info dict or None."""
- matches = []
+ """Locate the usbtest device in sysfs, return info dict or None.
+
+ Cached by serial: this is called after EVERY case, and a full scan pays a bounded
+ but real `serial` read for every cafe:4010 peer on the rig. With another board
+ wedged that cost lands on a HEALTHY battery ~30 times over, truncating it into
+ BUDGET entries. The fast path pays ONE bounded read -- our own device's serial, the
+ only attribute that tells identical boards apart (see _reread).
+ """
+ if serial:
+ sysname = _DEV_CACHE.get(serial.lower())
+ if sysname:
+ hit = _reread(sysname, serial)
+ if hit:
+ return hit
+ _DEV_CACHE.pop(serial.lower(), None)
+ matches, inconclusive = [], []
for dev in SYS_USB.iterdir():
try:
if (dev / 'idVendor').read_text().strip() != VID or \
(dev / 'idProduct').read_text().strip() != PID:
continue
- dev_serial = (dev / 'serial').read_text().strip()
+ # BOUNDED: idVendor/idProduct are cached descriptors, but `serial` is served
+ # under device_lock(), so an unbounded read blocks us in D state on exactly the
+ # DUT whose hang we are here to report, losing every verdict collected so far.
+ dev_serial = _read_sysfs_bounded(dev / 'serial')
+ if dev_serial is not None and not isinstance(dev_serial, str):
+ inconclusive.append(dev.name) # unknown: NOT proof it is not ours
+ continue
+ if dev_serial is None:
+ continue
if serial and dev_serial.lower() != serial.lower():
continue
matches.append({
@@ -140,12 +281,17 @@ def find_device(serial, first=False):
except (OSError, ValueError):
continue
if not matches:
- return None
+ # "could not tell" is not "gone". The caller aborts the battery on a falsy return
+ # and that path skips the HUNG reflash, so a blinded scan would report the wedge
+ # we exist to recover from as a physical disconnect.
+ return {'inconclusive': inconclusive} if inconclusive else None
+ if serial and len(matches) == 1:
+ _DEV_CACHE[serial.lower()] = matches[0]['sysname']
if len(matches) > 1 and not first:
if serial:
- # Dual-port parts (nanoch32v203 fsdev/usbfs, ch32v307 usbhs/usbfs) briefly enumerate
- # BOTH ports with the same serial around a variant reflash; picking one arbitrarily
- # could bind the stale port. Report ambiguity so the caller retries until it drops.
+ # Dual-port parts (nanoch32v203, ch32v307) briefly enumerate BOTH ports with
+ # one serial around a variant reflash, and picking one could bind the stale
+ # port -- report ambiguity so the caller retries until it drops.
return {'ambiguous': sorted(m['sysname'] for m in matches)}
sys.exit(f'multiple {VID}:{PID} devices found, use --serial: '
+ ', '.join(m["serial"] for m in matches))
@@ -165,8 +311,8 @@ def check_host_compat(dev):
vid_did = ((pci / 'vendor').read_text().strip(), (pci / 'device').read_text().strip())
break
except (OSError, ValueError):
- # transient sysfs error (e.g. racing a re-enumeration): retry so a blip doesn't
- # silently pass an incompatible host; if the probe truly fails, fail open but say so
+ # transient sysfs error (racing a re-enumeration): retry so a blip does not
+ # silently pass an incompatible host, then fail open but say so
if attempt == 2:
print('warning: cannot probe the upstream host controller; '
'skipping the host compatibility check', file=sys.stderr)
@@ -178,19 +324,16 @@ def check_host_compat(dev):
'placed in the EHCI periodic schedule and unlinked reads complete as short '
'transfers (EREMOTEIO). Move the DUT to an xHCI port.')
if drv.startswith('xhci') and vid_did in (('0x1912', '0x0014'), ('0x1912', '0x0015')):
- # The Renesas uPD720201/uPD720202 must run its latest firmware (>= 2.0.2.6,
- # K2026090.mem; RAM-uploaded, so it reverts to ROM on every power cycle unless
- # re-loaded). On the ROM firmware its command ring intermittently dies under unlink
- # stress: a Configure Endpoint command stops completing, the hub worker deadlocks
- # holding the device lock (needs a host power cycle). Three separate boards killed
- # it this way (ch32v307 2026-07-10; ra6m5 test 24, mimxrt1015 2026-07-11). Both
- # parts expose the FW version register at PCI config offset 0x6c. NOTE this check
- # is necessary, not sufficient: board-specific batteries have killed the controller
- # on current firmware too (mimxrt1015, stop-endpoint timeout) - those are handled
- # by per-board skips in the rig config.
+ # The Renesas uPD720201/uPD720202 must run firmware >= 2.0.2.6 (K2026090.mem;
+ # RAM-uploaded, so it reverts to ROM on every power cycle): on ROM firmware its
+ # command ring dies under unlink stress and the hub worker deadlocks holding the
+ # device lock, needing a host power cycle (ch32v307 2026-07-10; ra6m5 test 24,
+ # mimxrt1015 2026-07-11). Both parts expose the FW version at PCI config 0x6c.
+ # Necessary, not sufficient -- batteries have killed the controller on current
+ # firmware too, which per-board skips in the rig config handle.
fw = None
try:
- r = sudo(['setpci', '-s', pci.name, '0x6c.l'], capture_output=True, text=True)
+ r = _sudo_soft(['setpci', '-s', pci.name, '0x6c.l'], capture_output=True, text=True)
if r.returncode == 0:
fw = int(r.stdout.strip(), 16)
except (OSError, ValueError):
@@ -211,7 +354,7 @@ def check_host_compat(dev):
def bind_usbtest(dev):
"""Bind the device's interface 0 to the usbtest driver."""
if not DRIVER.exists():
- r = sudo(['modprobe', 'usbtest'])
+ r = _sudo_soft(['modprobe', 'usbtest'])
if r.returncode != 0 or not DRIVER.exists():
sys.exit(f'cannot load usbtest module: {r.stderr.strip()}')
@@ -222,8 +365,8 @@ def bind_usbtest(dev):
sysfs_write(DRIVER / 'remove_id', f'{VID} {PID}', check=False)
sysfs_write(DRIVER / 'new_id', f'{VID} {PID} 0 {GZ_REF}')
if stale_binding:
- # bound before the re-registration: that probe captured the OLD dynamic id's capability
- # profile; unbind once (device is idle here) so the loop below reprobes the fresh one
+ # it probed against the OLD dynamic id's capability profile; unbind once (the
+ # device is idle here) so the loop below reprobes the fresh one
sysfs_write(drv / 'unbind', intf, check=False)
deadline = time.monotonic() + 3
@@ -248,51 +391,64 @@ def set_pattern(value):
'the "pattern" param, or it is not readable')
+def _sudo_soft(cmd, **kw):
+ """sudo() for calls whose failure must never abort the battery: run() re-raises
+ TimeoutExpired, and two of these are evaluated inside run_case's own timeout handler
+ -- a raise there loses the HUNG verdict, the recovery and the JSON report."""
+ try:
+ return sudo(cmd, **kw)
+ except (OSError, ValueError, subprocess.SubprocessError, SystemExit) as e:
+ # SystemExit too: sudo() sys.exit()s on 'a password is required', unwinding out of
+ # run_case's timeout handler before the HUNG verdict is recorded -- which leaves
+ # unrecovered_hang False and lets the finally run the remove_id/unbind that must
+ # never happen while a D-state device lock is held
+ print(f'{cmd[0]}: {type(e).__name__}: {e}', file=sys.stderr)
+ return subprocess.CompletedProcess(cmd, 1, '', '')
+
+
def dmesg_tail():
- r = sudo(['dmesg'])
+ r = _sudo_soft(['dmesg'])
lines = [l for l in r.stdout.splitlines() if 'usbtest' in l]
return '\n'.join(lines[-8:])
+
+
def wedged_pids(devnode):
- """Return (pids, complete): PIDs in uninterruptible sleep whose cmdline names devnode, i.e.
- still holding its usbfs device lock, and whether every /proc entry could actually be read.
+ """(pids, complete): pids still in D state on `devnode` after a recovery reflash.
+
+ Matched by device node rather than by our child's pid because run_case() may wrap
+ testusb in sudo: the Popen pid is then the wrapper and the blocked process is its
+ child. A clean flash only proves the probe wrote the MCU, not that the D-state holder
+ let go -- this is what tells the two apart.
- Matched by device node rather than by our child's pid because run_case() may wrap testusb in
- sudo, in which case the Popen pid is the wrapper and the blocked process is its child --
- killing the wrapper would make a pid-based check look clean while the real holder is stuck.
+ FAIL CLOSED. `complete` is False when an entry could be HIDDEN from us, and the caller
+ must then keep treating the hang as unrecovered: the holder is root-owned (run_case
+ uses `sudo -n` whenever the node is not writable) and a hidepid/ProtectProc mount
+ hides exactly that entry. Reporting "no holder" from a scan that could not see it
+ clears unrecovered_hang and lets cleanup run remove_id/unbind against a device whose
+ usbfs lock is still held -- which deadlocks the bus, not just this board.
- complete is False when a PermissionError hid an entry (a hidepid/ProtectProc mount, or the
- root-owned child of that same sudo). An entry we could not read might be the holder, so the
- caller must treat that as unrecovered rather than as an all-clear."""
+ Self-contained: /proc is plain text and this is one pass over it, so importing a
+ helper to do it would only add a failure mode on the recovery path.
+ """
stuck, complete = [], True
- # hidepid=2 and systemd's ProtectProc=invisible omit other users' processes from iterdir()
- # entirely -- no entry at all, so no PermissionError to catch -- and testusb runs under sudo
- # whenever the device node is not writable. The scan would then look clean while hiding the
- # very holder it exists to find. pid 1 is always root-owned, so being unable to read it means
- # enumeration is restricted and no result from this scan can be trusted as complete.
+ # A restricted /proc hides other users' entries ENTIRELY -- no entry, so no
+ # PermissionError to catch -- and testusb runs under sudo, so the holder is exactly
+ # what is hidden. Detect the restriction itself rather than its symptom.
if os.geteuid() != 0 and not os.access('/proc/1/cmdline', os.R_OK):
complete = False
- for entry in Path('/proc').iterdir():
- if not entry.name.isdigit():
- continue
- try:
- cmdline = (entry / 'cmdline').read_bytes()
- except PermissionError:
- complete = False # cannot rule this pid out
- continue
- except OSError:
- continue # raced with process exit: genuinely gone, not hidden
- if devnode.encode() not in cmdline:
- continue
+ for d in pathlib.Path('/proc').glob('[0-9]*'):
try:
- stat = (entry / 'stat').read_text()
- if stat[stat.rindex(')') + 2] == 'D': # comm may contain ')', so scan from the right
- stuck.append(int(entry.name))
+ st = (d / 'stat').read_bytes()
+ if st[st.rindex(b')') + 2:st.rindex(b')') + 3] != b'D':
+ continue
+ if devnode.encode() in (d / 'cmdline').read_bytes():
+ stuck.append(int(d.name))
except PermissionError:
- complete = False
- except (OSError, ValueError, IndexError):
- continue
+ complete = False # cannot rule this pid out
+ except (OSError, ValueError):
+ continue # raced with exit
return stuck, complete
@@ -306,17 +462,29 @@ def run_case(num, dev, testusb, quick, timeout):
cmd = ['sudo', '-n'] + cmd
result = {'num': num, 'name': CASE_NAMES[num], 'params': fs_hs}
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ # NO start_new_session: testusb must stay in OUR process group so the caller's outer
+ # killpg still reaps it; a sudo-wrapped child is escalated through sudo below instead.
+ # errors='replace': testusb output is not guaranteed UTF-8, and a strict decode would
+ # raise out of here and out of main(), printing no JSON at all (battery '0/30').
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, encoding='utf-8', errors='replace')
try:
out, _ = p.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
- p.kill()
+ # Under sudo we only kill the wrapper; its root-owned testusb keeps the inherited
+ # stdout pipe, so the reap below times out and the overrun is reported as HUNG.
+ # Accepted rather than escalated: the rig's udev rules make the device node
+ # writable, so sudo is the exception, and the harness must never sudo-kill a pid
+ # it cannot prove is its own.
+ try:
+ p.kill()
+ except OSError:
+ pass
try:
out, _ = p.communicate(timeout=5)
except subprocess.TimeoutExpired:
- # SIGKILL had no effect: the child is in uninterruptible sleep on an
- # in-kernel usbfs ioctl (device stopped responding mid-transfer).
- # Abandon it — waiting or re-signalling can never succeed.
+ # SIGKILL had no effect: the child is in uninterruptible sleep on an in-kernel
+ # usbfs ioctl. Abandon it — waiting or re-signalling can never succeed.
result.update(status='HUNG', detail=f'testusb stuck in D state after {timeout}s',
dmesg=dmesg_tail())
return result
@@ -362,7 +530,18 @@ def main():
p.add_argument('--keep-binding', action='store_true', help='leave usbtest dynamic id registered')
p.add_argument('--testusb', default=None, help='path to testusb binary')
p.add_argument('--timeout', type=int, default=120, help='per-case timeout in seconds')
+ p.add_argument('--recover-board', help='board JSON (name + flasher) for the post-hang '
+ 'reflash recovery; without it a HUNG case leaves the device wedged')
+ p.add_argument('--recover-fw', help='firmware path reflashed by the post-hang recovery')
+ p.add_argument('--outer-timeout', type=int, default=0,
+ help='the caller\'s total bound on this process; a reflash that cannot '
+ 'finish before it is skipped rather than orphaned mid-flash')
+ p.add_argument('--budget', type=int, default=0,
+ help='stop starting new cases after this many seconds (0 = no limit). '
+ 'Callers that impose their own outer timeout set this to reserve '
+ 'the remainder for the post-hang recovery path')
args = p.parse_args()
+ t_start = time.monotonic()
sys.stdout.reconfigure(line_buffering=True) # per-case results visible when piped/logged
testusb = args.testusb or shutil.which('testusb') or os.path.expanduser('~/testusb')
@@ -370,22 +549,32 @@ def main():
sys.exit('testusb binary not found: build kernel tools/usb/testusb.c '
'and install it, or pass --testusb')
- # retry briefly: right after a flash the enumeration may still be settling, and on dual-port
- # parts the other port's stale same-serial node takes a moment to drop off (see find_device)
+ # retry briefly: after a flash the enumeration may still be settling, and a dual-port
+ # part's stale same-serial node takes a moment to drop off (see find_device)
deadline = time.monotonic() + 8
while True:
dev = find_device(args.serial)
- if dev and 'ambiguous' not in dev:
+ # find_device is THREE-valued: a device, {'ambiguous': [...]}, or
+ # {'inconclusive': [...]} when bounded reads could not rule a device out. Screening
+ # only for 'ambiguous' let the inconclusive marker through as if it were a device,
+ # and the next statement subscripts dev['tier'] -> KeyError, no JSON on stdout, and
+ # hil_test reports "usbtest did not run / 0-30" for a merely-unreadable bus.
+ if dev and not ({'ambiguous', 'inconclusive'} & dev.keys()):
break
if time.monotonic() > deadline:
- if dev:
+ if dev and 'ambiguous' in dev:
sys.exit(f"multiple devices with serial {args.serial}: {', '.join(dev['ambiguous'])} "
'— stale enumeration from another port? replug or retry')
+ if dev and 'inconclusive' in dev:
+ from helper import hil_util as _hu
+ sys.exit(f"cannot tell whether {VID}:{PID} is present: bounded sysfs reads "
+ f"did not answer for {', '.join(dev['inconclusive'])}"
+ f"{_hu.sysfs_blind_note()}")
sys.exit(f'no {VID}:{PID} device' + (f' with serial {args.serial}' if args.serial else ''))
time.sleep(0.5)
- # tier drives which cases run; a stale/foreign device advertising an out-of-range tier
- # must not silently run an empty battery ('0/0 passed' would read as green in CI)
+ # a stale/foreign device advertising an out-of-range tier must not silently run an
+ # empty battery ('0/0 passed' would read as green in CI)
tier = args.tier or dev['tier']
if not 1 <= tier <= max(TIER_CASES):
sys.exit(f"device advertises tier {tier} (bcdDevice ...{tier:02x}); reflash a usbtest build "
@@ -404,8 +593,7 @@ def main():
if not args.json:
print(info)
- # probe the upstream controller before touching the device: an incompatible host
- # (MosChip MCS9990, or uPD720201 on pre-2.0.2.6 firmware) exits here, before any bind
+ # before touching the device: an incompatible host exits here, before any bind
check_host_compat(dev)
results = []
@@ -414,7 +602,15 @@ def main():
bind_usbtest(dev)
set_pattern(0) # tier 1 firmware sources zeros; also required by perf cases 27/28
- for num in cases:
+ abort_reason = None # set on any early exit; drives the BUDGET back-fill below
+ for idx, num in enumerate(cases):
+ # Only a HUNG case aborts the battery; an ordinary case timeout is a FAIL and
+ # the loop continues, each burning --timeout+5s, so without this the run can
+ # still be in the case loop when the outer timeout SIGKILLs it before it emits
+ # JSON. Checked before dispatch: worst overshoot is one case.
+ if args.budget and time.monotonic() - t_start > args.budget:
+ abort_reason = f'battery budget {args.budget}s exhausted'
+ break
results.append(run_case(num, dev, testusb, args.quick, args.timeout))
r = results[-1]
if not args.json:
@@ -422,112 +618,255 @@ def main():
extra += f" {r['mbps']} MB/s" if 'mbps' in r else ''
print(f"test {num:2d} {r['name']:22s} {r['status']:6s}{extra}")
if r['status'] == 'HUNG':
- print(f'aborting battery: kernel-side hang, device wedged mid-transfer.\n'
- f'auto-recovering: {USB_RECOVER.name} root-cycle {dev["sysname"]} '
- f'(see .claude/skills/usb-kernel-recover)', file=sys.stderr)
- # Cutting VBUS at the root port fails the in-flight URB so the usbfs ioctl returns.
- # Must run BEFORE any unbind/remove_id, which would take the device lock the stuck
- # ioctl holds and deadlock the bus.
+ abort_reason = 'battery aborted on a kernel-side hang'
+ # Reflash, NEVER a root-port cycle: resetting the MCU through the DUT's own
+ # debug probe fails the in-flight URB at the source, so the ioctl returns,
+ # the queued kill lands and the cleanup below is lock-safe -- and it reaches
+ # exactly one board, where a root-port cycle bounces every fixture under the
+ # port (and could never remove power anyway; see usb-kernel-recover).
+ # Deliberately not gated on a hub-worker check: our own stuck testusb is
+ # what drives a hub worker into usb_lock_device(), so a pre-check reads
+ # wedged by construction.
#
- # Assume unrecovered until proven otherwise, so that any early exit from this block
- # -- an OSError spawning the helper, a KeyboardInterrupt, a sudo prompt killing the
- # run -- still reaches the finally cleanup with the flag set, instead of running
- # the remove_id/unbind the comments there forbid while a device lock is held.
+ # Assume unrecovered until proven otherwise, so any early exit from this
+ # block reaches the finally with the flag set instead of running the
+ # remove_id/unbind that must not happen while a device lock is held.
unrecovered_hang = True
- # Pass the serial so the helper refuses a stale busport rather than cutting power
- # to whatever else now occupies that path. Popen rather than sudo()/subprocess.run:
- # run() would kill() then wait() unbounded on timeout, which never returns if
- # uhubctl is itself in D state -- the case the timeout exists for. Merge stderr
- # into stdout so the helper's target-identity and action lines are not lost.
- # Only pass the serial when we actually have one: an empty third argument reads as
- # "no expectation" and would silently disable the helper's stale-busport guard.
- cmd = [str(USB_RECOVER), 'root-cycle', dev['sysname']]
- if dev['serial']:
- cmd.append(dev['serial'])
- if os.geteuid() != 0:
- cmd = ['sudo', '-n'] + cmd
- try:
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
- text=True)
- except OSError as e:
- # helper missing or not executable, or sudo unavailable. unrecovered_hang is
- # already True so the finally block still skips the unsafe cleanup -- this only
- # replaces a traceback with a message that says what to fix.
- print(f'cannot run {USB_RECOVER}: {e}', file=sys.stderr)
+ print('aborting battery: kernel-side hang, device wedged mid-transfer',
+ file=sys.stderr)
+ if not (args.recover_board and args.recover_fw):
+ print('no --recover-board/--recover-fw: the device stays wedged and '
+ 'cleanup is skipped', file=sys.stderr)
+ break
+ # The reflash is bounded to RECOVER_FLASH_TIMEOUT and skipped when the
+ # caller's outer bound cannot contain it: the flasher runs in its own
+ # session, so an outer killpg mid-flash would ORPHAN it on the probe. Gate
+ # each step on the time actually LEFT -- reserving for the worst case up
+ # front skipped recovery for nearly every real hang, since the hang-prone
+ # cases run late in the tier order.
+ def _time_left():
+ if not args.outer_timeout:
+ return float('inf')
+ # what still runs after a step: run_cmd's post-kill reap (10s),
+ # the settle (5s), the sudo-escalated descendant reap run_case may
+ # have just paid (up to 7s) and the JSON write
+ return args.outer_timeout - (time.monotonic() - t_start) - 35
+
+ if _time_left() < RECOVER_RESET_TIMEOUT:
+ print('insufficient time before the outer bound for even a bounded '
+ 'reset; the device stays wedged and cleanup is skipped',
+ file=sys.stderr)
break
- rc = None
try:
- out, _ = p.communicate(timeout=60) # normal run is ~8s
- rc = p.returncode
- except subprocess.TimeoutExpired:
- p.kill()
+ board = json.loads(args.recover_board)
+ bname, fname = board['name'], board['flasher']['name']
+ import hil_flash # deferred: stdlib-only unless recovery actually runs
+ flash_fn = getattr(hil_flash, f'flash_{fname.lower()}')
+ reset_fn = getattr(hil_flash, f'reset_{fname.lower()}', None)
+ except Exception as e: # malformed/short json, import failure, unknown flasher
+ print(f'reflash recovery unavailable ({e})', file=sys.stderr)
+ break
+ # DELIVERY must be convoy-safe or the recovery makes things worse: our own
+ # testusb is D-state on this DUT's node, so a flasher that enumerates by
+ # OPENING usbfs nodes blocks on it, survives SIGKILL and is abandoned --
+ # a SECOND stray, the budget spent, the device still wedged. On 2026-08-12
+ # a vid_pid-pinned openocd was the only flasher that still reached its
+ # probe; JLinkExe's ShowEmuList returned zero. See hil_flash.convoy_safe.
+ if not hil_flash.convoy_safe(board['flasher']):
+ print(f'{fname} is not convoy-safe for delivery (it enumerates by '
+ f'opening usbfs nodes, and this DUT has a D-state holder on '
+ f'its own node): skipping the reflash rather than adding a '
+ f'second stray. Pin the roster entry with vid_pid on an '
+ f'openocd flasher to enable recovery for this board.',
+ file=sys.stderr)
+ break
+ # RESET FIRST (see recovery_steps). Non-destructive, ~130 ms, and it
+ # clears the wedge by the same mechanism as the reflash. wedged_pids is the
+ # arbiter: reset_esptool is a stub that returns rc 0 without resetting
+ # anything, so an exit code here proves nothing.
+ steps = recovery_steps(fname, _time_left())
+ if reset_fn and any(k == 'reset' for k, _ in steps):
+ print(f'auto-recovering: resetting {bname} via {fname} probe '
+ f'(non-destructive; reflash only if this does not clear it)',
+ file=sys.stderr)
try:
- out, _ = p.communicate(timeout=5)
- rc = p.returncode
- except subprocess.TimeoutExpired:
- out = ('root-cycle abandoned after 60s: uhubctl did not die to SIGKILL, so '
- 'it is wedged too and the convoy has spread beyond this device')
- if out:
- print(out.strip(), file=sys.stderr)
- if rc is not None:
- time.sleep(5) # let the bus settle and the freed ioctl unwind
- # Authoritative either way. A non-zero exit only means the device did not come
- # back within the poll (a slow bootloader will do that) -- if nothing still
- # holds the lock, the bus is usable and cleanup is safe. Conversely a zero exit
- # only proves re-enumeration, not that the D-state holder let go.
+ with redirect_stdout(sys.stderr):
+ reset_fn(board, timeout=RECOVER_RESET_TIMEOUT)
+ except TypeError:
+ with redirect_stdout(sys.stderr):
+ reset_fn(board) # older primitives take no bound
+ except Exception as e:
+ print(f'probe reset raised: {e}; falling through to the reflash',
+ file=sys.stderr)
+ time.sleep(5) # let the freed ioctl unwind
stuck, complete = wedged_pids(dev['node'])
- if stuck:
- print(f'{dev["sysname"]}: pid(s) {stuck} still in D state on '
- f'{dev["node"]} — the device lock was never released', file=sys.stderr)
- elif not complete:
- print('cannot confirm recovery: /proc is only partly readable, so a '
- 'hidden D-state holder cannot be ruled out', file=sys.stderr)
- else:
+ if complete and not stuck:
+ print('probe reset cleared the wedge; skipping the reflash '
+ '(firmware under test left intact for autopsy)',
+ file=sys.stderr)
unrecovered_hang = False
+ break
+ if _time_left() < RECOVER_FLASH_TIMEOUT:
+ print('reset did not clear it and no budget left for a reflash; '
+ 'the device stays wedged', file=sys.stderr)
+ break
+ print(f'auto-recovering: reflashing {bname} via '
+ f'{fname} (see .claude/skills/usb-kernel-recover). '
+ f'Unbudgeted by flash_permit, like the root-cycle it replaced: the '
+ f'per-controller semaphores live in hil_test\'s process.',
+ file=sys.stderr)
+ # run_cmd bounds the flash; its banners go to stdout, which in --json mode
+ # carries the result object -- keep them off it. A raising flasher (missing
+ # serial node, unwritable CWD) must not cost the battery its JSON report.
+ try:
+ with redirect_stdout(sys.stderr):
+ ret = flash_fn(board, args.recover_fw, timeout=RECOVER_FLASH_TIMEOUT)
+ except Exception as e:
+ print(f'reflash raised: {e}; the device may still be wedged', file=sys.stderr)
+ break
+ if ret.returncode != 0:
+ # a wedged RP DAP answers nothing and the probe has no reset line;
+ # POR it via the Rescue DP and retry once, exactly as the normal
+ # flash path does (no-op for every other board/failure)
+ out_txt = ret.stdout if isinstance(ret.stdout, str) else ''
+ # inside the redirect like its siblings (hil_test slices the result
+ # object from the first '{' on stdout), and only if a POR + retry
+ # still fits before the outer kill
+ rescued = False
+ try:
+ if _time_left() >= 2 * RECOVER_FLASH_TIMEOUT:
+ with redirect_stdout(sys.stderr):
+ rescued = hil_flash.rescue_openocd(
+ board, out_txt, timeout=RECOVER_FLASH_TIMEOUT)
+ if rescued:
+ print('DAP wedged; rescued via Rescue DP, retrying reflash',
+ file=sys.stderr)
+ with redirect_stdout(sys.stderr):
+ ret = flash_fn(board, args.recover_fw,
+ timeout=RECOVER_FLASH_TIMEOUT)
+ except Exception as e:
+ # guarded like the first flash: a raise here would unwind past the
+ # BUDGET back-fill and the JSON print
+ print(f'rescue/retry raised: {e}', file=sys.stderr)
+ if ret.returncode != 0:
+ print(f'reflash failed (rc {ret.returncode}); the device may still '
+ f'be wedged', file=sys.stderr)
+ # settle even on a non-zero exit: the reset may have landed before the
+ # flasher failed, and the freed ioctl needs a moment to unwind before
+ # wedged_pids samples
+ time.sleep(5)
+ # Authoritative either way: a clean flash only proves the probe wrote the
+ # MCU, not that the D-state holder let go.
+ stuck, complete = wedged_pids(dev['node'])
+ if stuck:
+ print(f'{dev["sysname"]}: pid(s) {stuck} still in D state on '
+ f'{dev["node"]} — the device lock was never released', file=sys.stderr)
+ # No hub-worker verdict here: our own testusb still holds the DUT's
+ # device lock, which is what drives a hub worker into usb_lock_device()
+ # -- any verdict from here is confounded by construction.
+ elif not complete:
+ print('cannot confirm recovery: /proc is only partly readable, so a '
+ 'hidden D-state holder cannot be ruled out', file=sys.stderr)
+ else:
+ unrecovered_hang = False
+ break
+ # re-resolve: a mid-battery re-enumeration changes the devnum and so the node
+ # path. Match on the concrete serial (not args.serial, which may be None) so
+ # this can never retarget to another device sharing the VID:PID.
+ # first=False: the ambiguity guard exists because ONE serial can match two
+ # sysfs nodes on the dual-port WCH parts, and `dev = live` below makes any
+ # mistake stick for the rest of the battery -- including wedged_pids() then
+ # scanning the wrong node and clearing unrecovered_hang on a device it never
+ # checked. Ambiguous comes back as {'ambiguous': [...]}, handled below.
+ live = find_device(dev['serial'])
+ if live and live.get('ambiguous'):
+ # two nodes now answer to one serial (the dual-port WCH parts do this
+ # around a re-enumeration). Picking either would file the rest of the
+ # battery's verdicts under a device we cannot identify, so stop here and
+ # keep the recovery in play rather than guess.
+ abort_reason = (f'serial {dev["serial"]} matches more than one device '
+ f'({", ".join(live["ambiguous"])}) after case {num}')
+ unrecovered_hang = True
+ break
+ if live and live.get('inconclusive'):
+ # bounded reads stopped answering, so we cannot say the device left --
+ # treat it as the wedge it probably is, which keeps the HUNG reflash and
+ # the lock-safe cleanup in play
+ from helper import hil_util as _hu
+ abort_reason = ('cannot tell whether the device is still present: bounded '
+ 'sysfs reads stopped answering' + _hu.sysfs_blind_note())
+ unrecovered_hang = True
break
- # re-resolve: after a mid-battery re-enumeration the devnum (and thus the node
- # path) changes; keep testing the live node instead of the stale one. Match on the
- # concrete serial (not args.serial, which may be None) so this can never retarget to
- # a different device that happens to share the VID:PID.
- live = find_device(dev['serial'], first=True)
if not live:
- results.append({'num': num, 'status': 'FAIL',
- 'detail': f'device dropped off the bus after case {num}'})
+ # no second entry for `num`: run_case already recorded it, and a duplicate
+ # inflates the denominator (31/30) and reports a PASSing case as failed
+ abort_reason = f'device dropped off the bus after case {num}'
break
dev = live
+ if abort_reason and all(c in {r['num'] for r in results} for c in cases) \
+ and 'dropped off the bus' in abort_reason and results:
+ # nothing left to back-fill (the drop happened during/after the LAST case),
+ # so the run would report a clean pass; the case it died on is not a pass
+ if results[-1].get('status') == 'PASS':
+ # only a PASS: a real FAIL/NOTRUN verdict names the actual regression
+ # (errno, dmesg) and must not be overwritten by the drop message
+ results[-1] = dict(results[-1], status='FAIL', detail=abort_reason)
+ if abort_reason:
+ # One BUDGET entry per case never dispatched, on EVERY abort path: a shrunken
+ # denominator (4/5 instead of 4/30) hides that most of the battery never
+ # executed and makes a regression in the skipped range read as "not the
+ # problem".
+ ran = {r['num'] for r in results}
+ results += [{'num': n, 'status': 'BUDGET', 'detail': f'not run: {abort_reason}'}
+ for n in cases if n not in ran]
finally:
# best-effort cleanup: a sudo/sysfs failure here (sudo() may sys.exit) must not replace
# an exception propagating out of the try body with a less useful one
try:
if unrecovered_hang:
- # testusb is still stuck in a usbfs ioctl holding the device lock; remove_id/unbind
- # would join the convoy and deadlock the bus (see usb-kernel-recover skill) — leave it be
+ # testusb still holds the device lock in a usbfs ioctl: remove_id/unbind
+ # would join the convoy and deadlock the bus (see usb-kernel-recover)
print('skipping cleanup after unrecovered hang: ask the operator for a full PVE host '
'power cycle (a VM reboot is not reliable — hubs latch up across the PCIe reset)',
file=sys.stderr)
elif not args.keep_binding:
sysfs_write(DRIVER / 'remove_id', f'{VID} {PID}', check=False)
- # release every claimed interface: other devices sharing the VID:PID (stale example
- # firmware on a test rig) may have been grabbed on probe and would otherwise stay
+ # release every claimed interface: another device sharing the VID:PID
+ # (stale example firmware) may have been grabbed on probe and would stay
# bound to usbtest until re-plugged, hijacking the next test's device
for intf in DRIVER.glob('*:*'):
sysfs_write(DRIVER / 'unbind', intf.name, check=False)
except SystemExit:
pass
- failed = [r for r in results if r['status'] != 'PASS']
+ # BUDGET, not NOTRUN: NOTRUN is taken, for a case the KERNEL gated off (-EOPNOTSUPP,
+ # see run_case) -- a real result that must stay in `failed` and keep its case number.
+ # BUDGET keeps the denominator honest without lying about the numerator: naming cases
+ # that never executed as failures sends a maintainer bisecting one of them.
+ notrun = [r for r in results if r['status'] == 'BUDGET']
+ failed = [r for r in results if r['status'] not in ('PASS', 'BUDGET')]
ran = len(results)
if args.json:
+ # `wedged` is the verdict this process ALREADY computed; without it the caller had
+ # to infer one from 'HUNG' in our stdout, which misses a recovery that ran and
+ # failed, the inconclusive abort (no case reaches status HUNG), and any battery
+ # killed before it printed.
print(json.dumps({'serial': dev['serial'], 'speed': dev['speed'], 'tier': tier,
- 'passed': ran - len(failed), 'failed': len(failed),
+ 'passed': ran - len(failed) - len(notrun),
+ 'failed': len(failed), 'notrun': len(notrun),
+ 'wedged': bool(unrecovered_hang),
'cases': results}, indent=2))
else:
- print(f"{ran - len(failed)}/{ran} passed")
+ print(f"{ran - len(failed) - len(notrun)}/{ran} passed"
+ + (f", {len(notrun)} not run" if notrun else ""))
for r in failed:
print(f" FAILED test {r['num']}: {r.get('detail', '')}")
if r.get('dmesg'):
print(' ' + r['dmesg'].replace('\n', '\n '))
- return len(failed)
+ # NOTRUN counts toward the exit status even though it is reported separately: a
+ # standalone run whose cases were all skipped has NOT passed, and returning 0 hands a
+ # false success to any script driving this directly.
+ return len(failed) + len(notrun)
if __name__ == '__main__':
diff --git a/tools/metrics_compare_base.py b/tools/metrics_compare_base.py
index 799a96800..844130097 100644
--- a/tools/metrics_compare_base.py
+++ b/tools/metrics_compare_base.py
@@ -81,7 +81,7 @@ def symlink_deps(main_root, worktree_dir):
def ci_first_boards():
"""Return the first board (alphabetical) of each arm-gcc CI family."""
- matrix_py = os.path.join(TINYUSB_ROOT, '.github', 'workflows', 'ci_set_matrix.py')
+ matrix_py = os.path.join(TINYUSB_ROOT, '.github', 'scripts', 'ci_set_matrix.py')
if not os.path.isfile(matrix_py):
return []
ret = run([sys.executable, matrix_py])
@@ -188,7 +188,7 @@ def main():
args.combined = True
ci_boards = ci_first_boards()
if not ci_boards:
- parser.error('--ci: failed to derive boards from .github/workflows/ci_set_matrix.py')
+ parser.error('--ci: failed to derive boards from .github/scripts/ci_set_matrix.py')
# Append, dedup, preserve order
seen = set(args.board)
for b in ci_boards: