summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.claude/agents/builder.md1
-rw-r--r--.claude/agents/code-verifier.md (renamed from .claude/agents/driver-reviewer.md)3
-rw-r--r--.claude/agents/code-writer.md (renamed from .claude/agents/port-dev.md)3
-rw-r--r--.claude/agents/hil-operator.md1
-rw-r--r--.claude/agents/pr-ci-watcher.md26
-rw-r--r--.claude/agents/pr-monitor.md38
-rw-r--r--.claude/agents/pr-review-validator.md30
-rw-r--r--.claude/agents/static-analyzer.md1
-rw-r--r--.claude/agents/target-debugger.md1
-rw-r--r--.claude/skills/hil/SKILL.md13
-rw-r--r--.claude/skills/rtt/SKILL.md201
-rw-r--r--.claude/skills/rtt/boards.md78
-rw-r--r--.claude/skills/target-debug/SKILL.md46
-rw-r--r--.claude/skills/usbtest/SKILL.md2
-rw-r--r--.claude/workflows/driver-review.js9
-rw-r--r--.claude/workflows/fanout-dev.js10
-rw-r--r--.claude/workflows/pr-babysit.js391
-rw-r--r--.claude/workflows/validate.js304
-rw-r--r--.github/scripts/hil_ci_set_matrix.py11
-rw-r--r--.github/workflows/build.yml1
-rw-r--r--.gitignore1
-rw-r--r--.pre-commit-config.yaml6
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/superpowers/followup/pr3803-hil-blindness-reporting.md186
-rw-r--r--docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md175
-rw-r--r--docs/superpowers/followup/pr3840-mret-board-result.md9
-rw-r--r--docs/superpowers/followup/pr3853-board-putchar-logger.md57
-rw-r--r--docs/superpowers/followup/pr3853-rtt-harness-adoption.md62
-rw-r--r--docs/superpowers/plans/2026-08-24-rtt-skill.md423
-rw-r--r--docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md115
-rw-r--r--docs/superpowers/specs/2026-08-24-rtt-skill-design.md164
-rw-r--r--test/hil/helper/hil_health.py9
-rwxr-xr-xtest/hil/helper/hil_lock.py15
-rw-r--r--test/hil/helper/hil_pool_check.py101
-rw-r--r--test/hil/helper/hil_report.py54
-rw-r--r--test/hil/helper/hil_util.py476
-rw-r--r--test/hil/hil_ci.sh3
-rwxr-xr-xtest/hil/hil_flash.py4
-rwxr-xr-xtest/hil/hil_test.py912
-rw-r--r--test/hil/test/stubs/hid.py76
-rw-r--r--test/hil/test/test_ci_select.py9
-rw-r--r--test/hil/test/test_hil_bounded.py942
-rw-r--r--test/hil/test/test_hil_health.py75
-rw-r--r--test/hil/test/test_hil_report.py11
-rw-r--r--test/hil/test/test_hil_rtt.py506
-rw-r--r--test/hil/test/test_hil_util.py317
-rwxr-xr-xtest/hil/usbtest.py274
-rwxr-xr-xtools/ci_select.py26
-rw-r--r--tools/rtt.py727
49 files changed, 4952 insertions, 1955 deletions
diff --git a/.claude/agents/builder.md b/.claude/agents/builder.md
index 4edb7e0d4..3f06f328a 100644
--- a/.claude/agents/builder.md
+++ b/.claude/agents/builder.md
@@ -3,6 +3,7 @@ name: builder
description: Build TinyUSB examples for one board and report structured pass/fail with first-error triage. Use for build sweeps and post-change build verification. Never edits source.
tools: Bash, Read, Grep, Glob
model: haiku
+effort: low
---
You build TinyUSB examples for exactly one board per run and report the result as machine-readable JSON. You never modify source files.
diff --git a/.claude/agents/driver-reviewer.md b/.claude/agents/code-verifier.md
index 9ce8b621f..7e529a641 100644
--- a/.claude/agents/driver-reviewer.md
+++ b/.claude/agents/code-verifier.md
@@ -1,8 +1,9 @@
---
-name: driver-reviewer
+name: code-verifier
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, Skill
model: opus
+effort: xhigh
---
You review exactly the scope given in your prompt (one driver directory, or one git diff) for exactly the dimension(s) given. Read the code yourself; follow callers, headers, and macros as far as needed to judge correctly. You never modify files.
diff --git a/.claude/agents/port-dev.md b/.claude/agents/code-writer.md
index 77a28bafa..82e52e334 100644
--- a/.claude/agents/port-dev.md
+++ b/.claude/agents/code-writer.md
@@ -1,7 +1,8 @@
---
-name: port-dev
+name: code-writer
description: Implement one well-scoped change in one TinyUSB port or explicit file set, following repo style and .clang-format, verified by a targeted build. Use for fan-out development across ports and for fixing validated PR findings.
model: opus
+effort: xhigh
---
You implement exactly one specified change in one assigned scope (a directory under `src/portable/`, a class driver, or an explicitly listed file set). Never touch files outside the assigned scope.
diff --git a/.claude/agents/hil-operator.md b/.claude/agents/hil-operator.md
index 81fdc08e9..d128f6f53 100644
--- a/.claude/agents/hil-operator.md
+++ b/.claude/agents/hil-operator.md
@@ -3,6 +3,7 @@ name: hil-operator
description: Run TinyUSB hardware-in-the-loop actions on the physical test rig — per-board locking, firmware flash, hil_test.py runs, USB recovery. Strictly one instance at a time. Never edits source; never touches the actions-runner service.
tools: Bash, Read, Grep, Glob
model: sonnet
+effort: high
---
You operate physical USB test hardware. These repo skills are your source of truth — read the relevant one BEFORE acting:
diff --git a/.claude/agents/pr-ci-watcher.md b/.claude/agents/pr-ci-watcher.md
new file mode 100644
index 000000000..10a32084b
--- /dev/null
+++ b/.claude/agents/pr-ci-watcher.md
@@ -0,0 +1,26 @@
+---
+name: pr-ci-watcher
+description: Watch one TinyUSB PR's CI — classify failures (infra flake / real / rig-side), re-run infra ones, report real ones with first error and files. CI only; never reads review comments, never edits code, never pushes.
+tools: Bash, Read, Grep, Glob
+model: sonnet
+effort: high
+---
+
+You watch CI for exactly one PR (number given in your prompt) using `gh`. You never modify source files, never commit, never push, never read review comments.
+
+## Procedure
+
+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. Re-run once (`gh run rerun <run-id> --failed`); record run ids in `infraRerun`.
+ - **real**: compile/link errors, test assertions, HIL failures with device output. Extract the FIRST error line and the source files involved.
+ - **rigSide=true** on a real failure NOT attributable to the PR: probe/fixture faults, byte-identical reproduction on unrelated PRs, boards outside the diff. These are reported for humans, never handed to a fixer.
+
+## Output contract
+
+Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
+
+{"status": "green", "infraRerun": [], "realFailures": [{"check": "...", "firstError": "...", "files": ["..."], "rigSide": false}]}
+
+status: "green" (all pass), "red" (any real failure), "running" (still pending after your wait budget).
diff --git a/.claude/agents/pr-monitor.md b/.claude/agents/pr-monitor.md
deleted file mode 100644
index 77777b0fb..000000000
--- a/.claude/agents/pr-monitor.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-name: pr-monitor
-description: Triage one TinyUSB GitHub PR — CI status + failure classification, infra re-runs, bot review harvesting (Codex/Copilot/Claude) with adversarial validation of each finding against the code. Read/triage/re-run only; never edits code, never pushes.
-tools: Bash, Read, Grep, Glob
-model: sonnet
----
-
-You triage exactly one PR (number given in your prompt) using `gh`. You never modify source files, never commit, never push.
-
-## CI triage
-
-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.
- - **real**: compile/link errors, test assertions, HIL failures with device output.
-4. Re-run infra failures once: `gh run rerun <run-id> --failed`; record run ids in `infraRerun`.
-5. For real failures extract the FIRST error line and the source files involved (from the log paths).
-
-## Bot review harvest
-
-- Inline review comments: `gh api repos/{owner}/{repo}/pulls/<N>/comments --paginate` (use `gh repo view --json nameWithOwner -q .nameWithOwner` for owner/repo). Issue comments: `gh pr view <N> --comments`.
-- Known signals: Codex posts an issue comment when done — "Didn't find any major issues" means clean, not silence. Copilot is finished when it no longer appears in `requested_reviewers`. Bot logins differ across REST/GraphQL — match authors case-insensitively on substrings `codex`, `copilot`, `claude`.
-- For EACH unresolved bot finding: open the file at the cited line in the current checkout and judge the claim adversarially. `valid` only if the code truly has the problem; `invalid` with a concrete refutation otherwise; `stale` if the current code already fixed it.
-- Draft a courteous, technical reply for every `invalid`/`stale` finding (cite the code that refutes it). Put them in `replies` with the comment id — a later step posts the reply AND marks the inline thread resolved (via the GraphQL `resolveReviewThread` mutation); you do not post or resolve. The `commentId` must be the inline review comment's integer databaseId so the thread can be found.
-
-## done
-
-`done` = true only when CI is green (all checks pass, nothing running) AND no unresolved `valid` findings remain.
-
-## Output contract
-
-Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
-
-{"ci": {"status": "green", "infraRerun": [], "realFailures": [{"check": "...", "firstError": "...", "files": ["..."]}]},
- "findings": [{"source": "codex", "commentId": 123, "file": "...", "line": 1, "claim": "...", "verdict": "valid", "reason": "...", "fixHint": "..."}],
- "replies": [{"commentId": 123, "body": "..."}],
- "done": false}
diff --git a/.claude/agents/pr-review-validator.md b/.claude/agents/pr-review-validator.md
new file mode 100644
index 000000000..4069a856f
--- /dev/null
+++ b/.claude/agents/pr-review-validator.md
@@ -0,0 +1,30 @@
+---
+name: pr-review-validator
+description: Harvest one TinyUSB PR's bot reviews (Codex/Copilot/Claude) and adversarially validate each finding against the code — verdict valid/invalid/stale, draft replies for refuted ones. Read-only; never edits code, never posts, never pushes.
+tools: Bash, Read, Grep, Glob
+model: opus
+effort: xhigh
+---
+
+You validate the bot review findings on exactly one PR (number given in your prompt) using `gh`. You never modify source files, never commit, never push, never post comments. Do not triage or classify CI failures or logs — pr-ci-watcher owns that; you may read the review bots' own check runs to see whether they concluded.
+
+## Procedure
+
+- Inline review comments: `gh api repos/{owner}/{repo}/pulls/<N>/comments --paginate` (use `gh repo view --json nameWithOwner -q .nameWithOwner` for owner/repo). Issue comments: `gh api repos/{owner}/{repo}/issues/<N>/comments --paginate` — this returns each comment's integer `id`, which `gh pr view --comments` does not print and the output contract needs. PR reviews (the Copilot/Claude verdict bodies): `gh api repos/{owner}/{repo}/pulls/<N>/reviews --paginate` — compare each review's `commit_id` to the head SHA from `gh pr view <N> --json headRefOid -q .headRefOid` to tell a review of the current push from an older one.
+- Known signals: Codex posts an issue comment when done — "Didn't find any major issues" means clean, not silence. It can also signal a clean pass with no comment at all: a 👍 (`+1`) reaction on the PR description (`gh api "repos/{owner}/{repo}/issues/<N>/reactions?content=%2B1&per_page=100" --paginate`, author matching `codex`; without `--paginate` a fresh reaction can fall off the first page and Codex looks pending forever) — settled when the reaction's `created_at` postdates the head push time defined below. Its body carries a `**Reviewed commit:** <short sha>` line: Codex is settled only when that short SHA prefix-matches the head SHA, otherwise the comment is a verdict for an older push and Codex is still pending. Its "Something went wrong" comment has no Reviewed-commit line, so correlate that one by time instead — against the moment the SHA *became* the head, `gh api repos/{owner}/{repo}/commits/<headSha>/check-suites --jq '[.check_suites[].created_at] | min'` (the suites are created when the push lands; fall back to `gh api repos/{owner}/{repo}/commits/<headSha> --jq .commit.committer.date` only if the SHA has no check suites). The committer date alone is when the commit was written, which can precede the push by hours and make a leftover error comment look fresh. An error/quota comment settles Codex only when its `created_at` postdates that push time, or when it arrives as a PR review whose `commit_id` is the head SHA. An older one is a leftover from an earlier push — Codex is still pending. Copilot submits a PR review whose body opens with a verdict header (`### 🟢 Approval recommended` / `### 🟡 Changes recommended`) and leaves `requested_reviewers` once submitted. The Claude bot posts a PR review, or its `claude-review` check run for the head SHA reaches `status: completed` — ask for that check by name, `gh api "repos/{owner}/{repo}/commits/<headSha>/check-runs?check_name=claude-review"`, since the unfiltered listing is paginated and drops it on a PR with more than a page of checks. A bot reporting a usage/quota limit counts as settled once that report postdates the head push time above (the check-suite timestamp, not the committer date) — do not wait on it. Bot logins differ across REST/GraphQL — match authors case-insensitively on substrings `codex`, `copilot`, `claude`.
+- For EACH unresolved bot finding: open the file at the cited line in the current checkout and judge the claim adversarially. `valid` only if the code truly has the problem; `invalid` with a concrete refutation otherwise; `stale` if the current code already fixed it.
+- Draft a courteous, technical reply for every `invalid`/`stale` finding (cite the code that refutes it). Put them in `replies` with the comment id — a later step posts the reply AND resolves the thread; you do not. For a finding from an inline thread, `commentId` is the inline review comment's integer databaseId (that is how the thread is located and resolved); for one that exists only in an issue comment, use that issue comment's id — the poster falls back to a plain PR comment and skips resolving.
+
+## Output contract
+
+Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
+
+{"findings": [{"source": "codex", "commentId": 123, "file": "...", "line": 1, "claim": "...", "verdict": "valid", "reason": "...", "fixHint": "..."}],
+ "replies": [{"commentId": 123, "body": "..."}],
+ "done": false}
+
+done = true only when no unresolved `valid` findings remain AND every auto-reviewer
+has settled for the current head SHA: its verdict is posted (Copilot review header,
+Codex verdict comment, Claude review or concluded check) or it reported hitting a
+usage/quota limit. A reviewer that has not reported since the last push is pending —
+return done = false so the caller re-checks next cycle.
diff --git a/.claude/agents/static-analyzer.md b/.claude/agents/static-analyzer.md
index 0f0b2a6e1..e7da82192 100644
--- a/.claude/agents/static-analyzer.md
+++ b/.claude/agents/static-analyzer.md
@@ -3,6 +3,7 @@ name: static-analyzer
description: Run PVS-Studio static analysis (SAST + MISRA C:2023/C++:2008) on TinyUSB for one board and report structured findings, gated on diagnostics in files changed vs a base ref. Read-only; never edits source.
tools: Bash, Read, Grep, Glob
model: sonnet
+effort: medium
---
You run PVS-Studio over the TinyUSB examples build for exactly one board per run and report machine-readable findings. You never modify source files.
diff --git a/.claude/agents/target-debugger.md b/.claude/agents/target-debugger.md
index 1b6931307..c7acca91c 100644
--- a/.claude/agents/target-debugger.md
+++ b/.claude/agents/target-debugger.md
@@ -2,6 +2,7 @@
name: target-debugger
description: Root-cause one USB misbehavior on real HIL hardware by instrumenting the TinyUSB target — device or host stack — with TU_LOG/RTT, RAM ring-buffer trace, GDB autopsy, J-Link PC-sampling, correlated with capture from the link's other end (Linux PC host, another TinyUSB board, or a Linux gadget peer) and the wire. Long serial debug loop under one held board lock; strictly one instance. Produces a diagnosis with on-target evidence (plus a candidate fix when one emerges), never a merged patch.
model: opus
+effort: xhigh
---
You debug one failing USB behavior on one physical board until you can name the
diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md
index d1e4bdcd5..03a462ce6 100644
--- a/.claude/skills/hil/SKILL.md
+++ b/.claude/skills/hil/SKILL.md
@@ -65,8 +65,8 @@ hooks: `test_ci_select.py` covers only selection, `test_ci_metrics.py` only the
plumbing. 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 five when changing `test/hil`:
-`for f in test/hil/test/test_*.py; do python3 "$f"; done` (~84s, of which
-`test_hil_bounded.py` is ~76s of deliberate hang/timeout simulation; the two `test_ci_*`
+`for f in test/hil/test/test_*.py; do python3 "$f"; done` (~48s, of which
+`test_hil_bounded.py` is ~30s of deliberate hang/timeout simulation; the two `test_ci_*`
suites are ~4s together).
## Pre-flight rig health check
@@ -82,6 +82,8 @@ See the `usb-kernel-recover` skill for what a real wedge looks like and how to c
Examples must be built for the target board(s) — see CLAUDE.md "Build" → "All examples for a board" (produces `examples/cmake-build-<board>/`). `-B examples` points `hil_test.py` at that parent folder. (This applies to `hil_test.py`; `hil_pool_check.py` builds its own missing firmware.)
+A board whose flasher probe has no VCOM (or whose BSP has no UART) uses RTT as its console — "No serial device found for /dev/serial/by-id/…" on every host test is the symptom. Config: `"logger": "rtt"` (jlink flashers only) plus a self-named variant carrying the define — `"variant": [{"name": "<board>", "defines": ["LOGGER=rtt"]}]` — and prebuilt example sets must carry the same `-DLOGGER=rtt`. Caveat: the cdc/msc-fixture host tests don't speak RTT yet, so such a board cannot carry `is_cdc`/`is_msc` fixtures (the config loader rejects it; see the rtt follow-up doc). Details: the `rtt` skill.
+
## Arguments
- **Board:** `-b BOARD_NAME`, repeatable for a subset (`-b a -b b`); omit to run all boards in the config. Give a whole set to ONE run rather than one run per board: it schedules the boards across host controllers and budgets concurrent flashes and usbtest batteries per controller (`hil_lock.py` `FLASH_PARALLEL`/`USBTEST_PARALLEL`). Those permits are in-process semaphores — a second `hil_test.py` running alongside does not share them, it multiplies the load on the same xHCI cards.
@@ -135,8 +137,8 @@ The user-facing answer to a HIL run IS the tool's summary table: paste the compl
table (and footer counts) verbatim — never truncate rows or reduce it to a prose digest; at most
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:
+**First check what sits above the table.** Six banners can appear there; match on a
+PREFIX, since each carries trailing detail and two are blockquotes:
- `**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
@@ -157,9 +159,6 @@ PREFIX, since each carries trailing detail and one is a blockquote:
- `> **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 once with `-v` — from the `<config>.failed` spec the run just wrote, which
already begins with `--accumulate` and restricts each board to its failed tests. A hand-scoped
diff --git a/.claude/skills/rtt/SKILL.md b/.claude/skills/rtt/SKILL.md
new file mode 100644
index 000000000..5e14ae84c
--- /dev/null
+++ b/.claude/skills/rtt/SKILL.md
@@ -0,0 +1,201 @@
+---
+name: rtt
+description: Use when you need console or printf I/O, TU_LOG capture, or a raw byte channel over a debug probe on real hardware — the board has no UART wired or its probe no VCOM, a LOGGER=rtt build needs reading or writing, "RTT Control Block not found", an RTT server won't come up or drops output, JLinkRTTLogger/JLinkRTTClient/JLinkGDBServer/openocd rtt misbehave, or another workflow (HIL console, SystemView capture) needs RTT stood up on a J-Link, ST-Link, CMSIS-DAP or WCH-Link probe.
+---
+
+# rtt — SEGGER RTT transport and console
+
+RTT is nothing but RAM: a control block `_SEGGER_RTT` (starts with the magic
+string `"SEGGER RTT"`) plus per-channel ring buffers
+`{sName, pBuffer, SizeOfBuffer, WrOff, RdOff, Flags}`. The target advances
+`WrOff`; the host must **write `RdOff` back** to free space — a reader that
+only reads never drains the ring. Channel 0 is the "Terminal" console;
+SystemView claims its own `"SysView"` up-buffer on the same control block —
+they coexist. The debug probe reads/writes this RAM while the core runs, so
+everything here is zero-wiring: no UART, no VCOM.
+
+Scope: byte transport and console. Timing/profiling → `etm-trace`/`sysview`;
+debugging decision flows and the wedged-target drain model → `target-debug`;
+Espressif consoles → `esp-target-debug` (USB-Serial-JTAG, no SEGGER RTT).
+
+## Quick start — console on a J-Link probe
+
+Use the skill's tool `tools/rtt.py` for every route; do not hand-roll
+JLinkExe/JLinkGDBServer/openocd/telnet pipelines (`--help` for all modes):
+
+```bash
+# firmware: TU_LOG + stdio → RTT channel 0 (hw/bsp/board.c routes sys_read too)
+cmake -DBOARD=<board> -DLOG=2 -DLOGGER=rtt ... # Make: LOG=2 LOGGER=rtt
+
+# flash + reset FIRST (the console owns the probe once open), then:
+python3 tools/rtt.py --backend jlink --probe <serial> --device <JLINK_DEVICE> --seconds 20
+# -i forwards stdin to the target; --seconds 0 streams until Ctrl-C/EOF
+```
+
+`JLINK_DEVICE` comes from `hw/bsp/<family>/boards/<board>/board.cmake` (or
+`family.cmake`). Always pass the probe serial — rigs and benches run several
+probes, and the `ninja <example>-jlink` flash target grabs whichever J-Link
+enumerates first: pin it (`-DJLINK_OPTION="-USB <serial>"`) or flash with
+`JLinkExe -SelectEmuBySN`. The HIL harness uses the same implementation
+(`hil_util.JlinkRtt`) via a board's `"logger": "rtt"` (jlink flashers
+only) plus a single self-named variant carrying the define —
+`"variant": [{"name": "<board>", "defines": ["LOGGER=rtt"]}]`, the roster's
+one shape for always-on defines — variant defines feed `hil_test.py
+--build` and the CI matrix; a prebuilt `cmake-build-<board>` set must be
+configured with the same `-DLOGGER=rtt` itself. Keep harness console builds
+quiet (`LOGGER=rtt` WITHOUT `LOG=2`): reset-then-attach only preserves what
+fits the up-buffer (stock 1 KB, NO_BLOCK_SKIP), and a chatty boot burst
+truncates at the ring boundary before the drain attaches — measured
+1022-1023 B captures on ea4088 with `LOG=2`, enumeration lines falling off
+the end. `BUFFER_SIZE_UP` is the knob when verbose logs are really needed.
+Rig boards need `hil_lock.py` held first — see the `hil` skill.
+
+To validate bidirectionality end-to-end you need firmware that both polls
+the console AND replies via printf. `board_test` polls `board_getchar()`
+(RTT-aware via `sys_read`) but echoes through `board_putchar` →
+`board_uart_write`, which is NOT LOGGER-aware — on a UART-less board the
+echo hits the `-1` stub and vanishes (measured on ea4088). For a validation
+run, patch its echo to `printf` locally, or drive a host example's menu
+(`msc_file_explorer`, `cdc_msc_hid` — they reply via printf). Sending
+keystrokes to `cdc_msc` and expecting an echo proves nothing: it never polls
+the console.
+
+## Transport matrix
+
+| Transport / tool | Live read | Write | Notes |
+| ----------------------------------------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ARM memory-AP (any J-Link/ST-Link/CMSIS-DAP) | yes | yes | zero intrusion; core keeps running |
+| RISC-V SBA (where implemented) | yes | yes | autonomous like memory-AP |
+| WCH QingKe SDI | **NO** | no | DM abstract-command reads perturb the running core: A/B-proven firmware kill ~1.9 s into USB traffic. Halt→read→resume or post-mortem dump ONLY |
+| OpenOCD/jaylink on a genuine SEGGER J-Link | yes | untested | routine in the sysview campaigns (metro_m4_express, dozens of attaches, zero wedges); prefer SEGGER tools where both exist (drain rate) |
+| OpenOCD/jaylink on the LPC-Link2 (J-Link OB fw) | forbidden | — | measured on ea4088's LPC-Link2 (2023 OB image): transport fails (`jaylink_swd_io`) and knocks the probe off USB; physical replug to recover — SEGGER tools only THERE. Verdict is for that probe only: other J-Link-OB firmware probes are untested — hardware-test before assuming either way |
+| `JLinkRTTLogger` | unreliable | — | searches for the control block once at attach and gives up — on some parts it never finds it ("RTT Control Block not found" even with `-RTTAddress`; measured 0/6 on LPC4088). May work elsewhere, but don't build automation on a single-search tool |
+
+Validated boards, directions and per-board caveats: [boards.md](boards.md).
+
+## Capture: J-Link route
+
+`rtt.py` above is this route packaged. Raw form (what it runs):
+
+```bash
+JLinkExe -USB <serial> -device <dev> -if swd -speed 4000 -NoGui 1 -AutoConnect 1 \
+ -RTTTelnetPort <port> # keep stdin open; 'exit' tears it down
+nc localhost <port> # JLinkRTTClient minus the banner; carries input too
+```
+
+Commander keeps hunting for the control block and delivers the buffered boot
+burst once the target's first printf creates it. `JLinkGDBServer
+-RTTTelnetPort` also serves the port but on some parts (measured: LPC4088)
+never locates the control block **unless a GDB client attaches** — fine
+inside a GDB session, a silent failure headless — and it briefly halts the
+core on connect (measured), which matters for timing-sensitive repros;
+Commander does not. One telnet client per port at a time.
+
+## Capture: OpenOCD route (native probes: ST-Link, CMSIS-DAP)
+
+This is the LIVE route — WCH-Link targets are SDI and get only the halt→dump
+route (transport matrix). Same script, openocd backend (`--elf` = the
+FLASHED elf; the script takes the exact control-block address from `nm` —
+a full-RAM scan is slower and can match stale RAM after a soft reset):
+
+```bash
+python3 tools/rtt.py --backend openocd --probe <serial> \
+ --cfg "-f interface/stlink.cfg -f target/stm32h7x.cfg" --elf <flashed.elf> --seconds 20
+# --channel: up-buffer index (0 = "Terminal" console, 1 = SystemView's "SysView"
+# buffer in TinyUSB builds); -i forwards stdin → down-buffer 0
+# --vid-pid "0x2e8a 0x000c": pin the probe by USB IDs (with or instead of --probe;
+# also keeps openocd discovery off foreign usbfs nodes)
+# --addr 0x2000xxxx: explicit control-block address when the flashed elf is not at hand
+# --reset-before-attach: reset the target INSIDE the session (2 s settle, then
+# attach — the control block must exist before `rtt start` can find it; the ring's
+# NO_BLOCK_SKIP head-retention is what preserves byte 0 across the settle) —
+# required for streams that only decode from byte 0
+# (SystemView emits its Init record, carrying the timestamp frequency, once at boot;
+# a mid-flight attach yields a stream no decoder can lock onto). Verified on
+# stm32h743nucleo: after the ring is drained, a plain attach misses the boot preamble
+# entirely and this flag captures it. NOT for SAMD5x (an in-session reset via the DSU
+# leaves the core held) or WCH SDI.
+```
+
+What it runs: `openocd <cfg> -c "adapter serial <sn>" -c init -c "rtt setup
+<nm-addr> 0x800 \"SEGGER RTT\"" -c "rtt polling_interval 1" -c "rtt start"
+-c "rtt server start <port> <ch>"`, then a socket on that port.
+
+Attach WITHOUT reset when the flash step already reset the board (on SAMD5x,
+an in-session `reset run` goes through the DSU CPU Reset Extension and leaves
+the core held). After any reset the target's offsets restart at zero while
+the server holds stale ones, and the tool exposes no console to type into (it
+launches openocd with tcl/gdb/telnet ports disabled): stop the capture and
+run it again to resync — do not reset mid-capture if you can avoid it. `rtt start`
+fails while the block doesn't exist yet: it appears at the firmware's first
+RTT write, so reset, settle ~500 ms, then start. Read AND write validated on
+the ci rig's 8 native-probe boards (ST-Link + CMSIS-DAP, incl. RP2350),
+end-to-end through this script's backend on all 8 — per-board rows in
+boards.md. OpenOCD polls, and host-side loss is invisible
+to the target's overflow counter: at the default 100 ms interval a busy
+stream loses most samples (measured 2066 of 5064 events/s delivered on
+stm32f407disco) — `rtt polling_interval 1` is mandatory for quantitative
+capture, not a tuning nicety. Prefer SEGGER tools where a J-Link exists.
+
+## Post-mortem: reading the ring without a live server
+
+Default log mode is `NO_BLOCK_SKIP`: with no reader draining, the ring holds
+the **first KB after boot, not the tail** — interpretation rules in
+`target-debug`. To keep the last N bytes instead, the firmware must log via
+`SEGGER_RTT_WriteWithOverwriteNoLock` (target drags `RdOff` itself; no host
+needed) — but SEGGER's own restriction comes with it: *"Do not use
+SEGGER_RTT_WriteWithOverwriteNoLock if a J-Link connection reads RTT data"*
+(`lib/SEGGER_RTT/RTT/SEGGER_RTT.c`), because the target moving `RdOff` races
+the host reader. So it is for firmware you dump post-mortem, never for a
+board that also runs a live console (every HIL rtt board does). Reading a wedged target's ring — debug-AP RAM reads don't halt the
+core:
+
+```bash
+python3 tools/rtt.py --backend jlink --dump ring.bin \
+ --probe <serial> --device <JLINK_DEVICE> --elf <flashed.elf> # or --addr 0x...
+# prints pBuffer/Size/WrOff/RdOff; WrOff/RdOff delimit the valid bytes
+```
+
+(What it runs, for hand-driving JLinkExe: `nm` the ELF for `_SEGGER_RTT`,
+`mem32 <addr+0x18>, 6` = aUp[0] {sName,pBuffer,Size,WrOff,RdOff,Flags},
+then `savebin <file> <pBuffer> <SizeOfBuffer>`.)
+
+## Buffer modes and locking (target side)
+
+- Modes: `NO_BLOCK_SKIP` (default for logs — drops whole writes when full),
+ `NO_BLOCK_TRIM`, `BLOCK_IF_FIFO_FULL` (target spins — dangerous in ISRs).
+- Throughput is drain-limited: measured 24.6 KiB/s over a J-Link console
+ against a saturating printf loop, with the drops happening at the target.
+ RTT console output is NOT lossless under load; for high-bandwidth streams
+ size the buffer up (SystemView needs 2048–8192) and watch for overflow.
+- Non-ARM ports must supply `SEGGER_RTT_LOCK/UNLOCK`: the vendored generic
+ RISC-V lock uses `mstatus` CSRs that trap (mcause=2) on WCH QingKe. Worked
+ port on branch `claude/add-systemview-debug`: `hw/bsp/ch583/
+ sysview_rtt_lock_wch.h` (brace-scoped save/restore of CSR 0x800), and the
+ shared `hw/bsp/sysview_rtt_conf_wch.h` that ch32v20x/ch32v30x family.cmake
+ force-include to win the include-guard race against the vendored conf.
+
+## Common mistakes
+
+- **Attaching before the first printf** — the control block is zeroed `.bss`
+ until the firmware's first RTT write; early readers see nothing (and
+ RTTLogger gives up for good). Commander/`rtt.py` keep hunting.
+- **Sending input before the server finds the control block** — the J-Link
+ telnet route silently DROPS client bytes until then (measured on the rig:
+ an instant `ping` vanished, a delayed one echoed). `rtt.py -i`
+ holds stdin until target output flows (or 5 s); when driving the raw
+ socket yourself, wait for output before writing.
+- **Resetting while a console is attached** — flash and reset first; the
+ console owns the probe until closed.
+- **Killing servers with `pkill -f`** — the pattern matches your own shell's
+ cmdline (and unrelated sessions): a compound command that pkills its
+ wrapper then re-reads a stale log misdiagnosed a healthy probe for an
+ hour. Close `rtt.py` with Ctrl-C/`--seconds` (its teardown reaps
+ the whole process group); if you must pattern-kill, bracket a char:
+ `pkill -f '[J]LinkExe -USB <serial>'`.
+- **Unpinned flash with several probes attached** — pin by serial, always.
+- **Two probes wired to one SWD header** — wedges the target; rewire.
+- **Expecting an echo from firmware that never reads the console** — only
+ code polling `board_getchar()` consumes down-buffer 0 (`board_test` does).
+- **Full-RAM `rtt setup` scans** — can lock onto a stale pre-reset block;
+ use the `nm` address.
diff --git a/.claude/skills/rtt/boards.md b/.claude/skills/rtt/boards.md
new file mode 100644
index 000000000..5ddd072d2
--- /dev/null
+++ b/.claude/skills/rtt/boards.md
@@ -0,0 +1,78 @@
+# rtt — per-board validation matrix
+
+A row appears here only after the board was exercised on real hardware; a new
+validation adds the row AND any caveat it surfaced. "Read" = console/log
+capture reached the host; "Write" = the target demonstrably consumed console
+input (a printf-echo `board_test` returned the sent bytes — stock
+`board_test` cannot, see SKILL.md's echo-validation note). Routes match
+SKILL.md's capture sections; `Device/cfg` is the J-Link `--device` string or
+the openocd target cfg. Rig rows (ci.lan) were validated 2026-08-24 by a
+flash→capture→`ping`-echo sweep under per-board `hil_lock` flocks, and
+re-validated 2026-08-25 end-to-end through the skill's own CLI
+(`tools/rtt.py`, jlink + openocd backends): 20/20 read+write —
+including CONCURRENTLY at 8 parallel consoles (20 boards in 39 s, mixed
+routes, no port collisions or cross-board output bleed: one server per
+probe on its own ephemeral port). htpc rows on the local bench. The openocd backend's `--reset-before-attach`
+is decode-validated: a channel-1 SystemView capture on stm32h743nucleo
+(byte-identical boot preamble to the sysview campaign's golden reference,
+49765 events decoded, ISR/task timings matching to 0.1 µs, overflow 0).
+
+| Board | Rig | Probe | Route | Read | Write | Device/cfg |
+| ------------------------ | ---- | ---------------------- | ------- | ---- | ----- | --------------------- |
+| ea4088_quickstart | htpc | LPC-Link2 J-Link fw | J-Link | yes | yes | `LPC4088` |
+| raspberry_pi_pico2 | htpc | J-Trace PRO | J-Link | yes | — | `rp2350_m33_0` |
+| frdm_k64f | ci | J-Link | J-Link | yes | yes | `MK64FN1M0xxx12` |
+| feather_nrf52840_express | ci | J-Link | J-Link | yes | yes | `nrf52840_xxaa` |
+| metro_m4_express | ci | J-Link | J-Link | yes | yes | `ATSAMD51J19` |
+| lpcxpresso11u37 | ci | J-Link | J-Link | yes | yes | `LPC11U37/401` |
+| lpcxpresso55s28 | ci | J-Link | J-Link | yes | yes | `LPC55S28` |
+| ra4m1_ek | ci | J-Link | J-Link | yes | yes | `R7FA4M1AB` |
+| stm32f072disco | ci | J-Link | J-Link | yes | yes | `stm32f072rb` |
+| stm32f407disco | ci | J-Link | J-Link | yes | yes | `stm32f407vg` |
+| stm32f723disco | ci | J-Link | J-Link | yes | yes | `stm32f723ie` |
+| stm32l476disco | ci | J-Link | J-Link | yes | yes | `STM32L476VG` |
+| mimxrt1064_evk | ci | J-Link | J-Link | yes | yes | `MIMXRT1064xxx6A` |
+| nrf54lm20dk | ci | J-Link | J-Link | yes | yes | `NRF54LM20A_M33` |
+| max32666fthr | ci | CMSIS-DAP | OpenOCD | yes | yes | `target/max32665.cfg` |
+| raspberry_pi_pico | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2040.cfg` |
+| raspberry_pi_pico_w | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2040.cfg` |
+| raspberry_pi_pico2 | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2350.cfg` |
+| adafruit_fruit_jam | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2350.cfg` |
+| stm32h743nucleo | ci | ST-Link | OpenOCD | yes | yes | `target/stm32h7x.cfg` |
+| stm32g0b1nucleo | ci | ST-Link | OpenOCD | yes | yes | `target/stm32g0x.cfg` |
+| stm32u083nucleo | ci | ST-Link | OpenOCD | yes | yes | `target/stm32u0x.cfg` |
+
+Probe serials live in the rig configs (`test/hil/tinyusb.json`, bench
+`local.json`) — always pass them (`--probe` / `adapter serial`).
+
+## Caveats
+
+- **ea4088_quickstart**: probe has no VCOM and the BSP has no UART — RTT is
+ the ONLY console; measured there: 6/6 JLinkExe attaches, boot burst
+ delivered, 24.6 KiB/s drain; the HIL suite runs over the RTT console
+ (device_info-class tests — the cdc/msc-fixture host tests don't speak RTT
+ yet, see the follow-up doc).
+ NEVER point OpenOCD at this J-Link-firmware probe (jaylink knocks it off
+ USB; physical replug). JLinkGDBServer never finds the CB headless on this
+ part; JLinkRTTLogger 0/6.
+- **raspberry_pi_pico2 (htpc, J-Trace)**: pin the probe by serial — that
+ bench runs two J-Links (`-DJLINK_OPTION="-USB <sn>"` for the flash
+ target). Never set a custom JLinkScript for RP2350 over J-Link. Write path
+ untested there only because the flashed example doesn't poll the console
+ (the ci row's debugprobe sweep validated RP2350 writes).
+- **ST-Link rows**: flashed by `STM32_Programmer_CLI`; RTT capture is a
+ separate openocd session (`interface/stlink.cfg` + the target cfg above),
+ attach without reset.
+
+## Excluded (recorded so absence is never read as "works")
+
+- `espressif_s3_devkitm`, `espressif_p4_function_ev` — no SEGGER RTT path in
+ our builds (console is the chip's USB-Serial-JTAG; see `esp-target-debug`).
+- `ek_tm4c123gxl` — flashed by `lm4flash`; no debug-probe path configured on
+ the rig.
+- `nanoch32v203`, `ch32v103r_r1_1v0`, `ch32v307v_r1_1v0`, `ch582m_evt` — a
+ `LOGGER=rtt` build traps on WCH QingKe (the vendored generic RISC-V
+ `SEGGER_RTT_LOCK` reads `mstatus` CSRs → mcause=2; the working lock port
+ `sysview_rtt_lock_wch.h` lives only on branch `claude/add-systemview-debug`),
+ and SDI permits no live streaming anyway (transport matrix). Revisit after
+ that branch merges.
diff --git a/.claude/skills/target-debug/SKILL.md b/.claude/skills/target-debug/SKILL.md
index 050a697b9..7ee96f48e 100644
--- a/.claude/skills/target-debug/SKILL.md
+++ b/.claude/skills/target-debug/SKILL.md
@@ -217,40 +217,36 @@ dump binary memory /tmp/ring.bin &dbg_ring[0] &dbg_ring[512]
## TU_LOG capture
Build with `LOG=2` (`LOG=3` adds per-transfer noise and much more timing skew).
-`LOGGER=rtt` routes it over the debug probe — no UART wiring. SEGGER's host
-tools need a J-Link, but OpenOCD serves the same RTT buffer on ST-Link /
-CMSIS-DAP / WCH-Link boards:
+`LOGGER=rtt` routes it over the debug probe — no UART wiring. Stand the
+channel up per the **rtt** skill (servers per probe, transport matrix,
+control-block gotchas live there):
```bash
-# RTT: JLinkGDBServer from CLAUDE.md "GDB Debugging" + -RTTTelnetPort, then:
-timeout 20s JLinkRTTClient > /tmp/rtt.log # non-interactive capture
+# RTT (J-Link probe; flash + reset first — the console owns the probe):
+timeout 20s python3 tools/rtt.py --backend jlink --probe <sn> --device <JLINK_DEVICE> > /tmp/rtt.log
# UART (board's debug serial, if wired):
stty -F /dev/ttyACM<N> 115200 raw && timeout 20s cat /dev/ttyACM<N> | tee /tmp/uart.log
```
-```bash
-# OpenOCD RTT (any probe OpenOCD drives) — in telnet :4444 (or -c equivalents):
-rtt setup 0x20000000 0x8000 "SEGGER RTT" # RAM ORIGIN + LENGTH (from the .ld/map)
-rtt start # after firmware booted; rerun after each reflash
-rtt server start 19021 0
-# then: timeout 20s nc localhost 19021 > /tmp/rtt.log
-```
-
-OpenOCD polls — bursty logs can drop lines; prefer J-Link where both
-exist. The drain-model warning below applies unchanged.
+OpenOCD RTT (native probes: ST-Link/CMSIS-DAP): rtt skill §OpenOCD — exact
+CB address from `nm`, attach-only. OpenOCD polls — bursty logs can drop
+lines; prefer J-Link where both exist. The drain-model warning below
+applies unchanged.
An RTT-built firmware that has since wedged still holds a log tail in RAM —
but ONLY what fits the drain model: the default SEGGER mode (NO_BLOCK_SKIP)
**drops** writes once the ring fills with no reader, so an undrained target
-holds the first KB after boot, not the wedge tail. There is no overwrite mode
-in stock SEGGER RTT (only SKIP/TRIM/BLOCK): post-mortem RTT is evidence only
-if a live drain was running — otherwise instrument with the RAM ring above.
-Use `JLinkGDBServer -RTTTelnetPort 19021` + `JLinkRTTClient` for the drain
-(proven; note the server briefly halts the core on connect). `JLinkRTTLogger`
-fails to find the control block on some parts (LPC4088) even when it exists
-and even given `-RTTAddress`; don't fight it — `nm` the ELF for `_SEGGER_RTT`,
-read the aUp[0] descriptor (`mem32`), `savebin` the buffer — debug-AP RAM
-reads don't halt the target.
+holds the first KB after boot, not the wedge tail. The buffer flags have no
+overwrite mode (only SKIP/TRIM/BLOCK); keeping the tail instead requires the
+firmware-side overwrite write call (rtt skill §post-mortem). So post-mortem
+RTT from a default-mode build is evidence only if a live drain was running —
+otherwise instrument with the RAM ring above.
+Stand up the drain per the **rtt** skill: JLinkExe's `-RTTTelnetPort` (what
+`rtt.py` wraps) is the headless-proven route; JLinkGDBServer's needs
+a GDB client attached on some parts (LPC4088), and JLinkRTTLogger fails to
+find the control block on some parts (measured LPC4088, 0/6). The manual
+ring read for a wedged target (`nm`/`mem32`/`savebin` — debug-AP reads don't
+halt the core) lives there too.
## GDB — state autopsy and watchpoints
@@ -331,7 +327,7 @@ Linux gadget peer):
```bash
.claude/skills/usbmon/scripts/usbcap.sh cafe: 30 /tmp/host.pcapng & # host URBs (usbmon skill)
-timeout 30s JLinkRTTClient > /tmp/target.rtt & # target (or ring dump after)
+timeout 30s python3 tools/rtt.py --backend jlink --probe <sn> --device <dev> > /tmp/target.rtt & # target (rtt skill; or ring dump after)
wait
```
diff --git a/.claude/skills/usbtest/SKILL.md b/.claude/skills/usbtest/SKILL.md
index 4d7c6e5ab..606b379b5 100644
--- a/.claude/skills/usbtest/SKILL.md
+++ b/.claude/skills/usbtest/SKILL.md
@@ -30,7 +30,7 @@ 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
+- CI (`hil_test.py`) additionally passes `--budget` 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
diff --git a/.claude/workflows/driver-review.js b/.claude/workflows/driver-review.js
index 255b8ac74..3d380c7e0 100644
--- a/.claude/workflows/driver-review.js
+++ b/.claude/workflows/driver-review.js
@@ -1,9 +1,9 @@
export const meta = {
name: 'driver-review',
- description: 'Review driver directories across dimensions with driver-reviewer scanners, then adversarially verify every finding; returns only confirmed findings',
+ description: 'Review driver directories across dimensions with code-verifier scanners, then adversarially verify every finding; returns only confirmed findings',
whenToUse: 'Auditing dcd/hcd drivers for a bug class (pass question) or a full-dimension review (default dimensions)',
phases: [
- { title: 'Scan', detail: 'driver-reviewer per (dir x dimension)' },
+ { title: 'Scan', detail: 'code-verifier per (dir x dimension)' },
{ title: 'Verify', detail: 'adversarial refutation per finding' },
],
}
@@ -58,7 +58,7 @@ const results = await pipeline(
p => agent(
`Review ${p.dir} for exactly one dimension: ${p.dim}. Read the sources yourself. Coverage-first — report everything, a verifier filters.`,
- { label: `scan:${short(p.dir)}`, phase: 'Scan', agentType: 'driver-reviewer', effort: 'xhigh', schema: FINDINGS },
+ { label: `scan:${short(p.dir)}`, phase: 'Scan', agentType: 'code-verifier', schema: FINDINGS },
),
(scan, p) => {
@@ -69,7 +69,8 @@ const results = await pipeline(
`Adversarially verify ONE review finding about ${p.dir}.\nDimension: ${p.dim}\nFinding: ${JSON.stringify(f)}\n` +
'Read the cited code plus enough context (callers, ISR paths, macros, and the datasheet if register-related) to judge. ' +
'Try to REFUTE it; real=true only if it survives your best attempt. Return {"real": bool, "reason": string}.',
- { label: `verify:${short(p.dir)}:${f.line}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: VERDICT },
+ // max: one judgment-dense call per finding decides what survives - worth the top tier
+ { label: `verify:${short(p.dir)}:${f.line}`, phase: 'Verify', agentType: 'code-verifier', effort: 'max', schema: VERDICT },
).then(v => v && { ...f, verdict: v })
)).then(vs => {
const alive = vs.filter(Boolean)
diff --git a/.claude/workflows/fanout-dev.js b/.claude/workflows/fanout-dev.js
index e98a86f1d..386f6df46 100644
--- a/.claude/workflows/fanout-dev.js
+++ b/.claude/workflows/fanout-dev.js
@@ -1,11 +1,11 @@
export const meta = {
name: 'fanout-dev',
- description: 'Implement one described change across many ports/file-sets: one port-dev worker per item, independent builder verification, optional review',
+ description: 'Implement one described change across many ports/file-sets: one code-writer worker per item, independent builder verification, optional review',
whenToUse: 'Applying a fix or pattern across multiple TinyUSB ports (e.g. the same DCD bug in several drivers)',
phases: [
- { title: 'Implement', detail: 'port-dev per item (opus xhigh)' },
+ { title: 'Implement', detail: 'code-writer per item (opus xhigh)' },
{ title: 'Verify', detail: 'builder single-example check' },
- { title: 'Review', detail: 'optional driver-reviewer pass' },
+ { title: 'Review', detail: 'optional code-verifier pass' },
],
}
@@ -71,7 +71,7 @@ const results = await pipeline(
: ' Pick a verification board from hw/bsp whose family uses this scope.'),
{
label: `dev:${short(item)}`, phase: 'Implement',
- agentType: 'port-dev', effort: 'xhigh', schema: DEV,
+ agentType: 'code-writer', schema: DEV,
...(args.worktree ? { isolation: 'worktree' } : {}),
},
),
@@ -96,7 +96,7 @@ const results = await pipeline(
return agent(
`Review the uncommitted change in ${item} (inspect with: git diff -- ${item}) against this task:\n${args.task}\n` +
'Dimension: does the diff correctly and completely implement the task with no unintended side effects? Coverage-first findings.',
- { label: `review:${short(item)}`, phase: 'Review', agentType: 'driver-reviewer', effort: 'xhigh', schema: FINDINGS },
+ { label: `review:${short(item)}`, phase: 'Review', agentType: 'code-verifier', schema: FINDINGS },
).then(f => {
// review: array = findings; null = reviewer died; absent = not requested
if (!f) log(`review:${short(item)}: reviewer agent died`)
diff --git a/.claude/workflows/pr-babysit.js b/.claude/workflows/pr-babysit.js
index 406213a5f..8438d4d04 100644
--- a/.claude/workflows/pr-babysit.js
+++ b/.claude/workflows/pr-babysit.js
@@ -1,47 +1,55 @@
export const meta = {
name: 'pr-babysit',
- description: 'Drive a PR to green: pr-monitor triage (CI + bot reviews), port-dev fixes for validated findings, driver-reviewer verification, one commit+push per cycle',
+ description: 'Drive a PR to green: a fast review lane (validate bot findings, fix, push without waiting on CI) overlapped with a CI-watch lane; code-writer fixes, code-verifier verification, at most one push per lane per cycle',
whenToUse: 'After opening a PR, from a checkout of the PR branch. Default is a dry run (fixes left uncommitted, nothing posted); passing autoPush: true is the explicit authorization for pushes and PR comments.',
phases: [{ title: 'Triage' }, { title: 'Fix' }, { title: 'Verify' }, { title: 'Push' }],
}
-// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run) }
+// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run),
+// checkoutDir?: string (PR branch checkout; default: the session working dir) }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !args.pr) {
- throw new Error('args must be { pr: number, maxCycles?, autoPush? }; run from a checkout of the PR branch')
+ throw new Error('args must be { pr: number, maxCycles?, autoPush?, checkoutDir? }; run from the PR branch checkout or point checkoutDir at it')
}
args.pr = Number(args.pr)
if (!Number.isInteger(args.pr) || args.pr <= 0) {
throw new Error('args.pr must be a positive integer PR number')
}
+const checkoutDir = args.checkoutDir || '.'
+if (typeof checkoutDir !== 'string' || checkoutDir.includes("'")) {
+ throw new Error('checkoutDir must be a plain path string')
+}
+const IN_CHECKOUT = checkoutDir === '.' ? 'The working tree IS the PR checkout. '
+ : `The PR branch checkout is at ${checkoutDir} - run every git/build/file command there, not in the session directory. `
const maxCycles = args.maxCycles ?? 3
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new Error('maxCycles must be an integer >= 1')
}
-const TRIAGE = {
+const CI = {
type: 'object', additionalProperties: false,
- required: ['ci', 'findings', 'replies', 'done'],
+ required: ['status', 'infraRerun', 'realFailures'],
properties: {
- ci: {
- type: 'object', additionalProperties: false,
- required: ['status', 'infraRerun', 'realFailures'],
- properties: {
- status: { type: 'string', enum: ['green', 'red', 'running'] },
- infraRerun: { type: 'array', items: { type: 'string' } },
- realFailures: {
- type: 'array',
- items: {
- type: 'object', additionalProperties: false,
- required: ['check', 'firstError', 'files'],
- properties: {
- check: { type: 'string' }, firstError: { type: 'string' },
- files: { type: 'array', items: { type: 'string' } },
- },
- },
+ status: { type: 'string', enum: ['green', 'red', 'running'] },
+ infraRerun: { type: 'array', items: { type: 'string' } },
+ realFailures: {
+ type: 'array',
+ items: {
+ type: 'object', additionalProperties: false,
+ required: ['check', 'firstError', 'files', 'rigSide'],
+ properties: {
+ check: { type: 'string' }, firstError: { type: 'string' },
+ files: { type: 'array', items: { type: 'string' } },
+ rigSide: { type: 'boolean' },
},
},
},
+ },
+}
+const REVIEWS = {
+ type: 'object', additionalProperties: false,
+ required: ['findings', 'replies', 'done'],
+ properties: {
findings: {
type: 'array',
items: {
@@ -84,6 +92,19 @@ const OP = {
required: ['pass', 'detail'],
properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
}
+const SCOPE = {
+ type: 'object', additionalProperties: false,
+ required: ['files'],
+ properties: { files: { type: 'array', items: { type: 'string' } } },
+}
+const OPIDS = {
+ type: 'object', additionalProperties: false,
+ required: ['pass', 'detail', 'doneIds'],
+ properties: {
+ pass: { type: 'boolean' }, detail: { type: 'string' },
+ doneIds: { type: 'array', items: { type: 'integer' } },
+ },
+}
// Marking a review thread resolved has no REST endpoint — it needs the
// GraphQL resolveReviewThread mutation. Shared recipe handed to the posting
@@ -107,120 +128,282 @@ const postReplyRecipe = (noun) =>
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 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 },
- )
- if (!t) {
- history.push({ cycle, error: 'pr-monitor died' })
- return { pass: false, cycles: cycle, history, reason: 'pr-monitor-died' }
- }
- const entry = { cycle, triage: t }
- history.push(entry)
- // Post drafted replies to REFUTED findings as soon as triage produces them —
- // decoupled from fixing/pushing so done/unactionable cycles still post.
- // Reply AND resolve the thread. Outward-facing, so gated on autoPush.
- const freshReplies = t.replies.filter(r => !repliedIds.has(r.commentId))
- if (freshReplies.length > 0 && args.autoPush === true) {
- const posted = await agent(
- `Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
- `Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where.`,
- { label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
- )
- // attempted counts as replied: better to drop a failed reply than spam duplicates
- freshReplies.forEach(r => repliedIds.add(r.commentId))
- if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
- }
+// Backoff between cycles that have nothing to do but wait. Degrades to a no-op
+// rather than throwing if the workflow host has no timer.
+const nap = (ms) => new Promise(res => { if (typeof setTimeout === 'function') setTimeout(res, ms); else res() })
- if (t.done) {
- log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
- return { pass: true, cycles: cycle, history }
+// Canonicalize a repo-relative path for set/collision comparison: resolve ./..
+// segments, unify separators; '' for anything that escapes the repo or uses
+// characters no repo path does (also makes the path shell-safe to interpolate).
+const canon = (p) => {
+ const s = String(p).trim().replace(/\\/g, '/')
+ // Absolute (CI-runner) paths: reject rather than corrupt into a bogus relative
+ // path — the file-less group then routes through the scoper, which recovers the
+ // real repo path and is existence-checked.
+ if (s.startsWith('/')) return ''
+ const out = []
+ for (const seg of s.split('/')) {
+ if (!seg || seg === '.') continue
+ if (seg === '..') { if (out.pop() === undefined) return '' } else out.push(seg)
}
+ const c = out.join('/')
+ return /^[A-Za-z0-9._+/-]+$/.test(c) ? c : ''
+}
- // Group actionable work by top-level scope (plain JS — no model tokens).
+// Group actionable notes by top-level scope (plain JS — no model tokens).
+const groupWork = (notes) => {
const groups = new Map()
- const groupOf = (key) => {
+ for (const n of notes) {
+ const key = (canon(n.scopeFile) || n.scopeFile).split('/').slice(0, 3).join('/')
if (!groups.has(key)) groups.set(key, { key, files: new Set(), notes: [] })
- return groups.get(key)
+ const g = groups.get(key)
+ n.files.forEach(f => { const c = canon(f); if (c) g.files.add(c) })
+ g.notes.push(n.text)
}
- for (const f of t.findings.filter(x => x.verdict === 'valid')) {
- const g = groupOf(f.file.split('/').slice(0, 3).join('/'))
- g.files.add(f.file)
- g.notes.push(`${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`)
+ return [...groups.values()]
+}
+
+// Fix + verify one work list; returns { ok, fixes } — ok only if every group
+// was scoped, fixed by a live worker, AND passed code-verifier verification.
+const fixAndVerify = async (workIn) => {
+ // code-writer's contract needs an explicit file set: a group whose notes named no
+ // files (a CI failure whose log yielded no paths) is scoped by a dedicated agent
+ // first; if that fails too, the group is withheld (ok=false → human review) rather
+ // than dispatched with an invalid scope.
+ const fileless = workIn.filter(w => w.files.size === 0)
+ await parallel(fileless.map(w => () =>
+ agent(
+ `${IN_CHECKOUT}Determine which repo files must change to address these notes (read the code; if a note is a CI failure, read its CI log too):\n- ${w.notes.join('\n- ')}\n` +
+ 'files = repo-relative paths; empty only if genuinely undeterminable.',
+ { label: `scope:${w.key}`, phase: 'Fix', model: 'sonnet', schema: SCOPE },
+ ).then(s => s && s.files.forEach(f => { const c = canon(f); if (c) w.files.add(c) }))))
+ // Scoped paths are model output: keep only what git ls-files confirms exists.
+ // The check is executed (by a mechanical agent) and intersected here — a dead
+ // checker drops every candidate, so unconfirmed groups fall through to withheld.
+ const candidates = [...new Set(fileless.flatMap(w => [...w.files]))]
+ if (candidates.length > 0) {
+ const v = await agent(
+ `${IN_CHECKOUT}Run exactly: git ls-files -- ${candidates.join(' ')}\nReturn files = the paths that command printed, verbatim — no additions, no substitutions.`,
+ { label: 'scope:verify', phase: 'Fix', model: 'haiku', schema: SCOPE },
+ )
+ const exists = new Set((v ? v.files : []).map(canon))
+ for (const w of fileless) for (const f of [...w.files])
+ if (!exists.has(f)) { w.files.delete(f); log(`scope:${w.key}: dropped ${f} — not confirmed as a repo file`) }
}
- for (const rf of t.ci.realFailures) {
- const g = groupOf((rf.files[0] || rf.check).split('/').slice(0, 3).join('/'))
- rf.files.forEach(x => g.files.add(x))
- g.notes.push(`CI ${rf.check}: ${rf.firstError}`)
+ const unscoped = workIn.filter(w => w.files.size === 0)
+ for (const w of unscoped) log(`fix for ${w.key}: no file scope determinable — withheld for human review`)
+ // Scoping can make groups overlap (two checks resolving to the same file); merge
+ // intersecting groups (to closure) so two fixers never edit one file concurrently.
+ const work = []
+ for (let g of workIn.filter(w => w.files.size > 0)) {
+ for (let i; (i = work.findIndex(m => [...g.files].some(f => m.files.has(f)))) >= 0;) {
+ const [m] = work.splice(i, 1)
+ g.files.forEach(f => m.files.add(f)); m.notes.push(...g.notes); m.key = `${m.key}+${g.key}`
+ g = m
+ }
+ work.push(g)
}
- const work = [...groups.values()]
-
- if (work.length === 0) {
- if (t.ci.status === 'running' || t.ci.infraRerun.length > 0) {
- log(`cycle ${cycle}: only infra re-runs in flight — next cycle waits on them`)
- continue
+ // HIL rig rosters (test/hil/*.json) describe physical hardware the user owns:
+ // never edit them autonomously — skipping/reshaping tests there papers over a
+ // failing fixture. A failure that needs hardware swapped or re-cabled stays RED
+ // for the user; roster edits happen only with the user's explicit approval.
+ const withheld = []
+ for (const w of work) {
+ for (const f of [...w.files]) if (/^test\/hil\/[^/]+\.json$/.test(f)) {
+ w.files.delete(f)
+ log(`fix for ${w.key}: ${f} is a HIL rig config — edits need user approval, dropped from scope`)
+ }
+ if (w.files.size === 0) {
+ withheld.push(w)
+ log(`fix for ${w.key}: only a HIL rig config edit would address it — leaving red for the user`)
}
- log(`cycle ${cycle}: nothing actionable`)
- return { pass: false, cycles: cycle, history, reason: 'unactionable' }
}
-
+ for (const w of withheld) work.splice(work.indexOf(w), 1)
+ const scopeOf = (w) => [...w.files].join(', ')
const fixes = await pipeline(
work,
w => agent(
- `Fix the following issues on the current PR branch (the working tree IS the PR checkout).\n` +
- `Scope: ${[...w.files].join(', ')}\nIssues:\n- ${w.notes.join('\n- ')}`,
- { label: `fix:${w.key}`, phase: 'Fix', agentType: 'port-dev', effort: 'xhigh', schema: DEV },
+ `Fix the following issues on the PR branch. ${IN_CHECKOUT}\n` +
+ 'Constraint: never modify test/hil/*.json (HIL rig hardware config) — a failure that needs hardware swapped/changed stays red for the user.\n' +
+ `Scope: ${scopeOf(w)}\nIssues:\n- ${w.notes.join('\n- ')}`,
+ { label: `fix:${w.key}`, phase: 'Fix', agentType: 'code-writer', schema: DEV },
),
(fix, w) => fix && agent(
- `Verify the uncommitted changes for ${[...w.files].join(', ')} (use git diff -- <files>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
+ `${IN_CHECKOUT}Verify the uncommitted changes for ${scopeOf(w)} (use git diff -- <the files above>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
'Return {"addresses": bool, "reason": string}.',
- { label: `check:${w.key}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: CHECK },
+ { label: `check:${w.key}`, phase: 'Verify', agentType: 'code-verifier', schema: CHECK },
).then(v => ({ ...fix, addresses: !!(v && v.addresses), checkReason: v ? v.reason : 'verifier died' })),
)
- const aliveFixes = fixes.filter(Boolean)
- if (aliveFixes.length < work.length) log(`${work.length - aliveFixes.length} fix group(s) lost to dead workers`)
- entry.fixes = aliveFixes
-
- if (args.autoPush !== true) {
- log('autoPush not set: fixes left uncommitted in the working tree (dry run)')
- return { pass: false, cycles: cycle, history, dryRun: true }
- }
-
- // Verification gates the push: never push a cycle containing an unverified
- // fix or the partial edits of a dead worker.
- const unverified = aliveFixes.filter(f => f.addresses !== true)
- if (aliveFixes.length < work.length || unverified.length > 0) {
- for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
- log(`cycle ${cycle}: fixes left uncommitted for human review — not pushing unverified changes`)
- return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
- }
+ const alive = fixes.filter(Boolean)
+ if (alive.length < work.length) log(`${work.length - alive.length} fix group(s) lost to dead workers`)
+ const unverified = alive.filter(f => f.addresses !== true)
+ for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
+ return { ok: unscoped.length === 0 && withheld.length === 0 && alive.length === work.length && unverified.length === 0, fixes: alive }
+}
+// Verification gates every push: never push unverified or partial edits.
+const commitAndPush = async (cycle, what) => {
const push = await agent(
- `On the current PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} fixes for PR #${args.pr}, repo commit conventions), ` +
+ `${IN_CHECKOUT}On the PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} ${what} fixes for PR #${args.pr}, repo commit conventions), ` +
"then push to the PR's remote branch. pass=true only if commit AND push succeeded; detail = pushed SHA.",
- { label: `push#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
+ { label: `push#${cycle}-${what}`, phase: 'Push', model: 'sonnet', schema: OP },
+ )
+ return push && push.pass ? push : null
+}
+
+for (let cycle = 1; cycle <= maxCycles; cycle++) {
+ // Two independent lanes, launched together. The review lane never waits on
+ // CI: it validates, fixes, and pushes while the CI lane is still watching.
+ const ciPromise = agent(
+ `Watch CI for PR #${args.pr} per your procedure; wait for pending checks.`,
+ { label: `ci#${cycle}`, phase: 'Triage', agentType: 'pr-ci-watcher', schema: CI },
+ ).catch(e => { log(`cycle ${cycle}: pr-ci-watcher errored — ${e && e.message}`); return null })
+ // Every early return below leaves the loop while the CI lane is still
+ // running: settle it first so no CI agent outlives the workflow.
+ const stopWith = async (result) => { await ciPromise; return result }
+
+ const r = await agent(
+ `Validate the bot review findings on PR #${args.pr} per your procedure. ${IN_CHECKOUT}`,
+ { label: `reviews#${cycle}`, phase: 'Triage', agentType: 'pr-review-validator', schema: REVIEWS },
)
- if (!push || !push.pass) {
- log(`cycle ${cycle}: push failed — stopping`)
- return { pass: false, cycles: cycle, history, reason: 'push-failed' }
+ if (!r) {
+ history.push({ cycle, error: 'pr-review-validator died' })
+ return await stopWith({ pass: false, cycles: cycle, history, reason: 'review-validator-died' })
}
+ const entry = { cycle, reviews: r }
+ history.push(entry)
+ // Outward reply/resolve attempts this cycle that did not fully complete; a green
+ // PR must not terminate the loop while any remain, or the retry never happens.
+ let pendingReplies = 0
- // The valid bot findings were fixed and pushed — answer each inline comment
- // with what changed and resolve its thread. CI-failure work has no comment.
- const fixed = t.findings.filter(x => x.verdict === 'valid')
- if (fixed.length > 0) {
+ // Post drafted replies to REFUTED findings immediately. Outward-facing,
+ // so gated on autoPush.
+ const freshReplies = r.replies.filter(x => !repliedIds.has(x.commentId))
+ if (freshReplies.length > 0 && args.autoPush === true) {
+ const posted = await agent(
+ `Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
+ 'If a thread already carries an identical reply of ours (a prior attempt that posted but failed to resolve), do not repost — just resolve it. ' +
+ `Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where. ` +
+ 'doneIds = the commentIds fully handled: reply posted (or already present) AND (thread resolved, or an issue comment with no thread to resolve).',
+ { label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OPIDS },
+ )
+ // Per-id accounting, matching the resolve path: only fully handled ids are marked
+ // replied; a failed reply/resolve stays fresh and retries next cycle (the prompt's
+ // already-present check keeps the retry from duplicating the reply).
+ for (const id of (posted && posted.doneIds) || []) repliedIds.add(id)
+ pendingReplies += freshReplies.filter(x => !repliedIds.has(x.commentId)).length
+ if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
+ }
+
+ // ---- review lane: fix + push without waiting for CI ----
+ const validFindings = r.findings.filter(x => x.verdict === 'valid')
+ let reviewPushed = false
+ if (validFindings.length > 0) {
+ const work = groupWork(validFindings.map(f => ({
+ scopeFile: f.file, files: [f.file],
+ text: `${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`,
+ })))
+ const { ok, fixes } = await fixAndVerify(work)
+ entry.reviewFixes = fixes
+ if (args.autoPush !== true) {
+ log('autoPush not set: review-lane fixes left uncommitted (dry run)')
+ return await stopWith({ pass: false, cycles: cycle, history, dryRun: true })
+ }
+ if (!ok) {
+ log(`cycle ${cycle}: review-lane fixes left uncommitted for human review — not pushing unverified changes`)
+ return await stopWith({ pass: false, cycles: cycle, history, reason: 'fix-verification-failed' })
+ }
+ const push = await commitAndPush(cycle, 'review')
+ if (!push) {
+ log(`cycle ${cycle}: review-lane push failed — stopping`)
+ return await stopWith({ pass: false, cycles: cycle, history, reason: 'push-failed' })
+ }
+ reviewPushed = true
const resolved = await agent(
`The fixes for PR #${args.pr}'s valid review findings were just committed and pushed (${push.detail}). ` +
`For each finding below: ${postReplyRecipe('fix note')}` +
'Each reply states the finding is fixed in the pushed commit, with one line on the change. ' +
- `Findings: ${JSON.stringify(fixed.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
- 'pass=true only if every reply was posted and every thread resolved; detail = what went where.',
- { label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
+ `Findings: ${JSON.stringify(validFindings.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
+ 'pass=true only if every reply was posted and every thread resolved; detail = what went where. ' +
+ 'doneIds = the commentIds fully handled: reply posted AND (thread resolved, or an issue comment with no thread to resolve).',
+ { label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OPIDS },
)
+ // Per-id accounting: a fully handled finding never re-replies (an issue comment
+ // has no thread to resolve, so it re-harvests as stale next cycle and would get
+ // a duplicate "fixed" note); an unfinished one stays out of repliedIds so its
+ // reply/resolve is retried next cycle instead of silently abandoned.
+ for (const id of (resolved && resolved.doneIds) || []) repliedIds.add(id)
+ pendingReplies += validFindings.filter(f => !repliedIds.has(f.commentId)).length
if (!resolved || !resolved.pass) log(`cycle ${cycle}: fixed reply/resolve incomplete — ${resolved ? resolved.detail : 'agent died'}`)
}
+
+ // ---- CI lane result ----
+ const c = await ciPromise
+ entry.ci = c
+ if (!c) {
+ log(`cycle ${cycle}: pr-ci-watcher died — re-arming`)
+ continue
+ }
+ if (reviewPushed) {
+ // The push restarted CI: this cycle's CI verdict is superseded. Re-arm;
+ // next cycle's ci#N watches the fresh run.
+ log(`cycle ${cycle}: review-lane push superseded the CI run — re-arming`)
+ continue
+ }
+ const rigSide = c.realFailures.filter(rf => rf.rigSide)
+ for (const rf of rigSide) log(`cycle ${cycle}: rig-side CI failure (not fixing): ${rf.check} — ${rf.firstError.slice(0, 120)}`)
+ const fixable = c.realFailures.filter(rf => !rf.rigSide)
+ if (fixable.length > 0) {
+ const work = groupWork(fixable.map(rf => ({
+ scopeFile: rf.files[0] || rf.check, files: rf.files,
+ text: `CI ${rf.check}: ${rf.firstError}`,
+ })))
+ const { ok, fixes } = await fixAndVerify(work)
+ entry.ciFixes = fixes
+ if (args.autoPush !== true) {
+ log('autoPush not set: CI-lane fixes left uncommitted (dry run)')
+ return { pass: false, cycles: cycle, history, dryRun: true }
+ }
+ if (!ok) {
+ log(`cycle ${cycle}: CI-lane fixes left uncommitted for human review — not pushing unverified changes`)
+ return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
+ }
+ if (!(await commitAndPush(cycle, 'ci'))) {
+ log(`cycle ${cycle}: CI-lane push failed — stopping`)
+ return { pass: false, cycles: cycle, history, reason: 'push-failed' }
+ }
+ continue // pushed: fresh CI run next cycle
+ }
+ if (r.done && c.status === 'green') {
+ if (pendingReplies > 0) {
+ log(`cycle ${cycle}: PR green but ${pendingReplies} reply/resolve unfinished — re-arming to retry`)
+ continue
+ }
+ log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
+ return { pass: true, cycles: cycle, history }
+ }
+ if (r.done && rigSide.length > 0 && fixable.length === 0 && c.infraRerun.length === 0 && c.status !== 'running') {
+ log(`cycle ${cycle}: CI red only from rig-side failures — human/rig attention needed, nothing to fix in the PR`)
+ return { pass: false, cycles: cycle, history, reason: 'ci-red-rig-side' }
+ }
+ if (c.status === 'running' || c.infraRerun.length > 0) {
+ log(`cycle ${cycle}: CI still settling (${c.infraRerun.length} infra re-run(s)) — re-arming`)
+ continue
+ }
+ if (!r.done) {
+ // A bot has not reported for this head SHA yet. With CI already green there is
+ // nothing else to wait on, so back off before re-arming or the cycle budget
+ // burns on back-to-back re-harvests of the same unchanged PR.
+ if (cycle < maxCycles) {
+ log(`cycle ${cycle}: auto-review still pending — re-arming after a wait`)
+ await nap(60000 * cycle) // no wait on the last cycle: nothing would re-check after it
+ } else {
+ log(`cycle ${cycle}: auto-review still pending — cycle budget exhausted`)
+ }
+ continue
+ }
+ log(`cycle ${cycle}: nothing actionable`)
+ return { pass: false, cycles: cycle, history, reason: 'unactionable' }
}
return { pass: false, cycles: maxCycles, history, reason: 'maxCycles reached' }
diff --git a/.claude/workflows/validate.js b/.claude/workflows/validate.js
index 522548ee6..773dd47ad 100644
--- a/.claude/workflows/validate.js
+++ b/.claude/workflows/validate.js
@@ -1,18 +1,33 @@
export const meta = {
name: 'validate',
- description: 'Pre-PR software validation: unit tests + per-board build sweeps + code-size compare + PVS, in parallel, joined into one verdict',
+ description: 'Pre-PR software validation loop: unit tests + per-board build sweeps + code-size compare + PVS + diff reviews (claude + codex) in parallel; a red verdict dispatches a fix agent for the confirmed findings, then the affected stages re-run — up to maxCycles (default 5) validation passes; a fix that edits a workflow file stops with restartRequired so the caller re-invokes it',
whenToUse: 'Before opening or updating a PR, after any non-trivial change',
- phases: [{ title: 'Validate', detail: 'unit + builds + size + pvs in parallel' }],
+ phases: [
+ { title: 'Validate', detail: 'unit + builds + size + pvs + reviews in parallel' },
+ { title: 'Fix', detail: 'one fix agent per red cycle; commits, then affected stages re-run' },
+ ],
}
-// args: { boards: string[], examples?: string, base?: string, skip?: ('unit'|'size'|'pvs')[] }
+// args: { boards: string[], examples?: string, base?: string,
+// skip?: ('unit'|'size'|'pvs'|'review'|'codex')[], maxCycles?: number }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
- throw new Error('args must be { boards: string[], examples?, base?, skip? }')
+ throw new Error('args must be { boards: string[], examples?, base?, skip?, maxCycles? }')
}
+if (args.maxCycles !== undefined && (!Number.isInteger(args.maxCycles) || args.maxCycles < 1)) {
+ throw new Error('maxCycles must be an integer >= 1')
+}
+const maxCycles = args.maxCycles ?? 5
const skip = args.skip || []
for (const s of skip) log(`stage skipped by request: ${s}`)
const base = args.base || 'master'
+// Every stage agent re-resolves `base` in every cycle, and the fixer commits
+// between cycles: a moving expression (HEAD~1, @{u}, a ^/~ walk) would advance
+// with each fix commit, so cycle 2 would review only the fix and silently drop
+// the original branch changes. Accept stationary refs only.
+if (/(^|[^\w/-])HEAD/.test(base) || /[~^]/.test(base) || base.includes('@{')) {
+ throw new Error(`base must be a fixed ref (sha or branch name), not the moving expression "${base}" — resolve it with git rev-parse first`)
+}
const clip = (s, n = 800) =>
s.length > n ? s.slice(0, n) + ` …[truncated ${s.length - n} chars]` : s
@@ -56,45 +71,268 @@ const PVS = {
},
}
-const thunks = []
+const REVIEW = {
+ type: 'object', additionalProperties: false,
+ required: ['pass', 'findings', 'detail'],
+ properties: {
+ pass: { type: 'boolean' },
+ findings: {
+ type: 'array',
+ items: {
+ type: 'object', additionalProperties: false,
+ required: ['file', 'line', 'severity', 'summary'],
+ properties: {
+ file: { type: 'string' }, line: { type: 'integer' },
+ severity: { type: 'string' }, summary: { type: 'string' },
+ },
+ },
+ },
+ detail: { type: 'string' },
+ },
+}
+
+const FIX = {
+ type: 'object', additionalProperties: false,
+ required: ['changed', 'commit', 'files', 'summary'],
+ properties: {
+ changed: { type: 'boolean' }, commit: { type: 'string' },
+ files: { type: 'array', items: { type: 'string' } },
+ summary: { type: 'string' },
+ },
+}
-if (!skip.includes('unit')) thunks.push(() =>
- agent(
+// read back out of git what the fix commit actually touched
+const PATHS = {
+ type: 'object', additionalProperties: false,
+ required: ['paths', 'isHead'],
+ properties: {
+ paths: { type: 'array', items: { type: 'string' } },
+ isHead: { type: 'boolean' },
+ },
+}
+
+// gate helpers — enforced here, never trusted from the agents
+const confirmedReview = f =>
+ /^confirmed/i.test(f.severity) && !/quality|simplification|style/i.test(f.severity)
+const codexBlocking = f => /\bP[01]\b/i.test(f.severity)
+
+// ---------------------------------------------------------------------------
+// Stage builders, parameterized so later cycles can re-run a subset. Stage
+// names: 'unit', 'build:<board>', 'size', 'pvs', 'review', 'codex'.
+// ---------------------------------------------------------------------------
+const stageNames = []
+if (!skip.includes('unit')) stageNames.push('unit')
+for (const b of args.boards) stageNames.push(`build:${b}`)
+if (!skip.includes('size')) stageNames.push('size')
+if (!skip.includes('pvs')) stageNames.push('pvs')
+if (!skip.includes('review')) stageNames.push('review')
+if (!skip.includes('codex')) stageNames.push('codex')
+
+function stageThunk(name, cycle) {
+ const label = (cycle > 1 ? `c${cycle}:` : '') + name
+ // findings: [] so a dead review/codex stage flows through fixerEvidence()
+ // instead of throwing on f.findings inside the fix dispatch's catch
+ const died = { stage: name, pass: false, findings: [], detail: 'stage agent died' }
+
+ if (name === 'unit') return () => agent(
'Run the TinyUSB unit tests: cd test/unit-test && ceedling test:all. ' +
'pass=true only if every test passes. detail = the ceedling summary line, or the first failing test output.',
- { label: 'unit', phase: 'Validate', model: 'haiku', schema: STAGE },
- ).then(r => r && { stage: 'unit', ...r }))
+ { label, phase: 'Validate', model: 'haiku', schema: STAGE },
+ ).then(r => r ? { stage: name, ...r } : died).catch(() => died)
-for (const b of args.boards) thunks.push(() =>
- agent(
- `Build TinyUSB examples for board ${b}` + (args.examples ? ` (only: ${args.examples})` : ' (full example set)') + '.',
- { label: `build:${b}`, phase: 'Validate', agentType: 'builder', schema: BUILD },
- ).then(r => r && {
- stage: `build:${b}`, pass: r.pass,
- detail: r.pass ? `${r.builtCount} examples built` : clip(JSON.stringify(r.failures)),
- }))
+ if (name.startsWith('build:')) {
+ const b = name.slice('build:'.length)
+ return () => agent(
+ `Build TinyUSB examples for board ${b}` + (args.examples ? ` (only: ${args.examples})` : ' (full example set)') + '.',
+ { label, phase: 'Validate', agentType: 'builder', schema: BUILD },
+ ).then(r => r ? {
+ stage: name, pass: r.pass,
+ detail: r.pass ? `${r.builtCount} examples built` : clip(JSON.stringify(r.failures)),
+ } : died).catch(() => died)
+ }
-if (!skip.includes('size')) thunks.push(() =>
- agent(
+ if (name === 'size') return () => agent(
`Compare TinyUSB code size against ${base}: python3 tools/metrics_compare_base.py --base-branch ${base} -b ${args.boards[0]} -e device/cdc_msc (exactly this command — no extra positional args). ` +
'The report lands in cmake-metrics/<board>/metrics_compare.md. pass=false only if the tool itself errors; ' +
'detail = the flash/RAM delta summary from the report (mention any example that grew).',
- { label: 'size', phase: 'Validate', model: 'haiku', schema: STAGE },
- ).then(r => r && { stage: 'size', ...r }))
+ { label, phase: 'Validate', model: 'haiku', schema: STAGE },
+ ).then(r => r ? { stage: name, ...r } : died).catch(() => died)
-if (!skip.includes('pvs')) thunks.push(() =>
- agent(
+ if (name === 'pvs') return () => agent(
`Run PVS-Studio static analysis for board ${args.boards[0]}, gating on files changed vs ${base}. ` +
'Parallel build agents are running — use your dedicated build dir, never cmake-build-<board>.',
- { label: 'pvs', phase: 'Validate', agentType: 'static-analyzer', effort: 'low', schema: PVS },
- ).then(r => r && {
- stage: 'pvs', pass: r.pass,
+ { label, phase: 'Validate', agentType: 'static-analyzer', effort: 'low', schema: PVS },
+ ).then(r => r ? {
+ stage: name, pass: r.pass,
detail: r.pass ? r.detail : clip(`${r.detail} ${JSON.stringify(r.changedFindings)}`),
- }))
+ } : died).catch(() => died)
+
+ if (name === 'review') return () => agent(
+ `Code-review this branch's diff vs ${base} (git diff ${base}...HEAD), coverage-first: walk every hunk, no spot checks. ` +
+ 'Find pass — candidate defects across all dimensions: correctness/logic, ISR & concurrency safety, ' +
+ 'memory/resource handling (bounds, leaks, no dynamic alloc), API contract & spec conformance, ' +
+ 'security of untrusted input parsing, behavior regressions; plus quality/simplification notes. ' +
+ 'Verify pass — adversarially check each candidate against the surrounding code: verdict CONFIRMED ' +
+ '(failing scenario constructed) or PLAUSIBLE (could not refute); report both, drop only refuted ones. ' +
+ 'Read-only: never apply fixes. severity = verdict plus category (e.g. "CONFIRMED correctness"). ' +
+ 'pass=false if any CONFIRMED correctness/safety/security bug survives; PLAUSIBLE and quality findings keep pass=true. ' +
+ 'detail = one-line review summary.',
+ { label, phase: 'Validate', model: 'opus', effort: 'high', schema: REVIEW },
+ ).then(r => r ? {
+ stage: name,
+ pass: r.pass && !r.findings.some(confirmedReview),
+ findings: r.findings, detail: r.detail,
+ } : died).catch(() => died)
+
+ if (name === 'codex') return () => agent(
+ `Run a Codex review of this branch's diff vs ${base}: ` +
+ `codex review --base ${base} -c model="gpt-5.6-sol" -c model_reasoning_effort="high" ` +
+ '(Bash timeout 600000; run from the repo root). Parse its output into findings; severity = Codex\'s priority label. ' +
+ 'pass=false only if Codex reports a correctness bug (P0/P1); style-level items keep pass=true. ' +
+ 'detail = Codex\'s overall verdict line. If the codex CLI is missing or the run errors, pass=false with the error in detail.',
+ { label, phase: 'Validate', model: 'haiku', schema: REVIEW },
+ ).then(r => r ? {
+ stage: name,
+ pass: r.pass && !r.findings.some(codexBlocking),
+ findings: r.findings, detail: r.detail,
+ } : died).catch(() => died)
+
+ throw new Error(`unknown stage ${name}`)
+}
+
+// Only the material that FAILED the gate reaches the fixer: confirmed review
+// findings, codex P0/P1, and failed unit/build/size/pvs stage evidence.
+// PLAUSIBLE and quality findings stay report-only — fixing them here would
+// churn style on an otherwise green branch. Bounded at the leaves (per-stage
+// finding cap, clipped summaries/details) so the serialized JSON stays valid
+// and every failed stage is represented — a document-level clip could cut
+// mid-JSON and silently drop trailing stages.
+function fixerEvidence(failures) {
+ return failures.map(f => {
+ const findings = (f.findings || [])
+ .filter(f.stage === 'review' ? confirmedReview : codexBlocking)
+ .slice(0, 10)
+ .map(x => ({ ...x, summary: clip(x.summary, 300) }))
+ if (f.stage === 'review' || f.stage === 'codex')
+ return { stage: f.stage, detail: clip(f.detail, 300), findings }
+ return { stage: f.stage, detail: clip(f.detail) }
+ })
+}
+
+function fixThunkPrompt(cycle, failures) {
+ return 'You are the fix agent of the validate loop, cycle ' + cycle + ', in this TinyUSB repo (work from the repo root). ' +
+ 'Failed stages: ' + failures.map(f => f.stage).join(', ') + '. ' +
+ 'The JSON below carries their evidence (review findings are pre-verified CONFIRMED, codex ones are P0/P1):\n' +
+ JSON.stringify(fixerEvidence(failures), null, 1) + '\n\n' +
+ 'For each item: verify it against the actual code first; fix the real ones with the smallest correct change, matching surrounding style. ' +
+ 'Skip anything that is an infrastructure failure rather than a code defect (missing CLI, tool crash, dead stage agent) and anything you can refute with evidence — say which and why in summary. ' +
+ 'Run the tests/suites covering what you changed. ' +
+ 'BEFORE editing anything, run git status --porcelain and record every path already dirty in either column ' +
+ '(staged or unstaged — those are someone else\'s in-flight edits, and git add <path> would sweep them into your commit). ' +
+ 'If any file you need to modify is in that set, edit nothing at all: return changed=false, naming the file in summary. ' +
+ 'Commit as ONE commit, staging ONLY the files you changed (git add <paths> — never git add -A or git commit -a). ' +
+ 'Message: imperative mood, subject like "validate: fix cycle ' + cycle + ' findings", ' +
+ 'NO trailers of any kind (no Co-Authored-By, no Claude-Session). Do NOT push. Never spawn subagents. ' +
+ 'Return: changed=true only if you committed; commit = the new sha (empty string if none); ' +
+ 'files = the commit\'s own paths, verbatim from git show --name-only --format= HEAD (empty if you did not commit); ' +
+ 'summary = one paragraph of what was fixed/skipped and why.'
+}
+
+// ---------------------------------------------------------------------------
+// The loop: validate → (red) fix → re-run affected stages, up to maxCycles
+// validation passes. Reviews always re-run after a fix (their input is the
+// diff, which just changed); unit/builds/size/pvs re-run only when the fix
+// touched code they consume, or when they failed themselves.
+// ---------------------------------------------------------------------------
+const latest = new Map() // stage name -> most recent result
+const history = []
+let toRun = new Set(stageNames)
+
+for (let cycle = 1; cycle <= maxCycles; cycle++) {
+ log(`cycle ${cycle}/${maxCycles}: running ${toRun.size}/${stageNames.length} stage(s)`)
+ const results = await parallel([...toRun].map(n => stageThunk(n, cycle)))
+ for (const r of results.filter(Boolean)) latest.set(r.stage, r)
+ const failures = [...latest.values()].filter(r => !r.pass)
+ const entry = { cycle, ran: [...toRun], failed: failures.map(f => f.stage), fix: null }
+ history.push(entry)
+
+ if (failures.length === 0) {
+ log(`cycle ${cycle}: all stages green`)
+ return { pass: true, cycles: history, stages: [...latest.values()], failures: [] }
+ }
+ log(`cycle ${cycle}: ${failures.length} stage(s) failing: ${entry.failed.join(', ')}`)
+ if (cycle === maxCycles) break
+
+ const fix = await (async () => {
+ try {
+ return await agent(fixThunkPrompt(cycle, failures),
+ { label: `c${cycle}:fix`, phase: 'Fix', model: 'sonnet', schema: FIX })
+ } catch { return null }
+ })()
+ if (!fix) { entry.fix = 'fix agent died'; break }
+ entry.fix = { changed: fix.changed, commit: fix.commit, files: fix.files, summary: clip(fix.summary) }
+ if (!fix.changed) {
+ // Nothing fixable in code. A dead stage agent is still worth retrying —
+ // that failure is transient infrastructure, and a retry is the only
+ // useful action for it. Everything else (refuted findings, missing CLI)
+ // would just spin, so stop with the report.
+ const deadStages = failures.filter(f => f.detail === 'stage agent died').map(f => f.stage)
+ if (deadStages.length > 0) {
+ log(`cycle ${cycle}: fix agent changed nothing — retrying dead stage(s): ${deadStages.join(', ')}`)
+ toRun = new Set(deadStages)
+ continue
+ }
+ log(`cycle ${cycle}: fix agent changed nothing — stopping`)
+ break
+ }
+
+ // What re-runs is gated on what the commit actually contains, never on the
+ // fixer's self-report (the FIX schema lets `files` be empty or wrong, and a
+ // code fix reported as a doc path would keep stale-green results). Read the
+ // paths back out of git; if that read fails, fall back to the self-report and
+ // grant no exemption below.
+ const verified = await (async () => {
+ if (!fix.commit) return null
+ try {
+ return await agent(
+ `In this repo run: git show --name-only --format= ${fix.commit} and git rev-parse HEAD. ` +
+ 'paths = the repo-relative paths that commit touched, verbatim, one per array entry; ' +
+ `isHead = true only if git rev-parse HEAD is exactly ${fix.commit}. ` +
+ 'Read-only: edit nothing, commit nothing, never spawn subagents.',
+ { label: `c${cycle}:fix-paths`, phase: 'Fix', model: 'haiku', schema: PATHS })
+ } catch { return null }
+ })()
+ const trusted = !!(verified && verified.isHead && verified.paths.length > 0)
+ const files = trusted ? verified.paths : (fix.files || [])
+ entry.fix.files = files
+ entry.fix.verified = trusted
+ if (!trusted) log(`cycle ${cycle}: could not confirm the fix commit's paths — treating the fix as touching everything`)
+
+ // The fixer rewrote this workflow, but the stage thunks, the gates and this
+ // loop are the old file — already loaded in memory. Re-running here would
+ // validate the corrected workflow with superseded orchestration and could
+ // report green off it, so hand the restart back to the caller instead.
+ const workflowFiles = files.filter(f => /^\.claude\/workflows\//.test(f))
+ if (workflowFiles.length > 0) {
+ log(`cycle ${cycle}: the fix commit edits ${workflowFiles.join(', ')} — stopping. ` +
+ 'Re-invoke validate so the committed workflow is loaded fresh; this run\'s verdict is not final.')
+ return { pass: false, restartRequired: true, cycles: history, stages: [...latest.values()], failures }
+ }
+
+ // A fix can invalidate any stage: builds/unit consume src|hw|examples|test,
+ // and size/pvs run tools the fixer may have edited. Only a pure-docs fix
+ // is safe to exempt — everything else re-runs the full stage set. Agent and
+ // skill instructions are Markdown but drive the stage agents themselves, so
+ // they are operational, not documentation: editing them must re-run
+ // everything, or the loop reports green on results the old instructions produced.
+ const docsOnly = trusted && files.length > 0 && files.every(f =>
+ !f.startsWith('.claude/') && f !== 'CLAUDE.md' && f !== 'AGENTS.md' &&
+ (/^docs\//.test(f) || f.endsWith('.md') || f.endsWith('.rst')))
+ toRun = new Set(failures.map(f => f.stage))
+ if (!skip.includes('review')) toRun.add('review')
+ if (!skip.includes('codex')) toRun.add('codex')
+ if (!docsOnly) for (const n of stageNames) toRun.add(n)
+}
-const results = (await parallel(thunks)).filter(Boolean)
-const dead = thunks.length - results.length
-if (dead > 0) log(`${dead} stage agent(s) died — counted as failures`)
-const failures = results.filter(r => !r.pass)
-log(`${results.length}/${thunks.length} stages completed, ${failures.length} failing`)
-return { pass: failures.length === 0 && dead === 0, stages: results, failures }
+const failures = [...latest.values()].filter(r => !r.pass)
+return { pass: false, cycles: history, stages: [...latest.values()], failures }
diff --git a/.github/scripts/hil_ci_set_matrix.py b/.github/scripts/hil_ci_set_matrix.py
index bf50061dd..b567f347c 100644
--- a/.github/scripts/hil_ci_set_matrix.py
+++ b/.github/scripts/hil_ci_set_matrix.py
@@ -1,5 +1,6 @@
import argparse
import json
+import shlex
import os
import sys
@@ -112,14 +113,20 @@ def main():
# Each variant builds into cmake-build-<variant.name> with its own cmake
# -D defines and raw CFLAGS. No 'variant' -> a single build named after
- # the board.
+ # the board; an always-on define (MAX3421_HOST=1, LOGGER=rtt) is a single
+ # self-named variant carrying it.
variants = board.get('variant') or [{'name': name, 'flags': ''}]
for v in variants:
arg = build_board
if v['name'] != name:
arg += f' --build-name {v["name"]}'
+ # build_util.yml's Build step splices this string into bash source,
+ # so the quoting round-trips a spaced value into one argv item like
+ # build_board's argv path. The SAME string also reaches the get_deps
+ # env expansion and the artifact-name charset, where spaced/quoted
+ # values still fail (loudly) -- keep defines space-free
for d in v.get('defines', []):
- arg += f' -D{d}'
+ arg += f' -D{shlex.quote(d)}'
for tok in v.get('flags', '').split():
arg += f' --cflag={tok}'
append_build_arg(toolchain, arg)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c26fe5cf8..70555b111 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -41,6 +41,7 @@ jobs:
- 'tools/ci_select.py'
- 'tools/get_deps.py'
- 'tools/metrics.py'
+ - 'tools/rtt.py'
- '.github/actions/**'
- '.github/workflows/build.yml'
- '.github/workflows/build_util.yml'
diff --git a/.gitignore b/.gitignore
index d74f12459..14bc22b61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,3 +96,4 @@ hw/mcu/sony/cxd56/spresense-exported-sdk/
hw/mcu/st/
hw/mcu/ti/
hw/mcu/wch/
+test/hil/local.json
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7a29dc89a..537ed3bc4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -51,7 +51,7 @@ repos:
# Two hooks, split by what each suite RUNS, not by what it reads: discovery is
# disjoint (test_hil*.py vs the two named suites) so nothing runs twice, but the
# file patterns overlap where both suites care. hil-test runs test_hil*.py only
- # (~80s: deliberate hang and timeout simulations) and is scoped to the rig harness
+ # (~45s: deliberate hang and timeout simulations) and is scoped to the rig harness
# that owns them. The one part of it the selector depends on - the BottomLayer
# stdlib-closure AST guard over tools/ci_select.py and its imports - is named
# explicitly by ci-select-test instead, so a tools/ or workflow edit costs 4s
@@ -69,7 +69,7 @@ repos:
# 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/)
+ files: ^(test/hil/|examples/device/mtp/src/|tools/rtt\.py$)
entry: python3 -m unittest discover -s test/hil/test -p 'test_hil*.py'
pass_filenames: false
language: system
@@ -84,7 +84,7 @@ repos:
language: system
- id: ci-select-test
name: ci-select-test
- files: ^(hw/bsp/|hw/mcu/|src/|examples/|test/hil/|tools/(ci_select|build|build_utils|get_deps|metrics)\.py$|\.github/(scripts|workflows)/|\.circleci/)
+ files: ^(hw/bsp/|hw/mcu/|src/|examples/|test/hil/|tools/(ci_select|build|build_utils|get_deps|metrics|rtt)\.py$|\.github/(scripts|workflows)/|\.circleci/)
entry: sh -c "python3 test/hil/test/test_ci_select.py && python3 test/hil/test/test_ci_metrics.py && cd test/hil/test && python3 -m unittest -q test_hil_util.BottomLayer"
pass_filenames: false
language: system
diff --git a/CLAUDE.md b/CLAUDE.md
index c43a4f9f7..d3ab995bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,7 +74,7 @@ Terminal 2 — connect (`<port>`: 2331 JLink, 3333 OpenOCD):
arm-none-eabi-gdb build/your_app.elf
(gdb) target remote :<port> # then: monitor reset halt → load → continue
```
-**RTT:** build `LOG=2 LOGGER=rtt`, run JLinkGDBServer with `-RTTTelnetPort 19021`, then `JLinkRTTClient` (`timeout 20s JLinkRTTClient > rtt.log` for non-interactive capture).
+**RTT:** build `LOG=2 LOGGER=rtt`; capture/console via the `rtt` skill (`.claude/skills/rtt/SKILL.md`).
## Testing
diff --git a/docs/superpowers/followup/pr3803-hil-blindness-reporting.md b/docs/superpowers/followup/pr3803-hil-blindness-reporting.md
deleted file mode 100644
index 374ee62c7..000000000
--- a/docs/superpowers/followup/pr3803-hil-blindness-reporting.md
+++ /dev/null
@@ -1,186 +0,0 @@
-# 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, {hil_report.BOUNDARY_CELL:
- f'{hil_report.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-usbtest-recovery-reserve.md b/docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md
deleted file mode 100644
index eb8959520..000000000
--- a/docs/superpowers/followup/pr3803-usbtest-recovery-reserve.md
+++ /dev/null
@@ -1,175 +0,0 @@
-# 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/followup/pr3840-mret-board-result.md b/docs/superpowers/followup/pr3840-mret-board-result.md
index 7b8da7b9c..77b76b605 100644
--- a/docs/superpowers/followup/pr3840-mret-board-result.md
+++ b/docs/superpowers/followup/pr3840-mret-board-result.md
@@ -3,6 +3,15 @@
**Origin:** split out of PR #3840 (making `hil_report.md` a rendering of `hil_report.json`).
Delete this file when its own PR lands.
+> **SUPERSEDED IN PART (2026-08-26).** Written against a 7-field tuple whose index 5 was
+> `blind`. The sysfs blindness subsystem is gone: `test_board` now returns **6** fields with
+> `stray` at index 5, and its board-locked early return is 5 wide. The problem described
+> below is unchanged and still worth fixing — three producers, three widths, and
+> `len(r) > 5 and r[5]` reads a WRONG SLOT rather than raising. But drop the `blind` field
+> from the proposed NamedTuple and re-derive every index from `hil_test.test_board` before
+> executing, or `_stray_note` starts reading a duration as a stray count.
+> `StrayNoteSurvivesTheTupleWidth` pins the current shape.
+
## What is established
`test_board()` returns a bare tuple that three producers build and fourteen call sites read
diff --git a/docs/superpowers/followup/pr3853-board-putchar-logger.md b/docs/superpowers/followup/pr3853-board-putchar-logger.md
new file mode 100644
index 000000000..46a4417bd
--- /dev/null
+++ b/docs/superpowers/followup/pr3853-board-putchar-logger.md
@@ -0,0 +1,57 @@
+# `board_putchar` is not LOGGER-aware
+
+**Origin:** surfaced while validating the RTT console in PR #3853 (the `rtt` skill
+promotion), which is harness-only scope. This is a src-level fix to `hw/bsp/board.c`
+that touches every board/logger combination, so it needs its own build sweep rather
+than a drive-by. Delete this file when its own PR lands.
+
+## Established (with evidence)
+
+`hw/bsp/board.c` retargets stdio through `sys_write`/`sys_read`, which are compiled
+per logger: `SEGGER_RTT_Write`/`SEGGER_RTT_Read` under `LOGGER_RTT`, ITM under
+`LOGGER_SWO`, `board_uart_write`/`board_uart_read` by default. The two board-level
+character helpers do not agree:
+
+```c
+168: int board_getchar(void) {
+169: char c;
+170: return (sys_read(0, &c, 1) > 0) ? (int) c : (-1);
+171: }
+172:
+173: int board_putchar(int c) {
+174: if (board_uart_write((const char *)&c, 1) > 0) {
+```
+
+`board_getchar` follows the logger; `board_putchar` always goes to the UART. So with
+`LOGGER=rtt` console input arrives over RTT while the echo goes out the UART.
+
+Measured on ea4088_quickstart (`LOGGER=rtt`, `board_uart_write` is a `-1` stub on
+lpc40): the `board_test` echo vanishes entirely while a `printf` echo — same console,
+same keystroke — comes back byte-for-byte. `LOGGER=swo` has the same asymmetry by
+construction (ITM out of `sys_write`, UART out of `board_putchar`), unverified on
+hardware.
+
+## What remains
+
+Candidate fix: route `board_putchar` through `sys_write(0, ...)` for symmetry with
+`board_getchar`. Two things to settle while doing it:
+
+- `board_putchar` currently passes `&c` of an `int` to a `const char*` — it writes
+ the low byte only on little-endian. Narrow to a `char` local as part of the change.
+- The default (UART) path must keep its current return contract: `board_uart_write`
+ returns negative when the UART is a stub, and the default `sys_write` breaks out of
+ its retry loop on that, returning a short count — so `board_putchar` still has to
+ map "wrote nothing" to `-1`.
+
+## Validation
+
+Build sweep across loggers and families — at minimum one UART board, one
+`LOGGER=rtt` board and one `LOGGER=swo` board — plus a hardware check that the
+`board_test` echo comes back on an RTT board (ea4088_quickstart reproduces the bug
+today) and that a plain UART board's echo is unchanged.
+
+## Why it was split out
+
+PR #3853 promotes a debug-tooling skill and touches `test/hil/*.py` and
+`tools/rtt.py`. A `hw/bsp/board.c` change lands in every example on every board and
+belongs in a review that carries the build evidence for it.
diff --git a/docs/superpowers/followup/pr3853-rtt-harness-adoption.md b/docs/superpowers/followup/pr3853-rtt-harness-adoption.md
new file mode 100644
index 000000000..8f3eae16b
--- /dev/null
+++ b/docs/superpowers/followup/pr3853-rtt-harness-adoption.md
@@ -0,0 +1,62 @@
+# Follow-up: finish RTT-console adoption in the HIL harness
+
+Split out of the `rtt` skill-promotion PR #3853. That PR deliberately ships the skill + CLI and leaves the harness's remaining
+VCOM assumptions in place — converting them is separate test-infra scope that
+deserves its own review and HIL runs. Scope here is `test/hil/*.py` only; the
+src-level `board_putchar` asymmetry this work surfaced has its own handoff
+(`pr3853-board-putchar-logger.md`).
+
+## Established (with evidence)
+
+- `hil_util.JlinkRtt` + `open_board_console()` work end-to-end:
+ ea4088_quickstart runs its host suite over RTT (16 passed / 0 failed / 3
+ skipped, the 'hil: read the host console over RTT when the probe has no VCOM' commit), and the `rtt` skill's boards.md carries the
+ validated matrix.
+- `test_host_device_info` honors `"logger": "rtt"` (hil_test.py, `test_host_device_info`; the eof fail-fast assert sits in its read loop):
+ in RTT mode it resets via the flasher BEFORE opening the console (which
+ then owns the probe; Commander delivers the buffered boot burst) and its
+ read loop fails fast on `JlinkRtt.eof` instead of blaming the board.
+
+## Remaining gaps
+
+1. **`test_host_cdc_msc_hid` and `test_host_msc_file_explorer` (hil_test.py) still call `hil_util.get_serial_dev(flasher["uid"], ...)`
+ directly** — on a `logger: rtt` board with `is_cdc`/`is_msc` fixtures they
+ would fail with the same "No serial device found" the console work fixed
+ for device_info (an interim load-time gate in `hil_test.py` now rejects
+ that combination up front; delete the gate when this lands). Fix: route
+ both through `open_board_console(board)` — but design the conversion
+ reset-aware rather than hand-copying device_info's dual branch: hoist a
+ `reset=` parameter into `open_board_console` that does the per-console
+ ordering itself (RTT: reset via flasher BEFORE opening — the console owns
+ the probe; VCOM: reset after open to catch the banner), and REMOVE the
+ existing post-open `# reset device to catch mount messages` blocks in both
+ tests (grep the marker — line numbers churn) — kept as-is on an RTT board they reset
+ while the console holds the probe. `JlinkRtt` carries input for their
+ menus and implements the `reset_input_buffer()` those tests call.
+2. **`hil_pool_check.check_host_serial` carries its own inline RTT branch**
+ (reset → `JlinkRtt` → poll through `hil_util.strip_banner`) — RTT boards
+ ARE health-checkable today, but the console-opening logic now lives in
+ two places (`open_board_console` in hil_test.py and this branch), each
+ with its own reset-ordering. Fix: hoist `open_board_console()` into
+ `hil_util.py` with the `reset=` parameter from item 1 and collapse
+ pool_check's branch onto it; keep the `do_reset` flush semantics for the
+ VCOM path intact.
+3. **OpenOCD console backend in the harness**: the skill's CLI
+ (`tools/rtt.py --backend openocd`, class
+ `OpenocdRtt` in the same module) is built, deduplicated behind a shared
+ base class next to `JlinkRtt` in `tools/rtt.py`, re-exported by
+ `hil_util`, and hardware-validated (all 20 rig boards through the CLI on
+ both backends, incl. the 8 native-probe ones). What remains is only the
+ `open_board_console` plumbing: choosing `OpenocdRtt` for a
+ `"logger": "rtt"` board with an openocd/stlink flasher needs the per-test
+ flashed-ELF path (for the control-block address) and, for stlink
+ flashers, an openocd target-cfg mapping the roster doesn't carry — until
+ then the config-load gate keeps rejecting non-jlink rtt boards.
+
+## Validation for this follow-up
+
+Run the ea4088 local host suite (a board with a `is_cdc`+`is_msc` capable
+device attached to J3, or the rig's frdm_k64f/mimxrt1064 with a temporary
+`logger: rtt` entry) so cdc_msc_hid and msc_file_explorer actually execute
+over RTT; then a `hil_pool_check.py` pass on a no-VCOM board. Delete this doc
+when the follow-up PR lands.
diff --git a/docs/superpowers/plans/2026-08-24-rtt-skill.md b/docs/superpowers/plans/2026-08-24-rtt-skill.md
new file mode 100644
index 000000000..e2a40c448
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-24-rtt-skill.md
@@ -0,0 +1,423 @@
+# `rtt` Skill 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:** Promote SEGGER RTT to a standalone skill `.claude/skills/rtt/` (transport core + console layer) with a versioned CLI, validated first on the local htpc bench, then across the ci.lan rig.
+
+**Architecture:** Knowledge lives in `.claude/skills/rtt/SKILL.md` + `boards.md`; the single code implementation is `test/hil/helper/hil_util.py::RttConsole` (cherry-picked from branch `hil-add-ea4088qs`) exposed via a thin CLI `test/hil/helper/rtt.py`. Existing docs (target-debug, CLAUDE.md, hil) shrink their RTT recipes to pointers.
+
+**Tech Stack:** Python 3 (stdlib only, matching hil_util), JLinkExe, OpenOCD, TinyUSB `LOGGER=rtt` builds, TDD-for-skills (superpowers:writing-skills).
+
+> **Historical record — EXECUTED 2026-08-24/25.** The shipped shape evolved past
+> this plan during review rounds: the implementation is `tools/rtt.py` (classes
+> `JlinkRtt`/`OpenocdRtt`, `--backend` required), not `test/hil/helper/`. The
+> spec's "Tooling home" section is the current truth; do not re-execute this plan.
+
+**Spec:** `docs/superpowers/specs/2026-08-24-rtt-skill-design.md` — read it first; every content decision below argues from it.
+
+## Global Constraints
+
+- Branch: `rttconsole-skill`, worktree `/home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill`. Never touch the primary checkout's branch.
+- Commit messages: imperative mood, **no `Co-Authored-By:`/`Claude-Session:` trailers, no footers of any kind** (user's standing authorship rule — overrides harness defaults).
+- **Never push.** Commit locally; final report says "ready to push".
+- Curated-skills rule: smallest possible diffs to existing skills/agents/CLAUDE.md; anything beyond the pointer edits listed here must be proposed to the user first.
+- Iron Law (superpowers:writing-skills): no SKILL.md content and no edit to an existing skill without a failing/baseline test first.
+- Hardware rules: **never point OpenOCD at a J-Link-firmware probe** (LPC-Link2 611000000, the J-Trace (nickname `jtrace`; its serial is private — read it with ShowEmuList on the bench) — it drops them off USB; each attempt costs the user a physical replug). J-Trace is wired to raspberry_pi_pico2 (never set a custom JLinkScript for RP2350). Prefix any step needing the user's hands with **[ACTION]**.
+- ci.lan rig work: hold per-board locks per `.claude/skills/hil/SKILL.md` §Board locks; the actions-runner keeps running. Use the hil-operator agent for rig sweeps (strictly one instance).
+- Scratch files go in the session scratchpad, never `/tmp`, never committed.
+- `pre-commit run --all-files` must pass before declaring done.
+
+---
+
+### Task 1: Bring the tooling onto this branch
+
+**Files:**
+- Modify: `test/hil/helper/hil_util.py` (via cherry-pick + docstring fix)
+- Modify: `test/hil/hil_test.py` (via cherry-pick)
+
+**Interfaces:**
+- Produces: `hil_util.RttConsole(board: dict, timeout: float = 0.1)` where `board = {'flasher': {'uid': '<probe-serial>', 'args': '-device <JLINK_DEVICE>'}}`; methods `read(size)->bytes`, `write(bytes)->int`, `in_waiting->int`, `close()`, attr `timeout`. Also `hil_test.open_board_console(board)`.
+
+- [ ] **Step 1: Symlink missing deps** (worktree has `lib/SEGGER_RTT` but not the MCU SDKs):
+
+```bash
+cd /home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill
+python3 - <<'EOF'
+import os, sys
+sys.path.insert(0, 'tools'); import get_deps
+main = os.path.expanduser('~/code/tinyusb')
+for dep in get_deps.deps_all:
+ src, dst = os.path.join(main, dep), dep
+ if not os.path.exists(dst) and os.path.isdir(src):
+ os.makedirs(os.path.dirname(dst), exist_ok=True); os.symlink(src, dst); print('link', dep)
+EOF
+```
+
+- [ ] **Step 2: Cherry-pick the console commit** (object store is shared across worktrees):
+
+```bash
+git cherry-pick d98e77bac
+```
+
+Expected: clean pick of `hil: read the host console over RTT when the probe has no VCOM` (touches only hil_util.py + hil_test.py). If it conflicts, resolve keeping d98e77bac's hunks verbatim — master has not touched these regions.
+
+- [ ] **Step 3: Fix the stale docstring.** `RttConsole`'s docstring opens with "JLinkGDBServer owns the probe and serves RTT channel 0 over TCP" but the code launches `JLinkExe` (J-Link Commander). Edit the docstring's first paragraph to:
+
+```
+ J-Link Commander (JLinkExe) owns the probe and serves RTT channel 0 on -RTTTelnetPort --
+ what JLinkRTTClient talks to, minus its banner. Exposes the slice of pyserial the tests
+ use (read, in_waiting, write, close, timeout) so a caller does not care which console it got.
+```
+
+- [ ] **Step 4: Import smoke test:**
+
+```bash
+python3 -c "import sys; sys.path.insert(0,'test/hil/helper'); import hil_util; print(hil_util.RttConsole.__doc__.splitlines()[1].strip()[:20])"
+```
+
+Expected: `J-Link Commander (JL`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/helper/hil_util.py
+git commit -m "hil: RttConsole docstring names the tool it actually runs (JLinkExe)"
+```
+
+---
+
+### Task 2: RED — baseline scenarios without the skill
+
+Per superpowers:writing-skills, run the failing test before writing any skill text. These are **plan-only** subagents (they must output the exact commands they would run and MUST NOT execute anything against hardware — a wrong baseline attempt costs a probe replug). The lpc4088 session's real lost hour is the primary RED datapoint; these probes map the gap precisely.
+
+**Files:**
+- Create: `<scratchpad>/rtt-baselines.md` (verbatim findings; not committed)
+
+- [ ] **Step 1: Scenario S1 (console/harness routing + technique).** Dispatch a general-purpose subagent, no mention of RTT:
+
+> In the TinyUSB repo at /home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill: board ea4088_quickstart is flashed via an LPC-Link2 running J-Link firmware (serial 611000000). The probe exposes no VCOM and hw/bsp/lpc40/family.c's board_uart_read/write return -1. PLAN ONLY — do not run any hardware command. First list which repo skill(s) (.claude/skills/) you would load for this task and why. Then produce the exact commands to (a) get the firmware's printf/TU_LOG output on this PC headlessly and (b) send keystrokes to the firmware. State every failure mode you anticipate.
+
+- [ ] **Step 2: Scenario S2 (capture technique, OpenOCD/ST-Link).** Same rules:
+
+> PLAN ONLY. TinyUSB repo, board stm32h743nucleo flashed over an ST-Link. The firmware was built with LOG=2 LOGGER=rtt. Produce the exact commands to capture 20 seconds of its RTT log headlessly on Linux, and explain how you locate the RTT control block and what can go wrong right after a reset.
+
+- [ ] **Step 3: Record baseline verbatim** in `<scratchpad>/rtt-baselines.md`: which skills each agent said it would load (expected gap: nothing routes, or target-debug loaded for a non-debugging task), which tool each picked (expected: JLinkRTTLogger or bare JLinkGDBServer for S1; full-RAM `rtt setup` scan for S2), which known gotchas each missed (control-block-after-first-printf, probe-by-serial, exact CB address via nm, attach-only after flash-reset, drain-limited/lossy, probe ownership). Every missed item becomes required SKILL.md content; every wrong routing becomes description-keyword input.
+
+- [ ] **Step 4: Gate.** If a baseline agent nails everything (no gaps), STOP and tell the user — the skill may not be needed in that area and the plan's GREEN content shrinks. (Do not expect this; the lpc4088 session is an existence proof of the failure.)
+
+---
+
+### Task 3: `rtt.py` CLI (TDD)
+
+**Files:**
+- Create: `test/hil/helper/rtt.py`
+- Test: fake-probe harness in `<scratchpad>/fakejlink/` (not committed)
+
+**Interfaces:**
+- Consumes: `hil_util.RttConsole` from Task 1.
+- Produces: CLI `python3 test/hil/helper/rtt.py --probe <serial> --device <JLINK_DEVICE> [--seconds N] [-i]` — streams channel-0 bytes to stdout; `--seconds 0` (default) runs until Ctrl-C/EOF; `-i` forwards stdin to the target. Exit 0 on clean close, 1 on connect failure.
+
+- [ ] **Step 1: Write the fake probe** `<scratchpad>/fakejlink/JLinkExe` (`chmod +x`):
+
+```python
+#!/usr/bin/env python3
+# Stands in for J-Link Commander: serves -RTTTelnetPort, greets, echoes input back
+# uppercased, exits when stdin says exit (mirrors RttConsole's close() contract).
+import socket, sys, threading
+port = int(sys.argv[sys.argv.index('-RTTTelnetPort') + 1])
+srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+srv.bind(('127.0.0.1', port)); srv.listen(1)
+def serve():
+ conn, _ = srv.accept()
+ conn.sendall(b'hello from target\r\n')
+ while True:
+ d = conn.recv(4096)
+ if not d: return
+ conn.sendall(d.upper())
+threading.Thread(target=serve, daemon=True).start()
+for line in sys.stdin:
+ if line.strip() == 'exit': break
+```
+
+- [ ] **Step 2: Run the failing test:**
+
+```bash
+cd /home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill
+PATH=<scratchpad>/fakejlink:$PATH timeout 15 python3 test/hil/helper/rtt.py --probe 000 --device FAKE --seconds 2
+```
+
+Expected: FAIL — `No such file or directory` (rtt.py does not exist).
+
+- [ ] **Step 3: Implement** `test/hil/helper/rtt.py`:
+
+```python
+#!/usr/bin/env python3
+"""Stream a board's RTT channel-0 console to stdout over a J-Link probe.
+
+Thin CLI over hil_util.RttConsole -- the same implementation the HIL harness uses.
+The probe is owned for the whole run: flash and reset BEFORE starting this, never
+reset the target while it is attached. Select the probe by serial; rigs run several.
+"""
+import argparse
+import sys
+import threading
+import time
+
+import hil_util # same directory when run by path
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument('--probe', required=True, help='J-Link probe serial (JLinkExe -USB value)')
+ ap.add_argument('--device', required=True, help='JLINK_DEVICE string from the board.cmake/family.cmake')
+ ap.add_argument('--seconds', type=float, default=0, help='capture duration; 0 = until Ctrl-C/EOF')
+ ap.add_argument('-i', '--interactive', action='store_true', help='forward stdin to the target')
+ args = ap.parse_args()
+
+ board = {'flasher': {'uid': args.probe, 'args': f'-device {args.device}'}}
+ try:
+ con = hil_util.RttConsole(board, timeout=0.1)
+ except RuntimeError as e:
+ print(e, file=sys.stderr)
+ return 1
+
+ if args.interactive:
+ def pump_stdin():
+ for line in sys.stdin:
+ con.write(line.encode())
+ threading.Thread(target=pump_stdin, daemon=True).start()
+
+ deadline = time.monotonic() + args.seconds if args.seconds else None
+ try:
+ while deadline is None or time.monotonic() < deadline:
+ chunk = con.read(con.in_waiting or 1)
+ if chunk:
+ sys.stdout.buffer.write(chunk)
+ sys.stdout.buffer.flush()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ con.close()
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
+```
+
+- [ ] **Step 4: Run the tests, verify they pass:**
+
+```bash
+P=<scratchpad>/fakejlink
+PATH=$P:$PATH timeout 15 python3 test/hil/helper/rtt.py --probe 000 --device FAKE --seconds 2 # expect: hello from target
+echo hi | PATH=$P:$PATH timeout 15 python3 test/hil/helper/rtt.py --probe 000 --device FAKE --seconds 2 -i # expect: hello from target + HI
+pgrep -f '[J]LinkExe -USB 000' && echo LEAK || echo CLEAN # expect: CLEAN (bracket: else pgrep matches its own shell)
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/hil/helper/rtt.py
+git commit -m "hil: add rtt.py, a CLI over RttConsole"
+```
+
+---
+
+### Task 4: GREEN — write `.claude/skills/rtt/SKILL.md` + `boards.md` skeleton
+
+Write the skill addressing Task 2's recorded failures — nothing more (minimal GREEN). All facts below are established in the spec; the drafting job is assembling them into the sibling-skill shape (structure model: `sysview` SKILL.md; ~150–200 lines).
+
+**Files:**
+- Create: `.claude/skills/rtt/SKILL.md`
+- Create: `.claude/skills/rtt/boards.md`
+
+- [ ] **Step 1: Frontmatter.** Name `rtt`. Description (trigger-only, third person, no workflow — superpowers:writing-skills SDO; extend with keywords from Task 2's routing misses):
+
+```yaml
+---
+name: rtt
+description: Use when you need console or printf I/O, TU_LOG capture, or a raw byte channel over a debug probe on real hardware — the board has no UART wired or its probe no VCOM, a LOGGER=rtt build needs reading or writing, "RTT Control Block not found", an RTT server won't come up or drops output, JLinkRTTLogger/JLinkRTTClient/JLinkGDBServer/openocd rtt misbehave, or another workflow (HIL console, SystemView capture) needs RTT stood up on a J-Link, ST-Link, CMSIS-DAP or WCH-Link probe.
+---
+```
+
+- [ ] **Step 2: Body sections**, each carrying exactly this content (wording final at execution, facts verbatim from the spec):
+ 1. **Overview** — RTT is nothing but RAM (control block `_SEGGER_RTT`, magic "SEGGER RTT", up/down rings `{sName,pBuffer,SizeOfBuffer,WrOff,RdOff,Flags}`); host must write RdOff back to drain; channel 0 = console, SystemView's "SysView" buffer coexists.
+ 2. **When to use / when not** — console & capture here; timing/profiling → etm-trace/sysview; debugging decision flows → target-debug; Espressif console → esp-target-debug.
+ 3. **Transport matrix (quick reference table)** — spec §v1 backend matrix verbatim, per-TRANSPORT rows: ARM memory-AP (live, zero intrusion) / RISC-V SBA (live where implemented) / WCH SDI (**dump only, never live** — DM reads kill USB ~1.9 s in) / OpenOCD-on-J-Link-fw-probe (forbidden, USB drop + physical replug).
+ 4. **Console (bidirectional)** — `LOGGER=rtt` builds route TU_LOG + `sys_read` to channel 0 (`hw/bsp/board.c`); tooling `test/hil/helper/rtt.py` (CLI) / `hil_util.RttConsole` (harness, `"logger": "rtt"` board switch); flash+reset BEFORE opening, console owns the probe.
+ 5. **Capture: J-Link route** — `JLinkExe -USB <sn> -device <dev> -if swd -speed 4000 -NoGui 1 -AutoConnect 1 -RTTTelnetPort <port>` + socket/`nc`; proven standalone. `JLinkGDBServer -RTTTelnetPort` locates the block on some parts only with a GDB client attached (LPC4088 measured) — per-part variance, use JLinkExe when headless. `JLinkRTTLogger`: never (single search at attach, 0/6 measured).
+ 6. **Capture: OpenOCD route (native probes)** — exact CB address first (`arm-none-eabi-nm <elf> | grep _SEGGER_RTT`), then `-c 'rtt setup <addr> 0x1000 "SEGGER RTT"' -c 'rtt polling_interval 1' -c 'rtt start' -c 'rtt server start <port> 0'`; attach without reset when the flash step already reset (SAMD5x DSU `reset run` leaves the core held); read path validated on 13 boards (sysview campaign), write path per boards.md.
+ 7. **Post-mortem** — undrained NO_BLOCK_SKIP ring holds the FIRST KB after boot, not the wedge tail; overwrite mode (`SEGGER_RTT_WriteWithOverwriteNoLock`) keeps the last N bytes with no live host; manual ring read: `nm` the ELF for `_SEGGER_RTT`, `mem32` the aUp[0] descriptor, `savebin` the buffer — debug-AP reads don't halt the target (moved here from target-debug).
+ 8. **Buffer modes & locking** — SKIP/TRIM/BLOCK (BLOCK spins the target — dangerous in ISRs); non-ARM ports must supply `SEGGER_RTT_LOCK/UNLOCK` (worked example: `hw/bsp/ch583/sysview_rtt_lock_wch.h` on branch `claude/add-systemview-debug` — generic RISC-V lock traps mcause=2 on QingKe).
+ 9. **Common mistakes** — attach before first printf (block doesn't exist yet); reset while attached; probe not pinned by serial; two probes on one SWD header; treating RTT as lossless (24.6 KiB/s drain measured, drops at the target); full-RAM scan matching stale RAM after soft reset.
+ 10. **Per-board notes** → pointer to `boards.md`.
+
+- [ ] **Step 3: `boards.md` skeleton** — header modeled on sysview's boards.md (row = board, probe/transport, backend+direction validated, JLINK_DEVICE/openocd cfg, caveats), plus the two measured rows seeded from the spec: `ea4088_quickstart` (J-Link/LPC-Link2 611000000, read+write-accepted, `LPC4088`, "probe has no VCOM; BSP has no UART; never OpenOCD on this probe") and a placeholder-free note that all further rows land during Tasks 7–8 validation (no unvalidated rows allowed).
+
+- [ ] **Step 4: Length check:** `wc -l .claude/skills/rtt/SKILL.md` — expect ≤ ~200 (siblings: hil 168, etm-trace 203).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add .claude/skills/rtt/
+git commit -m "skills: add rtt - RTT transport and console reference"
+```
+
+---
+
+### Task 5: GREEN verification + REFACTOR
+
+- [ ] **Step 1: Re-run S1 and S2** (Task 2 prompts verbatim, still plan-only) with fresh subagents. Success criteria: S1 routes to the `rtt` skill, picks `rtt.py`/JLinkExe route, names probe-by-serial + flash-before-attach; S2 uses exact CB address via `nm`, attach-only, and the openocd command block.
+- [ ] **Step 2: REFACTOR.** Any missed item or new wrong turn → tighten the specific SKILL.md section (form per writing-skills "Match the Form to the Failure": these are technique/reference failures → recipes and required table slots, not prohibitions) → re-run that scenario until it passes.
+- [ ] **Step 3: Commit** (`git add .claude/skills/rtt/SKILL.md && git commit -m "skills: rtt - close gaps found in scenario verification"`) — only if Step 2 changed anything.
+
+---
+
+### Task 6: Pointer edits in existing docs
+
+Iron Law for skill edits: the failing test is S3 below, run BEFORE editing.
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md:224-253`
+- Modify: `CLAUDE.md:77`
+- Modify: `.claude/skills/hil/SKILL.md` (one added line)
+
+- [ ] **Step 1: S3 baseline (failing test).** Plan-only subagent:
+
+> PLAN ONLY. In this TinyUSB repo, a HIL host test on a board whose flasher probe has no VCOM fails with "No serial device found for /dev/serial/by-id/usb-*_<uid>-if*". Which repo skill(s) would you load, and what is the fix path?
+
+Expected FAIL today: the agent loads `hil` (correct routing) but `hil` says nothing about RTT consoles, so the fix path is rediscovery. Record verbatim.
+
+- [ ] **Step 2: Edit `hil/SKILL.md`** — add one line under its Prerequisites section (placement judgment at execution; content fixed):
+
+```
+- A board whose probe has no VCOM (or whose BSP has no UART) uses RTT as its console: `"logger": "rtt"` + `"build": {"args": ["LOGGER=rtt"]}` in its config entry — see the rtt skill.
+```
+
+- [ ] **Step 3: Edit `target-debug/SKILL.md`.** (a) Replace the two RTT lines of the capture block at 224-226 with:
+
+```bash
+# RTT (probe console; details, servers, gotchas: rtt skill):
+timeout 20s python3 test/hil/helper/rtt.py --probe <sn> --device <JLINK_DEVICE> > /tmp/rtt.log
+```
+
+(b) Replace the OpenOCD RTT block (232-237) with the single line: `` OpenOCD RTT (native probes): rtt skill §OpenOCD — exact CB address from `nm`, attach-only. `` Keep the drain-preference sentence that follows. (c) Keep the drain-model paragraph (242-247) unchanged; replace 248-253 (GDBServer/RTTLogger/manual-ring-read) with:
+
+```
+Stand up the drain per the **rtt** skill: JLinkExe's `-RTTTelnetPort` is the
+headless-proven route; GDBServer's needs a GDB client on some parts, and
+JLinkRTTLogger never works. The manual ring read for a wedged target
+(`nm`/`mem32`/`savebin`) lives there too.
+```
+
+(d) Line 334's correlation one-liner: swap `JLinkRTTClient` for the `rtt.py` invocation from (a). Keep the capture-channel table rows 64-65 unchanged.
+
+- [ ] **Step 4: Edit `CLAUDE.md:77`** to:
+
+```
+**RTT:** build `LOG=2 LOGGER=rtt`; capture/console via the `rtt` skill (`.claude/skills/rtt/SKILL.md`).
+```
+
+- [ ] **Step 5: GREEN for the edits.** Re-run S3 (expect: hil → rtt route, `logger: rtt` fix path) AND re-run S1 once more (expect: unchanged pass — the removed target-debug text must be reachable through the pointers). Also grep for dangling references: `grep -rn "JLinkRTTClient\|RTTTelnetPort" CLAUDE.md .claude/ | grep -v skills/rtt` — every remaining hit must be a deliberate pointer or the sysview branch's own copy.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add .claude/skills/target-debug/SKILL.md .claude/skills/hil/SKILL.md CLAUDE.md
+git commit -m "docs: route RTT recipes through the rtt skill"
+```
+
+---
+
+### Task 7: Dogfood on the local htpc bench
+
+Follow ONLY the SKILL.md text (dogfood discipline: gaps found here are REFACTOR input, fixed in SKILL.md before moving on). **[ACTION]-gate with the user before first hardware touch**: confirm LPC-Link2 (611000000) is back on USB and J-Trace (`jtrace`) is on pico2 with pico2 powered.
+
+**Files:**
+- Modify: `.claude/skills/rtt/boards.md` (validated rows)
+- Modify: `.claude/skills/rtt/SKILL.md` (only if dogfood exposes gaps)
+- Create: `test/hil/local.json` (untracked — copy from the lpc4088 worktree)
+
+- [ ] **Step 1: Probe roster check:** `JLinkExe -CommandFile <(echo -e 'ShowEmuList\nexit')` (or `lsusb`) — expect 611000000 and the jtrace probe. Missing probe → **[ACTION]** ask the user, do not improvise.
+
+- [ ] **Step 2: ea4088 bidirectional echo (board_test).** Build + flash + echo, exactly as SKILL.md describes it:
+
+```bash
+cd examples/device/board_test && mkdir -p build-ea4088 && cd build-ea4088
+cmake -DBOARD=ea4088_quickstart -DLOG=2 -DLOGGER=rtt -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel .. && cmake --build .
+ninja board_test-jlink # flashes via the LPC-Link2; resets the target
+cd ../../../..
+(sleep 1; echo ping) | timeout 15 python3 test/hil/helper/rtt.py --probe 611000000 --device LPC4088 --seconds 8 -i | tee <scratchpad>/ea4088-echo.log
+```
+
+Expected: board_test's periodic print lines AND the echoed `ping` (board_test echoes `board_getchar()`). This is the first true validation of target-side console INPUT consumption (the 8550-byte measurement only proved the socket accepted the bytes).
+
+- [ ] **Step 3: ea4088 HIL host suite over RTT.** Copy the untracked config: `cp /home/hathach/.herdr/worktrees/tinyusb/hil-add-ea4088qs/test/hil/local.json test/hil/local.json`. Build the full example set (`cd examples && cmake -B cmake-build-ea4088_quickstart -DBOARD=ea4088_quickstart -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-ea4088_quickstart` — LOGGER=rtt comes from local.json's `build.args`; verify the harness applies it, else add `-DLOGGER=rtt -DLOG=2`). Run per `.claude/skills/hil/SKILL.md` §Local execution against `local.json`. Expected: ≥ 16 passed / 0 failed (parity with d98e77bac's measured result).
+
+- [ ] **Step 4: pico2 second-probe/second-architecture capture.** Two J-Links are attached — the flash target MUST pin the probe:
+
+```bash
+cd examples/device/cdc_msc && mkdir -p build-pico2 && cd build-pico2
+cmake -DBOARD=raspberry_pi_pico2 -DLOG=2 -DLOGGER=rtt -DJLINK_OPTION="-USB <jtrace-serial>" -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel .. && cmake --build .
+ninja cdc_msc-jlink
+cd ../../../..
+timeout 15 python3 test/hil/helper/rtt.py --probe <jtrace-serial> --device rp2350_m33_0 --seconds 8 | tee <scratchpad>/pico2-rtt.log
+```
+
+(Verify `-DJLINK_OPTION` is the pin mechanism in `hw/bsp/rp2040/family.cmake` before flashing; if the variable differs, use the family's actual one — do NOT flash with an unpinned `-jlink` target.) Expected: TinyUSB init/TU_LOG lines. Silence → check SKILL.md's own troubleshooting first (block-after-first-printf, wrong device string); if it doesn't resolve the silence, that's a dogfood gap → REFACTOR.
+
+- [ ] **Step 5: Record boards.md rows** for ea4088_quickstart (upgrade: write path VALIDATED via echo) and raspberry_pi_pico2 (J-Trace, `rp2350_m33_0`, "pin probe by serial — bench runs two J-Links; never a custom JLinkScript"). Apply any SKILL.md refactors the dogfood forced.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add .claude/skills/rtt/
+git commit -m "skills: rtt - htpc dogfood rows (ea4088 bidirectional, pico2 capture)"
+```
+
+---
+
+### Task 8: ci.lan rig sweep — all applicable boards
+
+Goal: a boards.md row per rig board, per its transport. Drive hardware through the hil-operator agent (one instance), locks per hil skill. Builds: `LOGGER=rtt LOG=2` `board_test` per board (echo validates both directions where the backend supports writes). Firmware left on boards is fine — CI reflashes every run.
+
+**Files:**
+- Modify: `.claude/skills/rtt/boards.md`
+- Create: `<scratchpad>/rtt_sweep/` (per-board logs; not committed)
+
+- [ ] **Step 1: Build matrix.** From `test/hil/tinyusb.json` take all boards; groups: jlink×12, openocd×9, stlink×3; excluded with reasons recorded in boards.md: esptool×2 (no SEGGER-RTT path in our builds — USB-Serial-JTAG console), ek_tm4c123gxl (lm4flash only, no probe path configured on the rig). For each included board build `examples/device/board_test` with `-DLOG=2 -DLOGGER=rtt` locally where the toolchain exists (arm-none-eabi covers all but WCH); WCH boards (nanoch32v203, ch32v103, ch32v307, ch582m): build only if the riscv toolchain is present locally or on ci.lan — otherwise record `skipped: no riscv toolchain` rather than silently dropping (no silent caps).
+
+- [ ] **Step 2: Stage on ci.lan:** `scp` each ELF/bin + `test/hil/helper/{hil_util.py,rtt.py}` to `[email protected]:~/rtt-sweep/`.
+
+- [ ] **Step 3: Per-board procedure** (hil-operator executes on ci.lan; lock → flash → capture → echo → release):
+ - **jlink boards:** flash with the board's rig flasher recipe (uid + `-device` from tinyusb.json `flasher.args`), then `(sleep 1; echo ping) | timeout 15 python3 ~/rtt-sweep/rtt.py --probe <uid> --device <dev> --seconds 8 -i`. PASS = periodic board_test output + `ping` echoed.
+ - **stlink + openocd boards (native probes):** CB address from the local ELF (`arm-none-eabi-nm board_test.elf | grep _SEGGER_RTT`, computed before scp, carried in the sweep table). Then on ci.lan, one session per board using the board's existing openocd args from tinyusb.json plus: `-c 'adapter serial <uid>' -c 'rtt setup <addr> 0x1000 "SEGGER RTT"' -c 'rtt polling_interval 1' -c 'rtt start' -c 'rtt server start <port> 0'`; attach WITHOUT reset (flash already reset it). Read: `timeout 8 nc localhost <port>`. Write test: `(sleep 1; echo ping; sleep 3) | nc localhost <port>` — PASS/FAIL per direction recorded separately; a write failure here is a finding, not a blocker (spec: OpenOCD write path is the open question this phase answers).
+ - **WCH boards (WCH-Link, SDI):** NO live streaming, NO rtt server during USB traffic. Validation = post-mortem-style read only: flash, let it run 5 s, then `halt; read the ring via nm address + mdw/dump_image; resume` in one short openocd/wlink session. PASS = ring contains board_test's boot output. Any anomaly → stop, quiesce the DM (rig standing rule), record.
+- [ ] **Step 4: Per-board rows into boards.md** — board, transport, read/write verdicts, device string / cfg, caveat. Every board in tinyusb.json appears: validated, failed (with symptom), or skipped (with reason). If OpenOCD write path validated, update SKILL.md's transport matrix row; if not, matrix row says "read-only validated; write untested/failed on <boards>".
+- [ ] **Step 5: Restore rig state:** release all locks; run a normal single-board HIL smoke (`stm32f407disco`) per hil skill to confirm the rig is healthy for CI.
+- [ ] **Step 6: Commit**
+
+```bash
+git add .claude/skills/rtt/
+git commit -m "skills: rtt - ci.lan rig validation matrix"
+```
+
+---
+
+### Task 9: Follow-up doc, final validation, report
+
+**Files:**
+- Create: `docs/superpowers/followup/pr-rtt-pool-check.md` (rename to `pr<NNN>-…` once the PR number exists)
+
+- [ ] **Step 1: Follow-up handoff doc** (superpowers:writing-plans style, per CLAUDE.md "Deferred work"): adopting `RttConsole` in `hil_pool_check.check_host_serial` (`test/hil/helper/hil_pool_check.py:354` — bidirectional, VCOM-assuming; needs `open_board_console` hoisted from `hil_test.py` into `hil_util.py`), citing the ea4088 validation as established ground. Also note the deferred sysview SKILL.md pointer (that branch owns its file; propose to user when it merges).
+- [ ] **Step 2: `pre-commit run --all-files`** — expect pass (~55 s; HIL hooks exercise real timeouts).
+- [ ] **Step 3: Commit follow-up doc:** `git add docs/superpowers/followup/ && git commit -m "docs: follow-up - pool-check adoption of RttConsole"`
+- [ ] **Step 4: Report** to the user: commit list, validation matrix summary (htpc + rig, per-direction verdicts), open findings (e.g. OpenOCD write path), and **ready to push — not pushed**.
+
+---
+
+## Self-Review (completed at planning time)
+
+- Spec coverage: scoring→spec only; scope/sections→Task 4; tooling→Tasks 1,3; measured-evidence carriage→Task 4 step 2; doc edits→Task 6; validation strategy→Tasks 7,8; non-goals→Task 4 §2 + exclusions in Task 8. Deferred sysview pointer→Task 9. No gaps.
+- Placeholder scan: `<scratchpad>` is the session scratchpad path (known at execution); `<port>/<addr>/<uid>` are computed per-board by given commands; Task 4 prose is assembled from enumerated facts (TDD forbids pre-writing final skill text before RED completes). No TBDs.
+- Type consistency: `RttConsole(board, timeout)` board-dict shape identical in Tasks 1, 3; CLI flags identical in Tasks 3, 6, 7, 8; skill name `rtt` throughout.
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
index 3ed0c1519..a34848f06 100644
--- 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
@@ -1,9 +1,9 @@
# 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.
+Status: implemented, then superseded in part, then TRIMMED (2026-08-25 — see the
+addendum at the end). Last checked against the shipped code 2026-08-25; 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
@@ -17,7 +17,9 @@ skill win, never this document.
- **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.
+- **Battery budgets.** `USBTEST_BATTERY_BUDGET` 260s. The recovery reserve is no longer a
+ constant: `usbtest.recovery_reserve(flasher)` derives it per flasher (RP-target openocd 390s,
+ other openocd/jlink/stlink 190s, esptool/lm4flash 150s) — see the trim addendum.
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
@@ -234,3 +236,108 @@ a stuck run and explain it without anyone touching the rig.
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.
+
+---
+
+## Trim addendum — 2026-08-25
+
+The containment above grew past what one maintainer could hold. This records what was
+removed and, more importantly, the rule that decided it, so the next reader does not
+re-derive the deleted layers from the incident above.
+
+### The dividing principle
+
+**The CI job ceiling bounds how long a run can burn. It does nothing about state that
+outlives the run.** Cut what the ceiling contains; keep what it does not.
+
+- Contained by the ceiling: a worker blocked on a wedged device. `drain_pool` keeps the
+ boards that finished, `_write_failed_spec` names the one in flight, `_abandon_exit`
+ writes and uploads the report, and the job dies at `timeout-minutes` regardless. The
+ cost is one pool slot.
+- **Not** contained: a D-state holder left on a usbfs node, or an unswept stray still
+ holding a probe. The job dies and those survive it, on a self-hosted runner, into the
+ next run. That is the original incident.
+
+### Removed
+
+- **The sysfs blindness subsystem.** `SYSFS_UNKNOWN`, the `_SysfsUnknown` sentinel, the
+ path→inode strand memo with its `_STRAND_MISS` miss-sentinel, the four-credit blindness
+ cap, `sysfs_blind()`/`sysfs_blind_note()`, `note_sysfs_strand()`, `bounded_open()`,
+ `usb_scan`'s `(list, bool)` return, usbtest's `inconclusive` abort, and `_blind_note`'s
+ report banner. `read_sysfs` is an ordinary `open().read()` returning `str | None`.
+
+ It was a three-valued contract five files had to reason about, and misreading unknown as
+ absence was silent — a healthy board reported as a firmware regression. It existed for
+ exactly one attribute that can block. Verified against v6.12.96 `sysfs.c`: only
+ `usb_string_attr` (`product`/`manufacturer`/`serial`, sysfs.c:141-143) takes
+ `usb_lock_device_interruptible`; `idVendor`, `idProduct`, `bcdDevice`, `busnum`,
+ `devnum` and `speed` are lock-free `sysfs_emit` from cached fields. Two of the five
+ `read_sysfs` call sites read attributes that cannot block at all.
+
+ **The bound stayed, and it is not opt-in.** An early cut of this trim made `read_sysfs`
+ unbounded on the theory that a blocked worker costs one pool slot. That is false:
+ `usb_scan` reads `serial` on every device matching the VID to find the one it wants, and
+ `hil_lock.controller_of` does exactly that from `controller_permit`, on essentially every
+ board — so one wedged DUT would stall *every* worker and the pool guard would take the
+ whole run. `read_sysfs` and `usb_scan` are bounded by `SYSFS_READ_GRACE` by default;
+ three call sites forgot an opt-in version within a single sitting, and a unit test now
+ pins the default.
+
+ What is gone is the *contract*, not the bound: no third value, no process-wide blindness
+ latch, no `(list, bool)` return, no report banner. A give-up reads as None like any
+ unreadable attribute, and the cost is confined to the device that is actually wedged.
+
+ **`hil_pool_check` is why the memo has to be exact.** It is a standalone
+ ThreadPoolExecutor tool with no guard behind it, run precisely when a device is suspected
+ wedged, and it polls (`wait_device` re-scans every 0.5 s). The bounded read gives up and
+ remembers
+ the path so a poll loop cannot leak a thread and an fd per pass. That memo is keyed by
+ **kernfs inode, not by path**: a busport does not change when a board returns to the same
+ physical port, so a path-only blacklist would outlive the wedge and make the tool's own
+ recovery flow (reset/reflash → `wait_device` polls for the new inode) never see the board
+ again. A changed inode is the all-clear; `os.stat` is safe on a wedged device because it
+ does not invoke `->show()`. A give-up reads as None
+ — the same as unreadable — and `sysfs_stranded()` lets the footer warn that a "missing"
+ row may be the tool losing sight of healthy hardware. One local bound with a warning
+ line, not the five-file three-valued contract that was removed.
+
+- **The recovery budget arithmetic.** `recovery_steps()`, `_time_left()` and its three
+ per-step gates. The reserve was an independent 250s — one number for the whole fleet —
+ that could not contain the ladder it
+ reserved for (reset 30 + reflash 90 + Rescue-DP POR 90 + retry 90 + settles), which is
+ why the child re-decided before every step — with a bare `- 35` for downstream costs
+ that nobody could re-derive. Between them they produced a recovery that skipped its own
+ steps for most real hangs. The reserve now counts `hil_util.REAP_GRACE` **per bounded
+ step** — `run_cmd` spends that reaping a child it had to SIGKILL, on top of the step's own
+ timeout — which is what the `- 35` was standing in for. Undersizing it is worse than not
+ recovering at all: the outer killpg lands mid-reflash and orphans the flasher on the
+ probe. A unit test asserts the reserve covers the ladder. `USBTEST_RECOVERY_BUDGET` is now derived from
+ `usbtest.RECOVER_*` **per flasher and per target**: the Rescue-DP legs are openocd-only
+ (`rescue_openocd` refuses anything else) and a stub reset is screened out, so an esptool
+ board no longer reserves 200s it can never spend. The child runs the ladder straight
+ through, and `--outer-timeout` — parsed but unused once the gates went — is deleted.
+
+### Deliberately kept
+
+- The pool guard, `drain_pool`, the re-run spec, `_abandon_exit`, the CI ceilings.
+- `hil_health`'s sweep **including** `_kill_and_confirm`. SIGKILL is queued, not delivered,
+ for a task in uninterruptible sleep, and a healthy in-flight testusb sits in exactly that
+ state — so `os.kill` returning success proves nothing, and the recheck is the only honest
+ answer to "is the rig dirty for the next job?".
+- usbtest's reset→check→reflash ladder and the `convoy_safe` gate. This is the only thing
+ that unpoisons the rig mid-run, and PR #3832 extends it from 11 to 18 of 27 boards.
+- `mtp_test.py` as a separate process — one job, a clean boundary, and runnable by hand
+ against a board while debugging.
+
+### Structural changes with no behaviour change
+
+- Blocking device IO now runs in a child process everywhere, not just where it was noticed
+ first. The printer WRITE half joined the read half (`usblp_open` ignores `O_NONBLOCK` and
+ stalls in `usb_autopm_get_interface()` holding the driver-global `usblp_mutex`), and the
+ HID echo followed (`hid.enumerate()` reads `manufacturer`/`product` for every HID device
+ it lists, both under the device lock). `test_device_midi_test` is NOT in that set: ALSA
+ rawmidi honours `O_NONBLOCK` on open (v6.12.96 rawmidi.c:489), unlike usblp.
+- `main()`'s two abort paths were near-identical 40-line blocks; `_abort_report` holds that
+ shape once. The controller-hint cache and pool construction moved to their own helpers.
+- The unit suite stopped sleeping 54 of its 78 seconds — mostly one named-and-zeroable
+ post-flash settle paid by ten tests against a fake rig.
diff --git a/docs/superpowers/specs/2026-08-24-rtt-skill-design.md b/docs/superpowers/specs/2026-08-24-rtt-skill-design.md
new file mode 100644
index 000000000..7a621726f
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-rtt-skill-design.md
@@ -0,0 +1,164 @@
+# `rtt` skill — design & decision record
+
+Date: 2026-08-24. Branch: `rttconsole-skill`. Author sessions: lpc4088 handoff
+(measurements), sysview handoff (mechanics + probe matrix), this session
+(verification + decision). User approved promotion and the name `rtt` on
+2026-08-24.
+
+## Decision
+
+Promote SEGGER RTT from an inline technique in `.claude/skills/target-debug/`
+to a standalone skill `.claude/skills/rtt/`, scoped as **transport core +
+console layer**: getting bytes on/off RTT channels over any debug probe, plus
+the bidirectional console tooling the HIL harness ships. Consumer-specific
+layers (SystemView encode/decode/licensing, TU_LOG conventions, debugging
+methodology) stay in their skills and cross-reference.
+
+## Scoring against the promotion criteria
+
+Criteria: `docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md`
+§"Skill vs technique — promotion criteria" (exists only on branch
+`claude/add-systemview-debug`; read via `git show`). Two or more of four
+required. Score: **3/4**.
+
+1. **Ships tooling — yes.** `hil_util.JlinkRtt` (commit d98e77bac: probe
+ selection by serial, dynamic port allocation, non-blocking bidirectional
+ socket, process-group teardown) plus a thin CLI added by this plan.
+ Precedent: `hil` and `code-size` are skills wrapping repo-versioned tools;
+ "recipes over already-installed tools" is what RTT was *before* this code
+ existed (why SWO stayed a technique at 1.5/4 — see `SWO_SKILL_HANDOFF.md`).
+2. **Answers its own routed question — yes.** "Give this board a console /
+ printf I/O with no UART and no VCOM" is asked from harness and bring-up
+ contexts that never load target-debug (whose trigger is *misbehaving
+ firmware*). Measured cost of the missing route: the lpc4088 session burned
+ an hour rediscovering a gotcha already written at target-debug
+ SKILL.md:249-253.
+3. **Carries validation state — yes.** Measured tool matrix (below), 13-board
+ OpenOCD read-path campaign from the sysview cycle, WCH SDI A/B proof,
+ SAMD5x DSU gotcha, lock-porting example, per-probe constraints.
+4. **Long but conditionally relevant — yes.** The transport knowledge is a
+ page+ that most target-debug sessions don't need and harness sessions
+ can't find there.
+
+## Measured evidence the skill must carry
+
+From the lpc4088 session (LPC4088 + LPC-Link2 J-Link fw 611000000, SWD 4 MHz;
+single board — re-verify on more hardware during validation):
+
+- `JLinkExe -RTTTelnetPort <port> -AutoConnect 1`: 6/6 reliable; delivers the
+ buffered boot burst; accepted an 8550-byte write in one call. **The proven
+ standalone path.**
+- Drain rate 24.6 KiB/s (253,127 B / 10.0 s) against a saturating printf
+ firmware that produced 689,896 lines — 0.6 % delivered. RTT console is
+ **drain-limited and lossy under saturation; drops happen at the target**
+ (NO_BLOCK_SKIP, 1 KB default buffer).
+- `JLinkRTTLogger`: 0/6 — "RTT Control Block not found" even given
+ `-RTTAddress`, block plainly readable over SWD. Searches once at attach,
+ never retries. **Never build on it.**
+- `JLinkGDBServer -RTTTelnetPort` with **no GDB client attached**: served the
+ port, never located the control block (this board). target-debug's
+ GDBServer+JLinkRTTClient recipe was proven in flows where GDB attaches, and
+ CLAUDE.md's recipe worked on other parts — treat as per-part variance,
+ document both; do not "correct" either into a flat contradiction.
+- OpenOCD (jaylink) driving this J-Link-firmware probe: transport failure
+ (`LIBUSB_ERROR_TIMEOUT`, `jaylink_swd_io() failed`), probe drops off USB,
+ **physical replug needed** — twice, reproducible. Standing rule: never
+ point OpenOCD at that class of probe (J-Link OB firmware on a debug-probe
+ board like the LPC-Link2). Genuine SEGGER J-Links work under jaylink —
+ routine in the sysview campaigns (metro_m4_express).
+
+From the sysview cycle (branch `claude/add-systemview-debug`, 13-board
+campaign 2026-08-12):
+
+- OpenOCD `rtt setup <exact CB addr> … ; rtt start; rtt server start <port>
+ <ch>` **read path validated** on ST-Link, CMSIS-DAP and J-Link probes
+ (`test/hil/sysview_ci.py`). Exact CB address from
+ `arm-none-eabi-nm <elf> | grep _SEGGER_RTT` beats a full-RAM scan (slower,
+ can mis-hit stale RAM after soft reset).
+- The real transport requirement is **autonomous memory access while the core
+ runs**: ARM memory-AP (zero intrusion), RISC-V SBA where implemented.
+ **WCH QingKe SDI has neither** — Debug Module abstract commands perturb the
+ running core; A/B-proven kill ~1.9 s into USB traffic. Per-transport rule:
+ SDI = halt→read→resume / post-mortem dump only, never live streaming.
+- SAMD5x + OpenOCD: in-session `reset run` via the DSU CPU Reset Extension
+ leaves the core held — attach without reset when the flash step already
+ reset the board (general preference: attach-only capture).
+- Lock porting example: `hw/bsp/ch583/sysview_rtt_lock_wch.h` (QingKe CSR
+ 0x800 brace-scoped save/restore; generic RISC-V lock traps mcause=2).
+- Drain hierarchy: J-Link native > OpenOCD polling; matters only at
+ SystemView bandwidths (workable buffers 2048–8192); console logs never
+ overflow the drain in practice.
+- RTT mechanics for the concepts section: control block `_SEGGER_RTT` (magic
+ "SEGGER RTT") + ring buffers {sName, pBuffer, SizeOfBuffer, WrOff, RdOff,
+ Flags}; the HOST must write RdOff back to drain; modes NO_BLOCK_SKIP (log
+ default) / NO_BLOCK_TRIM / BLOCK_IF_FIFO_FULL (target spins — dangerous in
+ ISRs); post-mortem mode = `SEGGER_RTT_WriteWithOverwriteNoLock` (target
+ drags RdOff, ring holds last N bytes, no live host needed); channel 0 =
+ "Terminal" console, SystemView claims its own "SysView" up-buffer —
+ coexist on one control block.
+
+## Gotchas the skill centralises
+
+Control block exists only after the target's first printf (early reader sees
+nothing; Logger gives up). The console owns the probe: flash and reset before
+opening it; never reset while attached. An undrained NO_BLOCK_SKIP ring holds
+the FIRST KB after boot, not the wedge tail. Always select probes by serial
+(`-USB <sn>` / `adapter serial`) — rigs run several. Two probes wired to one
+SWD header wedge the target.
+
+## v1 backend matrix
+
+| Backend | Read (capture) | Write (console input) |
+| ----------------------------------------------------- | ---------------------------- | ------------------------------------------ |
+| J-Link native (`JLinkExe -RTTTelnetPort`) | validated | validated (8.5 KB writes) |
+| OpenOCD on native probes (ST-Link/CMSIS-DAP/WCH-Link) | validated (sysview campaign) | unvalidated — validate in the ci-rig phase |
+| OpenOCD on the LPC-Link2 (J-Link OB fw, measured) | forbidden (USB drop) | forbidden |
+| WCH SDI (any tool) | halt→dump only | n/a |
+
+`JlinkRtt`/CLI are J-Link-only in v1; OpenOCD console-write support is
+added only if the ci-rig phase validates it.
+
+## Tooling home
+
+Single implementation in `tools/rtt.py`: a stdlib-only importable module
+(shared socket-console base + `JlinkRtt` + `OpenocdRtt`) that doubles as
+the CLI. `hil_util` imports and re-exports the classes (the harness keeps
+addressing `hil_util.JlinkRtt`), so the dependency points harness → tools,
+never tools → harness. Because `hil_util` loads it at import time, the file
+is harness-critical: it is classified with `test/hil/` in `ci_select`'s full
+rule and covered by the pre-commit `hil-test` hook (test_hil_rtt.py).
+Precedent: `code-size` wrapping `tools/metrics_compare_base.py` — the skill
+is md-only and points at the tool. `open_board_console()` stays in
+`hil_test.py` for now; pool-check adoption is a follow-up doc, not this PR.
+
+## Doc edits (curated-skills rule: smallest possible diffs)
+
+- `target-debug/SKILL.md`: capture-channel rows and the drain-model warning
+ stay; the two capture recipe blocks and the RTTLogger/GDBServer paragraph
+ shrink to one-liners pointing at `rtt`; the manual ring-read recipe
+ (`nm`/`mem32`/`savebin`) moves into `rtt` §post-mortem.
+- `CLAUDE.md` GDB section RTT line becomes build flag + pointer.
+- `hil/SKILL.md` gains one routing line (the fix that would have prevented
+ the lost hour).
+- `sysview/SKILL.md` pointer is **deferred** until that branch merges, and
+ proposed to the user first. No edits to `sysview_ci.py` or the sysview
+ skill now.
+
+## Validation strategy (user-directed)
+
+1. **Dogfood on the local htpc bench first**: ea4088_quickstart via LPC-Link2
+ (replugged; OpenOCD attempts on it are skipped outright) and
+ raspberry_pi_pico2 via the J-Trace (nickname `jtrace`, serial private; now wired to pico2; RP2350 =
+ `rp2350_m33_0`, never a custom JLinkScript). Follow only the SKILL.md
+ text (dogfood = REFACTOR input).
+2. **Then all boards on the ci.lan rig**, per-transport smoke capture, rows
+ recorded in `.claude/skills/rtt/boards.md`. Exclusions recorded honestly
+ (esptool boards: no SEGGER-RTT path in our builds — USB-Serial-JTAG
+ console instead; tm4c: no probe path configured on the rig).
+
+## Non-goals
+
+Timing/profiling (etm-trace, sysview, parked swo-trace), SystemView
+encode/decode/licensing, TU_LOG conventions, debugging decision flows
+(target-debug), Espressif USB-Serial-JTAG console (esp-target-debug), WCH SDI
+live streaming (impossible — see matrix).
diff --git a/test/hil/helper/hil_health.py b/test/hil/helper/hil_health.py
index aa811eeb4..d78d0f220 100644
--- a/test/hil/helper/hil_health.py
+++ b/test/hil/helper/hil_health.py
@@ -214,9 +214,6 @@ def _kill_kids(kids: dict, seen: set) -> int:
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:
@@ -249,10 +246,8 @@ def _kill_kids(kids: dict, seen: set) -> int:
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.)
+ # SURVIVORS, not the count we signalled: the caller needs to know the rig is dirty for
+ # the next job, and a killpg is counted once per child sharing the group anyway.
return len(denied)
diff --git a/test/hil/helper/hil_lock.py b/test/hil/helper/hil_lock.py
index 7757ef17d..91f05ca86 100755
--- a/test/hil/helper/hil_lock.py
+++ b/test/hil/helper/hil_lock.py
@@ -175,13 +175,12 @@ def controller_of(uid: str):
if cached:
return cached
# 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)
+ # descriptor field. Without it this reads every probe's and hub's `serial` -- the one
+ # attribute served under device_lock -- so a wedged peer would block us here.
+ 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:
+ if busnum is None:
continue
try:
root = os.path.realpath(f'/sys/bus/usb/devices/usb{int(busnum)}')
@@ -212,7 +211,7 @@ def controller_slot(pci: str) -> int:
# 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
+# one board could not be resolved, 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.
@@ -252,8 +251,8 @@ class controller_permit:
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'
- f'{hil_util.sysfs_blind_note()}; budgeting it in the unknown bucket')
+ log(f'warning: cannot resolve {uid} to a host controller; '
+ f'budgeting it in the unknown bucket')
self.slots = [controller_slot(pci) if pci else UNKNOWN_SLOT]
def __enter__(self):
diff --git a/test/hil/helper/hil_pool_check.py b/test/hil/helper/hil_pool_check.py
index 179a417ed..d98b92bd4 100644
--- a/test/hil/helper/hil_pool_check.py
+++ b/test/hil/helper/hil_pool_check.py
@@ -54,7 +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
+_STRANDED_WARNED = False # scan_usb's caveat: once per process, not once per poll
t0 = time.monotonic()
@@ -72,20 +72,20 @@ def scan_usb() -> dict:
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 = {}
- # `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.')
+ # usb_scan's `serial` read is bounded by default (see hil_util.read_sysfs) -- this tool
+ # has no pool guard behind it and is run exactly when a device is suspected wedged. A
+ # device that will not answer is simply absent from the table; the footer says so.
+ devs = hil_util.usb_scan()
+ # ONCE per process, at SCAN time, not only in the footer: this tool prints rows as it
+ # goes over minutes, so a board dropped from the scan says "probe MISSING" within
+ # seconds while the only qualification would arrive after the final counts -- and an
+ # operator acting on the streaming output, or a run cut short by ^C, never sees it.
+ global _STRANDED_WARNED
+ if hil_util.sysfs_stranded() and not _STRANDED_WARNED:
+ _STRANDED_WARNED = True
+ say('WARNING: a bounded sysfs read gave up; 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:
found[dev['busport']] = {
@@ -360,7 +360,47 @@ def check_host_serial(board: dict, do_reset: bool = True, want_hello: bool = Fal
do_reset=False listens to the firmware as-is: used right after a flash whose
own reset already started it — a second openocd/JLink session back-to-back on
- the same probe can fail transiently and leave the target halted."""
+ the same probe can fail transiently and leave the target halted.
+
+ "logger": "rtt" boards have no VCOM: the same check runs over the probe's RTT
+ console instead. The reset happens BEFORE the console opens (it owns the probe),
+ which also zeroes the .bss ring — so pre-reset backlog cannot count as life, and
+ without a reset Commander delivers the boot burst the preceding flash left."""
+ if board.get('logger') == 'rtt':
+ if do_reset:
+ # a failed reset leaves the previous run's ring intact: attaching anyway would
+ # score stale output as life, so bail to host_alive's board_test reflash ladder
+ rc, err = call_flasher(getattr(hil_flash, f'reset_{board["flasher"]["name"].lower()}'), board)
+ if rc:
+ say(f'{board["name"]:26} reset failed: {err}')
+ return None
+ try:
+ ser = hil_util.JlinkRtt(board, timeout=0.3)
+ except hil_util.RttError as e:
+ say(f'{board["name"]:26} no RTT console: {e}')
+ return None
+ try:
+ data = b''
+ deadline = time.monotonic() + SERIAL_WAIT
+ while time.monotonic() < deadline:
+ ser.write(b'U')
+ data += ser.read(256)
+ # JLinkExe's banner arrives whether or not the target is alive --
+ # judged unfiltered it scores a dead board 'alive'. Same shared filter
+ # as test_host_device_info; complete_only drops a trailing partial
+ # line, so a banner FRAGMENT split by this read boundary cannot count
+ # as target output either.
+ td = hil_util.strip_banner(data, complete_only=True)
+ if want_hello:
+ if b'Hello from TinyUSB' in td:
+ return td
+ elif td and not boardtest_output(td):
+ return td
+ return hil_util.strip_banner(data)
+ except hil_util.RttError:
+ return None # console died mid-poll (server exited, probe dropped)
+ finally:
+ ser.close()
import serial
try:
port = hil_util.get_serial_dev(board['flasher']['uid'], None, None, 0)
@@ -983,9 +1023,13 @@ def main() -> None:
headers = ['Board', 'Probe', 'Flash', 'Device', 'Status', 'Note']
cells = [[r['name'], r['probe'], r['flash'], r['device'],
status_mark.get(r['status'], r['status']), '; '.join(r['note'])] for r in rows]
- widths = [max(len(h), *(len(c[i]) for c in cells)) if cells else len(h)
+ # display_width, not len(): ✅ / ❌ / 🔒 / ⚠ are one character and two columns, so
+ # len() pads every row holding one a column short of the header rule
+ _w = hil_util.display_width
+ widths = [max(_w(h), *(_w(c[i]) for c in cells)) if cells else _w(h)
for i, h in enumerate(headers)]
- line = lambda vals: '| ' + ' | '.join(v.ljust(w) for v, w in zip(vals, widths)) + ' |'
+ line = lambda vals: ('| ' + ' | '.join(hil_util.pad(v, w)
+ for v, w in zip(vals, widths)) + ' |')
print()
print(line(headers))
print('|' + '|'.join('-' * (w + 2) for w in widths) + '|')
@@ -1001,17 +1045,16 @@ 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.')
+ if hil_util.sysfs_stranded():
+ # Without this the table is the worst kind of wrong: a device whose `serial` never
+ # answered is absent from the scan, which prints as "probe MISSING"/"off bus" for
+ # hardware that is physically present -- during exactly the incident this tool is
+ # run to diagnose, and it sends the operator to power-cycle a healthy rig.
+ print('WARNING: at least one sysfs read did not answer within '
+ f'{hil_util.SYSFS_READ_GRACE:.0f}s, so rows above that say a probe or board '
+ f'is missing may be this tool losing sight of healthy hardware rather than '
+ f'absent hardware. Find the wedged device (see the usb-kernel-recover '
+ f'skill) and re-run before acting on the table.')
sys.exit(min(counts['flash-failed'] + counts['failed'], 125))
diff --git a/test/hil/helper/hil_report.py b/test/hil/helper/hil_report.py
index b73c030a9..c93c8e6a1 100644
--- a/test/hil/helper/hil_report.py
+++ b/test/hil/helper/hil_report.py
@@ -9,14 +9,36 @@ writers, and the fold to one machine-readable verdict per board.
Dual-mode by design: imported as `helper.hil_report` by hil_test.py, and run as a script by
the operator (see .claude/agents/hil-operator.md). A script run puts test/hil/helper on
-sys.path rather than test/hil, hence the guarded hil_health import below.
+sys.path rather than test/hil, so this module imports no sibling helper at all --
+_p and the width helpers below are defined locally for that reason.
"""
import argparse
import json
import sys
+import unicodedata
from pathlib import Path
+def _w(s: str) -> int:
+ """Terminal COLUMNS, not characters. Every status mark in REPORT_CELL is one Python
+ character and TWO columns wide, so len() pads a cell holding one a column short and
+ the pipes drift out of line with the header rule for the whole table.
+
+ Local, like _p above and for the same reason: this module is also run as a script, and
+ under PYTHONSAFEPATH=1 a sibling import dies before argparse runs. hil_util carries the
+ same pair for callers that can import it.
+ """
+ return sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in s)
+
+
+def _pad(s: str, width: int, center: bool = False) -> str:
+ """str.ljust/center, measured in display columns. See _w."""
+ room = max(0, width - _w(s))
+ if not center:
+ return s + ' ' * room
+ left = room // 2
+ return ' ' * left + s + ' ' * (room - left)
+
def _p(*args, **kwargs) -> None:
"""Print that cannot raise. Defined here rather than imported from hil_health: this
@@ -41,6 +63,10 @@ LOCKED_CELL = 'board-locked'
# A pseudo-test column, not a real one: write_timeout_report marks the boards that were
# still dispatched when the pool guard fired. accumulate_report clears it on a retry.
POOL_TIMEOUT_CELL = 'pool-timeout'
+# The other way a board can fail to report: the pool did not expire, a worker RAISED. Same
+# shape, different cause, and naming the cause is the whole point of the column -- a board
+# marked pool-timeout by an abort that never timed out sends the reader after the guard.
+RUN_ABORTED_CELL = 'run-aborted'
def _load(report_dir: Path) -> tuple:
@@ -136,12 +162,14 @@ def render_matrix(rows_all: list) -> str:
rows_vals = [(lbl, [cell(cells, c) for c in columns] + [dur or ''])
for lbl, cells, dur in rows_all]
board_hdr = 'Board'
- board_w = max([len(board_hdr)] + [len(lbl) for lbl, _ in rows_vals])
- col_w = [max([len(h)] + [len(vals[i]) for _, vals in rows_vals])
+ # display_width, not len(): the ✅/❌/⚪ marks are one character and two columns
+ board_w = max([_w(board_hdr)] + [_w(lbl) for lbl, _ in rows_vals])
+ col_w = [max([_w(h)] + [_w(vals[i]) for _, vals in rows_vals])
for i, h in enumerate(headers)]
def line(label, values):
- padded = [label.ljust(board_w)] + [v.center(w) for v, w in zip(values, col_w)]
+ padded = [_pad(label, board_w)] + [_pad(v, w, center=True)
+ for v, w in zip(values, col_w)]
return '| ' + ' | '.join(padded) + ' |'
header = line(board_hdr, headers)
@@ -328,6 +356,7 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = ''
# never reported, and update() below MERGES, so without this a board that
# passed clean on the retry kept a red cell for ever.
stale[0].pop(POOL_TIMEOUT_CELL, None)
+ stale[0].pop(RUN_ABORTED_CELL, None)
if not stale[0]:
# variant-keyed boards never repopulate the board-name row, so drop it
# or it renders as a blank ghost row
@@ -336,6 +365,7 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = ''
row = acc.setdefault(row_label, [{}, None])
# a row that ran is no longer pool-timed-out, whatever it is keyed by
row[0].pop(POOL_TIMEOUT_CELL, None)
+ row[0].pop(RUN_ABORTED_CELL, None)
# the boundary cell is only ever written on failure, so a re-run of this
# variant that cleared the boundary must drop the previous attempt's ❌
if BOUNDARY_CELL not in cells:
@@ -385,7 +415,8 @@ def _write_stuck_over_prior_md(report_dir: Path, doc: dict) -> None:
def write_timeout_report(report_dir: Path, boards, secs: int,
- banner: str = '', prefix: str = '') -> None:
+ banner: str = '', prefix: str = '',
+ cell: str = POOL_TIMEOUT_CELL) -> 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
@@ -403,7 +434,7 @@ def write_timeout_report(report_dir: Path, boards, secs: int,
caveat = 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. Rows other than '
- f'the {POOL_TIMEOUT_CELL} cells below are from an earlier attempt. Boards '
+ f'the {cell} cells below are from an earlier attempt. Boards '
f'dispatched:\n\n' + '\n'.join(f'- {n}' for n in names) + '\n')
doc, readable = _load(report_dir)
rows = doc['rows']
@@ -411,13 +442,13 @@ def write_timeout_report(report_dir: Path, boards, secs: int,
for name in names:
row = by_board.get(name)
if row is None:
- rows.append({'board': name, 'cells': {POOL_TIMEOUT_CELL: 'fail'},
+ rows.append({'board': name, 'cells': {cell: 'fail'},
'duration': None})
else:
# _load guarantees `cells` is a dict, so a null-cells row from an uploaded
# sidecar can no longer send this down the fallback and publish a board
# that ate the whole pool guard as a pass.
- row['cells'][POOL_TIMEOUT_CELL] = 'fail'
+ row['cells'][cell] = 'fail'
out = {'rows': rows, 'scope': doc['scope'], 'caveat': caveat,
'banner': ((doc['banner'] + prefix) if prefix not in doc['banner']
else doc['banner'])}
@@ -493,8 +524,11 @@ def summarize(cfg: dict, boards: list, report: dict) -> dict:
# a wedge outranks lock contention: `locked` short-circuits `detail` below, so a
# stale board-locked cell from an earlier attempt used to mask the pool-timeout
# cell the retry added -- publishing a board that hung the rig as LOCKED, which
- # hil-validate.js then RE-RUNS, paying another pool guard on it.
- wedged = any(POOL_TIMEOUT_CELL in cells for cells in mine.values())
+ # hil-validate.js then RE-RUNS, paying another pool guard on it. RUN_ABORTED_CELL
+ # is written by the same _abort_report path for a board the guard never reached,
+ # and must outrank it for the same reason.
+ wedged = any(POOL_TIMEOUT_CELL in cells or RUN_ABORTED_CELL in cells
+ for cells in mine.values())
locked = not wedged and any(LOCKED_CELL in cells for cells in mine.values())
bad = []
for vname, cells in sorted(mine.items()):
diff --git a/test/hil/helper/hil_util.py b/test/hil/helper/hil_util.py
index 0a2a13fca..6f84c143d 100644
--- a/test/hil/helper/hil_util.py
+++ b/test/hil/helper/hil_util.py
@@ -1,7 +1,8 @@
#!/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
+# data every other module needs. Stays stdlib-only; its one local dependency is
+# tools/rtt.py (the RTT console, loaded by path below) -- everything
# else imports this, including the unit tests on GitHub's bare runner; never import them
# from here. Callers set the module global `verbose`.
@@ -11,6 +12,7 @@ import glob
import os
import signal
import subprocess
+import unicodedata
import threading
import sys
from pathlib import Path
@@ -88,10 +90,33 @@ def pos_float_env(name: str, default: float) -> float:
CMD_TIMEOUT = pos_int_env('HIL_CMD_TIMEOUT', 180)
+# Post-SIGKILL reap, spent ON TOP of a run_cmd timeout whenever the child has to be killed.
+# A caller budgeting several bounded steps must add one of these PER STEP, or its own outer
+# bound fires mid-step -- for a flasher, orphaning it on the probe.
+REAP_GRACE = 10
TINYUSB_ROOT = Path(__file__).resolve().parents[3] # test/hil/helper/ -> repo root
+def display_width(s: str) -> int:
+ """Terminal COLUMNS, not characters.
+
+ The status marks the reports use -- ✅ ❌ ⚪ ⚠ 🔒 -- are one Python character and TWO
+ columns wide. Measuring with len() pads every cell containing one a column short, so
+ the pipes drift out of line against the header rule for the whole table.
+ """
+ return sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in s)
+
+
+def pad(s: str, width: int, center: bool = False) -> str:
+ """str.ljust/center, measured in display columns. See display_width."""
+ room = max(0, width - display_width(s))
+ if not center:
+ return s + ' ' * room
+ left = room // 2
+ return ' ' * left + s + ' ' * (room - left)
+
+
def cmd_stdout_text(out: Any) -> str:
if out is None:
return ''
@@ -137,74 +162,119 @@ def _print_banner(title: str, out: Any, err: Any) -> None:
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
+SYSFS_READ_GRACE = 2.0 # default bound on one attribute read; see read_sysfs
+# path -> the kernfs inode the node had when its bounded read gave up. Keyed by INODE, not
+# by path alone: a busport does not change when a board returns to the same physical port,
+# so a path-only blacklist outlives the wedge -- hil_pool_check resets or reflashes the
+# board, wait_device polls that busport for the new inode, and the scan it polls through
+# would never look at the device again. A re-enumeration destroys the kernfs node and makes
+# a new one, so a CHANGED inode is the all-clear. os.stat is safe on a wedged device: it
+# does not call ->show(), so it cannot block on the lock the reader is stuck behind.
+_stranded: dict = {}
+_strand_hits: dict = {} # path -> how many times it has stranded, ever
+_refused: set = set() # paths answered None WITHOUT reading, once past _STRAND_MAX
+_strand_lock = threading.Lock()
+_ever_stranded = 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__ = ()
+# Each strand costs a thread AND an fd for the life of the process -- on sysfs the open()
+# SUCCEEDS and only the read blocks. Two ceilings, because they bound different things:
+#
+# _PATH_STRAND_MAX -- a device that FLAPS while still wedged re-enumerates, clears the
+# inode memo, and strands again. Per path, so one sick board cannot leak without bound.
+# After this many it stays memoised whatever its inode says.
+# _STRAND_MAX -- a whole-process backstop against RLIMIT_NOFILE or the thread ceiling,
+# which would raise inside a worker and lose every board's result. Counted PER PATH, not
+# per reader: hil_pool_check runs four poll threads over one bus, and counting each
+# reader let four threads on ONE wedged device spend four credits between them. With
+# per-path counting a 27-board rig cannot approach this.
+_PATH_STRAND_MAX = 4
+_STRAND_MAX = 64
- def __bool__(self) -> bool:
- return False
- def __repr__(self) -> str:
- return 'SYSFS_UNKNOWN'
+def sysfs_stranded() -> bool:
+ """True once any bounded read has given up, and it STAYS true.
+
+ A sticky, process-wide fact, so it answers exactly one question: "could anything in
+ this process's output be the tool losing sight of healthy hardware?" -- which is what
+ hil_pool_check's footer needs. It canNOT answer "is THIS device unreadable" for a
+ caller deciding what a single missing device means; use path_stranded() for that.
+ """
+ return _ever_stranded
-SYSFS_UNKNOWN = _SysfsUnknown()
+def strand_note() -> str:
+ """Suffix for an absence claim, so "not found" never reads as proven absence.
+ Lives here because every caller that can say "not found" needs the same sentence, and
+ the one that had to re-invent it got missed: a wedged-but-enumerated printer was
+ reported as an enumeration failure, sending a maintainer after firmware.
+ """
+ return (' (a bounded sysfs read gave up, so "not found" here means "could not tell"'
+ ' -- see the usb-kernel-recover skill)') if sysfs_stranded() else ''
-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 path_stranded(path: str) -> bool:
+ """Whether THIS attribute is currently memoised as unreadable.
-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 ''
+ The per-device question sysfs_stranded() cannot answer. usbtest uses it to tell a DUT
+ whose `serial` is held under device_lock from one that genuinely left the bus, because
+ the difference decides whether it performs driver-registry writes that take the
+ UNINTERRUPTIBLE device_lock.
+ """
+ with _strand_lock:
+ return path in _stranded or path in _refused
-def read_sysfs(path: str, grace: float = SYSFS_READ_GRACE) -> str | None | _SysfsUnknown:
- """Read a sysfs attribute with a WALL-CLOCK bound.
+def read_sysfs(path: str, timeout: float = SYSFS_READ_GRACE) -> str | None:
+ """A sysfs attribute's value, or None when it did not answer.
- 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.
+ BOUNDED BY DEFAULT, and it has to be. `serial` is served by usb_string_attr, which
+ takes usb_lock_device_interruptible (v6.12.96 sysfs.c:141-143) -- the same lock a
+ wedged usbfs ioctl holds. Every OTHER attribute the harness reads (idVendor, idProduct,
+ bcdDevice, busnum, devnum, speed) is a lock-free sysfs_emit from a cached field and
+ cannot block.
- 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.
+ "Only the wedged board's own worker pays" is FALSE, which is why the bound is not
+ opt-in: usb_scan reads `serial` on every device matching the VID to find the one it
+ wants, so resolving MY board touches every peer's locked attribute. hil_lock's
+ controller_of does that from controller_permit, on essentially every board -- one
+ wedged DUT would stall every worker, not one. hil_pool_check has no guard at all.
- 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.
+ A give-up reads as None, the same as unreadable: there is no third value and no
+ per-attribute blindness. The memo is keyed by inode so the cost stays on the device
+ that is actually wedged; path_stranded() tells a caller which device that was.
"""
- 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
+ with _strand_lock:
+ was = _stranded.get(path)
+ stuck_for_good = _strand_hits.get(path, 0) >= _PATH_STRAND_MAX
+ budget_spent = len(_stranded) >= _STRAND_MAX
+ if was is not None:
try:
if os.stat(path).st_ino == was:
- return SYSFS_UNKNOWN # same node, still wedged
+ return None # same kernfs node, still wedged
except OSError:
- pass # gone: fall through, the read reports it
- _sysfs_stranded.pop(path, None) # replaced or gone -> re-read it
+ pass # gone: let the read below report it
+ if stuck_for_good:
+ return None # flapped too many times; see _PATH_STRAND_MAX
+ with _strand_lock:
+ _stranded.pop(path, None) # a different inode is the all-clear
+ elif budget_spent:
+ # see _STRAND_MAX. Recorded, not just returned: usbtest fails CLOSED on
+ # path_stranded() before the lock-taking cleanup, and a path we declined to read
+ # is exactly the case it must not be told is readable-and-absent.
+ with _strand_lock:
+ _refused.add(path)
+ return None
+
+ # BEFORE the read, not after: a node that re-enumerates DURING the grace would
+ # otherwise have its brand-new HEALTHY inode recorded as the wedged one, and only a
+ # second re-enumeration could ever clear it. If it cannot be stat'd there is no key to
+ # memoise against, so the path is simply re-read next time -- the open fails fast.
+ try:
+ ino = os.stat(path).st_ino
+ except OSError:
+ ino = None
out: dict = {}
def _read():
@@ -212,96 +282,70 @@ def read_sysfs(path: str, grace: float = SYSFS_READ_GRACE) -> str | None | _Sysf
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
+ pass
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.
+ t.join(timeout)
+ # `out` FIRST: a reader can deposit its value and still be alive for a moment
+ # afterwards, and counting that as a strand blacklists a healthy attribute forever
+ if 'v' in out:
+ # a path that answered is not refused any more: _refused feeds path_stranded(),
+ # and a stale entry makes usbtest read a LATER genuine disconnect as "cannot tell"
+ with _strand_lock:
+ _refused.discard(path)
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
+ global _ever_stranded
+ announce = False
+ if ino is None:
+ # the pre-read stat lost a race the open then won -- the node was replaced
+ # between them. Re-stat now: the reader is blocked on whatever node exists,
+ # so this is the key it is stuck on. Without a key nothing is memoised and
+ # every later poll starts another permanent thread and fd for this path.
+ try:
+ ino = os.stat(path).st_ino
+ except OSError:
+ pass
+ with _strand_lock:
+ _ever_stranded = True
+ if ino is not None:
+ first = path not in _stranded # count the PATH once, not each reader
+ _stranded[path] = ino
+ if first:
+ _strand_hits[path] = _strand_hits.get(path, 0) + 1
+ announce = len(_stranded) == _STRAND_MAX
+ else:
+ _refused.add(path) # unkeyable: at least do not vouch for it
+ if announce:
+ print(f'warning: {_STRAND_MAX} devices have unreadable sysfs attributes; '
+ f'refusing to start more bounded readers, so later reads answer None '
+ f'without looking. Find the wedged device (usb-kernel-recover skill).',
+ file=sys.stderr, flush=True)
+ return None
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.
+def usb_scan(vid_pid=None, serial=None, vid=None, timeout=SYSFS_READ_GRACE) -> list:
+ """Enumerated USB devices matching the filters: [{busport, dir, vid, pid, serial}].
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).
+ wrong; usb_string_attr reads a cached string, sysfs.c:141-143).
* 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).
+ * `serial` LAST and BOUNDED: it is the only attribute here served under the device
+ lock, so it is the only one that can block. Filtering on the lock-free pair first
+ keeps most devices out of it, but a scan for ONE board still reads the serial of
+ every peer that shares its VID -- so the bound is what stops one wedged DUT from
+ stalling every caller (see read_sysfs).
"""
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).
+ # `in`, not endswith: an interface is '<busport>:<cfg>.<ifnum>' (2-4:1.0), which
+ # CONTAINS the colon rather than ending with it. Screening them out here is worth
+ # real time -- they were 31 of 44 matches on this rig.
if ':' in os.path.basename(d):
continue
try:
@@ -315,106 +359,14 @@ def usb_scan(vid_pid=None, serial=None, vid=None) -> tuple[list, bool]:
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
+ sn = read_sysfs(os.path.join(d, 'serial'), timeout)
if sn is None:
- continue # no serial attribute: a fact
+ continue # no serial attribute
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')
+ return out
def _close_pipes(p: subprocess.Popen) -> None:
@@ -457,7 +409,7 @@ def run_alongside(argv: list, work, timeout: int) -> subprocess.CompletedProcess
except OSError:
p.kill()
try:
- out, err = p.communicate(timeout=5)
+ out, err = p.communicate(timeout=REAP_GRACE)
except subprocess.TimeoutExpired:
# Outlasted SIGKILL: uninterruptible, still holding whatever it opened.
# Abandoned like any other stray -- but as a real child in its own
@@ -479,9 +431,49 @@ def run_alongside(argv: list, work, timeout: int) -> subprocess.CompletedProcess
return _reap()
-def run_cmd(cmd: str, cwd: str | None = None, timeout: int | None = None,
+# The RTT console implementation lives in tools/rtt.py (importable classes + CLI,
+# stdlib-only, harness-critical — see its module docstring). Loaded by file path so
+# no sys.path entry for tools/ can shadow other imports; re-exported here so the
+# harness keeps addressing hil_util.JlinkRtt.
+import importlib.util as _ilu
+
+_rtt_path = TINYUSB_ROOT / 'tools' / 'rtt.py'
+if not _rtt_path.exists():
+ # name the real cause: a bare FileNotFoundError out of an exec_module here reads
+ # as a harness bug, when the actual problem is an incompletely staged tree
+ raise ImportError(f'{_rtt_path} is missing — the RTT console lives there and the '
+ f'harness depends on it; stage it alongside test/hil (hil_ci.sh does)')
+_rtt_spec = _ilu.spec_from_file_location('tinyusb_tools_rtt', _rtt_path)
+_rtt = _ilu.module_from_spec(_rtt_spec)
+sys.modules[_rtt_spec.name] = _rtt # registered: RttError must be picklable across the fork Pool
+_rtt_spec.loader.exec_module(_rtt)
+JlinkRtt = _rtt.JlinkRtt
+OpenocdRtt = _rtt.OpenocdRtt
+RttError = _rtt.RttError
+RTT_BANNER_RE = _rtt.RTT_BANNER_RE
+strip_banner = _rtt.strip_banner
+
+
+def _cmd_label(cmd) -> str:
+ """A one-line name for a banner. An argv whose payload is a `python3 -c` program would
+ otherwise dump the whole body into the CI log, where run_cmd's banners are already the
+ noisiest thing in a failing row."""
+ if isinstance(cmd, str):
+ return cmd
+ parts = [a if len(a) <= 60 else f'<{len(a)}-char program>' for a in cmd]
+ return ' '.join(parts)
+
+
+def run_cmd(cmd: str | list, cwd: str | None = None, timeout: int | None = None,
binary: bool = False, split_stderr: bool = False,
quiet: bool = False) -> subprocess.CompletedProcess:
+ """Bounded subprocess: own session, killpg on expiry, rc 124 when it had to be killed.
+
+ `cmd` is a shell STRING or an argv LIST. argv exists for a program that cannot survive
+ a trip through the shell -- a multi-line `python3 -c` body -- which is how the harness
+ runs a library call that no in-process bound can contain. A daemon thread cannot bound
+ a C call that holds the GIL, so for those the child process IS the bound.
+ """
if timeout is None:
timeout = CMD_TIMEOUT
# binary: raw bytes (text mode's errors='replace' mangles non-UTF-8 file content).
@@ -490,34 +482,31 @@ def run_cmd(cmd: str, cwd: str | None = None, timeout: int | None = None,
# still print: a killed child is always noteworthy).
popen_kwargs = {
'cwd': cwd,
- 'shell': True,
+ # a list goes straight to execve; only a string needs a shell to parse it
+ 'shell': isinstance(cmd, str),
'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
+ # 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)
+ 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
+ try:
+ out, err = p.communicate(timeout=REAP_GRACE)
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
@@ -543,7 +532,7 @@ def run_cmd(cmd: str, cwd: str | None = None, timeout: int | None = None,
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)
+ _print_banner(f'COMMAND TIMEOUT ({timeout}s): {_cmd_label(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):
@@ -551,18 +540,15 @@ def run_cmd(cmd: str, cwd: str | None = None, timeout: int | None = None,
# 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()
+ try:
+ os.killpg(p.pid, signal.SIGKILL)
+ except OSError:
+ pass
_close_pipes(p)
raise
if r.returncode != 0 and not quiet:
- _print_banner(f'COMMAND FAILED: {cmd}', r.stdout, r.stderr)
+ _print_banner(f'COMMAND FAILED: {_cmd_label(cmd)}', r.stdout, r.stderr)
elif verbose:
print(cmd)
print(cmd_stdout_text(r.stdout))
diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh
index daa787242..43ede5795 100644
--- a/test/hil/hil_ci.sh
+++ b/test/hil/hil_ci.sh
@@ -288,6 +288,9 @@ scp -q "$ROOT_DIR/test/hil/helper/__init__.py" \
"$ROOT_DIR/test/hil/helper/hil_lock.py" \
"$ROOT_DIR/test/hil/helper/hil_report.py" \
"$REMOTE:$REMOTE_DIR/test/hil/helper/"
+# the rtt console/capture tool (rtt skill), harness-critical: hil_util imports it
+ssh "$REMOTE" mkdir -p "$REMOTE_DIR/tools"
+scp -q "$ROOT_DIR/tools/rtt.py" "$REMOTE:$REMOTE_DIR/tools/"
# Copy only firmware binaries (elf/bin/hex) plus esptool metadata
# (config.env + flash_args needed by the esptool flasher), preserving structure
diff --git a/test/hil/hil_flash.py b/test/hil/hil_flash.py
index c4d4e6552..15f476ccd 100755
--- a/test/hil/hil_flash.py
+++ b/test/hil/hil_flash.py
@@ -237,7 +237,7 @@ def convoy_safe(flasher: dict) -> bool:
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,
+ # 'openocd_wch'-style entry would pass this gate, reserve the Rescue-DP legs,
# 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':
@@ -270,7 +270,7 @@ def flash_esptool(board: Board, firmware: str, timeout=None) -> subprocess.Compl
def reset_esptool(board):
# 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`.
+ # code as proof -- usbtest's recovery skips a primitive carrying `no_op`.
return subprocess.CompletedProcess(args=['dummy'], returncode=0)
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index 83e5082d4..b2b74b13c 100755
--- a/test/hil/hil_test.py
+++ b/test/hil/hil_test.py
@@ -44,7 +44,6 @@ import itertools
import os
import random
import re
-import select
import signal
import shlex
import sys
@@ -64,14 +63,15 @@ from multiprocessing import TimeoutError as MpTimeoutError
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it
import hil_flash
+import usbtest # for the recovery bounds only; hil_test runs it as a subprocess
from helper import hil_health, hil_lock, hil_report, hil_util
from helper.hil_util import device_tests, dual_tests, host_test
# 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.
+# interpreter default change.
-_mp = multiprocessing.get_context('fork') if os.name != 'nt' else multiprocessing.get_context()
+_mp = multiprocessing.get_context('fork')
Pool, Lock, Semaphore, Manager = _mp.Pool, _mp.Lock, _mp.Semaphore, _mp.Manager
import string
@@ -206,6 +206,7 @@ class Board(TypedDict):
# needs one carries a single variant named after itself (metro_m4_express /
# MAX3421_HOST=1), which is exactly what the `or [...]` default below synthesises
variant: NotRequired[list[VariantCfg]]
+ logger: NotRequired[str] # "rtt": console = the debug probe's RTT channel 0, not a VCOM (rtt skill)
toolchain: NotRequired[str] # CI build bucket override, e.g. "riscv-gcc" (consumed by hil_ci_set_matrix.py)
@@ -220,13 +221,17 @@ class HilConfig(TypedDict):
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)
+# The post-hang recovery reserve is PER BOARD and lives in usbtest.recovery_reserve(),
+# derived from the ladder that file itself declares. Reserved whole, which is what lets the
+# child run the ladder straight through instead of asking "does the next step still fit?"
+# before each step. It only ELAPSES when cases actually time out; a healthy battery returns
+# in ~200s and never touches it.
+
# 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.
+# always this PLUS the overshoot PLUS the recovery reserve when one can run, never a
+# separate literal, or lowering one eats the room the other 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
@@ -234,10 +239,13 @@ USBTEST_BATTERY_BUDGET = hil_util.pos_int_env('HIL_USBTEST_BATTERY_BUDGET', 260)
# 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.
+# dmesg_tail(), bounded by HELPER_TIMEOUT=30 and run on BOTH the FAIL and HUNG timeout
+# paths = 95s. 120 leaves a margin. Re-derive it if any of those three moves -- dmesg_tail
+# is the one easily missed, and without it the estimate lands 20s short.
USBTEST_OVERSHOOT = 120
+# Named, not a literal, so the unit tests can zero it: every test that drives
+# test_device_usbtest against a fake rig otherwise pays a real 3s (ten of them, 30s a run).
+USBTEST_SETTLE = 3
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)
@@ -289,6 +297,25 @@ def open_serial_dev(port: str):
return ser
+def open_board_console(board: Board):
+ """The board's log console: its probe's VCOM, or RTT when the probe has none.
+
+ Both ends expose the same read/in_waiting/write/close surface, so the tests read one
+ the same way they read the other."""
+ if board.get('logger') == 'rtt':
+ # JlinkRtt speaks JLinkExe only; an openocd/stlink flasher would yield
+ # `-device ''` and fail 15 s later with a misleading port error. The OpenOCD
+ # RTT route is validated manually on native probes but has no harness backend
+ # yet (rtt skill; followup doc) — and never point it at ea4088's LPC-Link2
+ # (measured: knocks that probe off USB; other J-Link-OB probes untested)
+ assert board['flasher']['name'].lower() == 'jlink', \
+ f'{board["name"]}: "logger": "rtt" needs a jlink flasher, not {board["flasher"]["name"]}'
+ return hil_util.JlinkRtt(board)
+ ser = open_serial_dev(hil_util.get_serial_dev(board['flasher']["uid"], None, None, 0))
+ ser.timeout = 0.1
+ return ser
+
+
def serial_write_all(ser: serial.Serial, data: bytes):
# 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
@@ -297,7 +324,18 @@ def serial_write_all(ser: serial.Serial, data: bytes):
ser.write(data)
except serial.SerialTimeoutException:
raise AssertionError(f'Serial write timeout after {SERIAL_WRITE_TIMEOUT:.1f}s')
+ except hil_util.RttError as e:
+ # the RTT console's failure contract (stall/closed/peer death): same
+ # drain-stopped meaning as the serial timeout -- a test failure, not a harness
+ # crash. Deliberately NOT bare RuntimeError: NotImplementedError and CPython's
+ # own 'dictionary changed size during iteration' are RuntimeErrors too, and a
+ # harness bug must not be reported as this board misbehaving.
+ raise AssertionError(f'Console write failed: {e}')
+
+# J-Link Commander's telnet greeting: never target output (defined with the console
+# in tools/rtt.py; hil_pool_check strips it through the same object)
+RTT_BANNER_RE = hil_util.RTT_BANNER_RE
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
@@ -317,6 +355,87 @@ LP_READER = (
' buf += chunk\n'
'sys.stdout.buffer.write(buf)\n'
)
+# Runs under hil_util.run_cmd as `python3 -c`, argv so the body needs no shell quoting.
+# A PROCESS, not a thread, and not optional: cython-hidapi wraps hid_enumerate in
+# `with nogil` but calls hid_open and hid_close BARE (hidapi 0.15.0 hid.pyx), so those hold
+# the GIL for their whole blocking call. A daemon thread cannot bound that -- the waiter
+# parks off-GIL but must reacquire the GIL to return, which the stuck thread never yields
+# -- so an in-process bound is inert exactly where it is needed, and the whole worker
+# freezes rather than just the call. killpg reaches a child regardless.
+#
+# What blocks: hidapi's hidraw backend reads `manufacturer` and `product` via udev for each
+# device that reaches create_device_info_for_device, via copy_udev_string(usb_dev,
+# "manufacturer"/"product") -- both usb_string_attr, served under the device lock a wedged
+# usbfs ioctl holds (v6.12.96 sysfs.c:141-143).
+#
+# Passing BOTH ids is what keeps a wedged peer out of that path, and it does more than skip
+# non-matches: hidapi only runs the cheap pre-check `if (vendor_id != 0 || product_id != 0)`
+# (0.15.0 linux/hid.c:962), so an unfiltered walk sends EVERY device straight to the locked
+# reads. The pre-check itself is free -- parse_hid_vid_pid_from_sysfs parses
+# <sysfs_path>/device/uevent (:532) -- and both `continue`s precede
+# create_device_info_for_device (:966-970 before :976). Six examples in this tree expose a
+# HID interface under VID cafe, so a VID-only walk would stall on any of them wedged on a
+# peer. hid_open passes the same ids through to hid_enumerate internally (:1030), so the
+# filter narrows that walk too -- but a peer running THIS example still matches both ids,
+# which is why the child process, not the filter, is what bounds this.
+HID_ECHO = r"""
+import hid, random, sys, time
+
+uid, budget, want_pid = sys.argv[1], float(sys.argv[2]), int(sys.argv[3], 16)
+deadline = time.monotonic() + budget
+
+dev = None
+while dev is None:
+ for d in hid.enumerate(0xCafe, want_pid):
+ if d["serial_number"] == uid:
+ dev = d
+ break
+ if dev is not None or time.monotonic() >= deadline:
+ break
+ time.sleep(1)
+if dev is None:
+ sys.exit(f"HID device not found for {uid}")
+
+h = hid.device()
+h.open(dev["vendor_id"], dev["product_id"], uid)
+try:
+ for size in (8, 32, 63):
+ # Report ID (0) + payload, padded to 64 bytes
+ payload = bytes(random.randint(1, 255) for _ in range(size))
+ h.write(bytes([0]) + payload + bytes(64 - size))
+ echo = h.read(64, 2000)
+ if not echo or len(echo) < size:
+ sys.exit(f"HID echo timeout or short read ({size} bytes)")
+ if bytes(echo[:size]) != payload:
+ sys.exit(f"HID echo wrong data ({size} bytes): "
+ f"sent {payload.hex()} received {bytes(echo[:size]).hex()}")
+finally:
+ h.close()
+"""
+# The write half, same shape and same reason: usblp_open() ignores O_NONBLOCK and stalls in
+# usb_autopm_get_interface() on a wedged device, holding the driver-global usblp_mutex. A
+# blocked THREAD cannot be abandoned without keeping the fd, and usblp allows a single opener
+# (v6.12.96 usblp.c), so the next open of this node returns -EBUSY for the life of the worker.
+# A killed process takes its fd with it. O_NONBLOCK is kept because usblp DOES honour it on
+# write, which is what the select()/partial-write loop below relies on.
+LP_WRITER = (
+ 'import os, random, select, sys\n'
+ 'lp, payload_path, ready = sys.argv[1], sys.argv[2], sys.argv[3]\n'
+ 'data = open(payload_path, "rb").read()\n'
+ 'fd = os.open(lp, os.O_WRONLY | os.O_NONBLOCK)\n'
+ # readiness marker, as in LP_READER: the parent must not read CDC before the node is open
+ 'open(ready, "w").close()\n'
+ 'off = 0\n'
+ 'while off < len(data):\n'
+ ' n = min(random.randint(1, 64), len(data) - off)\n'
+ ' buf, w = data[off:off + n], 0\n'
+ ' while w < len(buf):\n'
+ ' _, wr, _ = select.select([], [fd], [], 5.0)\n'
+ ' if not wr:\n'
+ ' sys.exit("printer write timeout (firmware not draining OUT endpoint)")\n'
+ ' w += os.write(fd, buf[w:])\n'
+ ' off += n\n'
+)
MTYPE_TIMEOUT = 30 # a README-sized read is <1 s; bounds a D-state hang on a wedged device
@@ -355,6 +474,13 @@ def read_disk_file(uid: str, lun: int, fname: str) -> bytes:
# ~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
+# room past the child's OWN enumeration budget for the echo exchange (3 x write + a 2000ms
+# hidapi read) and interpreter start-up, so the outer kill only fires on a real stall
+HID_ECHO_MARGIN = 30
+# hid_generic_inout's own idProduct. Pinned against the example's descriptor by
+# HidEchoRunsInAChild.test_the_pid_matches_the_example, because a silent drift here would
+# widen the walk back to every cafe: HID device without failing anything.
+HID_INOUT_PID = 0x4012
def get_printer_dev(id: str, vendor_str, product_str, ifnum: int):
@@ -363,12 +489,8 @@ 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:
- # 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:
+ if sn is None:
continue
if sn == id:
return f'/dev/usb/{os.path.basename(lp)}'
@@ -385,7 +507,7 @@ def open_printer_dev(id: str, vendor_str, product_str, ifnum: int) -> str:
lp_dev = wait_until(try_find)
assert lp_dev, (f'Printer device not found for {id} if{ifnum:02d}'
- + hil_util.sysfs_blind_note())
+ + hil_util.strand_note())
return lp_dev
@@ -441,34 +563,53 @@ 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_util.get_serial_dev(flasher["uid"], None, None, 0)
- ser = open_serial_dev(port)
- ser.timeout = 0.1
-
- # reset device since we can miss the first line
- ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board)
- assert ret.returncode == 0, 'Failed to reset device'
+ if board.get('logger') == 'rtt':
+ # The RTT console owns the probe, so reset BEFORE opening it (Commander then
+ # delivers the buffered boot burst). Unconditional, not only under --skip-flash:
+ # a previous run's console drained the ring, and the enumeration lines print
+ # only once — without this a re-run on unchanged firmware reads an empty ring.
+ ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board)
+ assert ret.returncode == 0, 'Failed to reset device'
+ ser = open_board_console(board)
+ try:
+ if board.get('logger') != 'rtt':
+ # reset device since we can miss the first line; on the VCOM the console
+ # survives the reset, so resetting after open catches the boot banner.
+ ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board)
+ assert ret.returncode == 0, 'Failed to reset device'
- data = b''
- timeout = enum_timeout()
- while timeout > 0:
- new_data = ser.read(ser.in_waiting or 1)
- if new_data:
- data += new_data
- 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)
- if vid_pid_sn:
- enum_dev_sn.append(f'{vid_pid_sn.group(1)}_{vid_pid_sn.group(2)}_{vid_pid_sn.group(3)}')
- if set(declared_devs).issubset(set(enum_dev_sn)):
- break
- time.sleep(0.1)
- timeout -= 0.1
- ser.close()
+ data = b''
+ timeout = enum_timeout()
+ while timeout > 0:
+ # infra death is not a board failure: without this a dead JLinkExe/probe
+ # would burn the whole timeout and report as 'No data from device'
+ assert not getattr(ser, 'eof', False), \
+ 'RTT console died (its server exited or the probe dropped off USB)'
+ new_data = ser.read(ser.in_waiting or 1)
+ if new_data:
+ data += new_data
+ 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)
+ if vid_pid_sn:
+ enum_dev_sn.append(f'{vid_pid_sn.group(1)}_{vid_pid_sn.group(2)}_{vid_pid_sn.group(3)}')
+ if set(declared_devs).issubset(set(enum_dev_sn)):
+ break
+ time.sleep(0.1)
+ timeout -= 0.1
+ finally:
+ ser.close()
- if len(data) == 0:
- assert False, 'No data from device'
lines = data.decode('utf-8', errors='ignore').splitlines()
+ if board.get('logger') == 'rtt':
+ # JLinkExe's telnet banner is delivered at connect, whether or not it ever
+ # finds the control block, so len(data) alone cannot tell "board said nothing"
+ # from "console never attached to the ring" -- drop the banner first
+ target_lines = hil_util.strip_banner(data).splitlines()
+ assert target_lines, ('No data from device: the RTT console attached but the target '
+ 'produced nothing -- firmware built without LOGGER=rtt, or SWD lost')
+ elif len(data) == 0:
+ assert False, 'No data from device'
enum_dev_sn = []
for l in lines:
@@ -765,9 +906,9 @@ def test_device_cdc_msc_freertos(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."""
+ """Payload scaling from a `speed` attribute. Anything not positively read as high
+ speed counts as FS, None included: 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')
@@ -806,16 +947,15 @@ def test_device_cdc_msc_throughput(board):
# 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.
+ # usb_scan, not a private glob: it skips root hubs and filters on the lock-free
+ # descriptor pair before touching `serial`.
is_fs = True
speed_known = False
- devs, _ = hil_util.usb_scan(vid='cafe', serial=uid)
+ 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)
+ speed_known = speed is not None
# Put tty in raw mode so dd sees pure binary throughput.
rs = hil_util.run_cmd(f'timeout 30 stty -F {tty} raw -echo')
@@ -988,45 +1128,81 @@ def test_device_printer_to_cdc(board):
ser.reset_input_buffer()
# Test 1: Printer -> CDC with multiple sizes, write in random 1-64 byte chunks
- LP_WRITE_TIMEOUT = 5.0 # seconds; firmware may stall draining the printer OUT endpoint
+ # The write runs in a PROCESS for the same reason the read below does: see LP_WRITER.
for size in sizes:
test_data = rand_ascii(size)
ser.reset_input_buffer()
- rd = b''
- offset = 0
- # 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'
+ rd = bytearray()
+
+ payload = Path(tempfile.gettempdir()) / f'hil-lp-tx-{os.getpid()}-{size}'
+ ready = Path(tempfile.gettempdir()) / f'hil-lp-ready-{os.getpid()}-{size}'
+ payload.write_bytes(test_data)
+ ready.unlink(missing_ok=True)
+ # +5 like write_cdc's sibling wait below: the bound is on the OPEN, and the child
+ # must first fork, exec and boot CPython, which on a loaded rig routinely exceeds
+ # LP_OPEN_TIMEOUT on its own. A tighter wait here reports a slow interpreter start
+ # as a wedged node.
+ open_deadline = time.monotonic() + LP_OPEN_TIMEOUT + 5
+ saw_ready = False
+
+ def read_cdc():
+ # WAIT for the writer to have the node open, as Test 2's write_cdc does: the
+ # child has to fork, exec and boot CPython, and reading before it starts just
+ # burns the serial timeout.
+ # ONE deadline, shared with the child's bound below. Two different ones let
+ # the writer open after the parent gave up: it writes the whole payload with
+ # nobody reading, exits 0, and the byte-compare reports FIRMWARE DATA
+ # CORRUPTION for a board whose only problem was a slow open.
+ nonlocal saw_ready
+ while not ready.exists():
+ if time.monotonic() > open_deadline:
+ return # never opened; the assert below reports THAT, not data
+ time.sleep(0.02)
+ saw_ready = True
+ # fullspeed devices may need extra time; ser.read is bounded by
+ # SERIAL_READ_TIMEOUT, so an empty return means the stream went quiet
+ while len(rd) < size:
+ chunk = ser.read(size - len(rd))
+ if not chunk:
+ break
+ rd.extend(chunk) # in place: `rd +=` would rebind it as a local
+
try:
- while offset < size:
- chunk_size = min(random.randint(1, 64), size - offset)
- buf = test_data[offset:offset + chunk_size]
- written = 0
- while written < len(buf):
- _, wr, _ = select.select([], [lp_fd], [], LP_WRITE_TIMEOUT)
- assert wr, f'Printer write timeout after {LP_WRITE_TIMEOUT}s (firmware not draining OUT endpoint)'
- n = os.write(lp_fd, buf[written:])
- written += n
- rd += ser.read(chunk_size)
- offset += chunk_size
+ r = hil_util.run_alongside(
+ [sys.executable, '-c', LP_WRITER, lp_dev, str(payload), str(ready)],
+ read_cdc, LP_OPEN_TIMEOUT + 12)
finally:
- os.close(lp_fd)
- # read any remaining bytes (fullspeed devices may need extra time)
- while len(rd) < size:
- remaining = ser.read(size - len(rd))
- if not remaining:
- break
- rd += remaining
- assert rd == test_data, (f'Printer->CDC wrong data ({size} bytes):\n'
- f' expected: {test_data[:64]}\n received: {rd[:64]}')
+ ready.unlink(missing_ok=True)
+ payload.unlink(missing_ok=True)
+ # rc 124 is run_alongside's kill, i.e. the open blocked -- and stderr is EMPTY
+ # there, so without the fallback the cell reads 'failed (32 bytes, rc 124):' and
+ # nothing, for the one failure this conversion exists to contain. An OSError is a
+ # FACT about the node (EBUSY from usblp's single-opener rule, ENOENT from a
+ # re-enumeration race) and must not send the operator to usb-kernel-recover.
+ # The bound covers the open AND the whole write, so rc 124 alone does not mean a
+ # wedged node. `ready` is written on the line after os.open() returns, so its
+ # ABSENCE is what says the open never completed -- the case that sends an operator
+ # to usb-kernel-recover. Anything else killed on the bound was a slow drain.
+ detail = hil_util.cmd_stdout_text(r.stderr).strip()[:200]
+ # FIRST: a child that exited on its OWN carries the concrete errno, and only one
+ # we KILLED (rc 124) can be diagnosed as an open that never completed. Asserting
+ # the marker before this reported EBUSY/ENOENT as a wedged node -- the conflation
+ # the comment above exists to prevent. rc is in the message because a child killed
+ # by a signal leaves `detail` empty.
+ assert r.returncode in (0, 124), (
+ f'Printer->CDC writer failed ({size} bytes, rc {r.returncode}): {detail}')
+ # saw_ready, not ready.exists(): a marker that appeared AFTER read_cdc gave up
+ # means the child wrote with nobody reading, and the byte-compare below would call
+ # that firmware data corruption. Report the slow open instead.
+ assert saw_ready, (f'printer: {lp_dev} was not opened for write within '
+ f'{LP_OPEN_TIMEOUT + 5}s (device wedged, or the writer never '
+ f'started); rc {r.returncode}')
+ assert r.returncode == 0, (
+ f'Printer->CDC writer killed on its bound after opening {lp_dev} '
+ f'(rc {r.returncode}): the firmware stopped draining the OUT endpoint')
+ assert bytes(rd) == test_data, (f'Printer->CDC wrong data ({size} bytes):\n'
+ f' expected: {test_data[:64]}\n'
+ f' received: {bytes(rd)[:64]}')
# 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
@@ -1066,8 +1242,13 @@ def test_device_printer_to_cdc(board):
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]}')
+ # rc 124 is run_alongside's kill -- a blocked usblp_open leaves stderr EMPTY, so
+ # without the fallback this renders as 'failed (32 bytes, rc 124):' and nothing
+ rdetail = hil_util.cmd_stdout_text(r.stderr).strip()[:200]
+ assert r.returncode == 0, (
+ f'CDC->Printer reader failed ({size} bytes): {rdetail}' if rdetail else
+ f'printer: reading {lp_dev} blocked (device wedged): the reader was killed on '
+ f'its bound (rc {r.returncode})')
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)
@@ -1234,9 +1415,6 @@ def test_device_midi_test(board):
def test_device_audio_test_freertos(board):
uid = board['uid']
- if os.name == 'nt':
- return 'skipped'
-
pcm = None
timeout = enum_timeout()
while timeout > 0:
@@ -1296,38 +1474,19 @@ def test_device_audio_test_freertos(board):
def test_device_hid_generic_inout(board):
+ # The whole exchange runs in a child (see HID_ECHO): hidapi's blocking calls hold the
+ # GIL, so nothing in-process can bound them. run_cmd's killpg can.
uid = board['uid']
- import hid # cython-hidapi (pip: hidapi, apt: python3-hid)
-
- timeout = enum_timeout()
- dev = None
- while timeout > 0:
- for d in hid.enumerate(0xCafe):
- if d['serial_number'] == uid:
- dev = d
- break
- if dev:
- break
- time.sleep(1)
- timeout -= 1
- assert dev is not None, f'HID device not found for {uid}'
-
- h = hid.device()
- h.open(dev['vendor_id'], dev['product_id'], uid)
- try:
- for size in [8, 32, 63]:
- # Report ID (0) + payload, padded to 64 bytes
- payload = bytes([random.randint(1, 255) for _ in range(size)])
- report = bytes([0]) + payload + bytes(64 - size)
- h.write(report)
- echo = h.read(64, 2000)
- assert echo and len(echo) >= size, (
- f'HID echo timeout or short read ({size} bytes)')
- assert bytes(echo[:size]) == payload, (
- f'HID echo wrong data ({size} bytes):\n'
- f' expected: {payload.hex()}\n received: {bytes(echo[:size]).hex()}')
- finally:
- h.close()
+ r = hil_util.run_cmd(
+ [sys.executable, '-c', HID_ECHO, uid, str(enum_timeout()), f'{HID_INOUT_PID:#06x}'],
+ timeout=enum_timeout() + HID_ECHO_MARGIN, split_stderr=True)
+ # rc 124 is run_cmd's kill: the child was still inside a hidapi call, which is the
+ # wedge this runs in a child FOR -- and stderr is empty there, so say so rather than
+ # render a bare trailing colon
+ detail = hil_util.cmd_stdout_text(r.stderr).strip()[:300]
+ assert r.returncode == 0, (f'hid_generic_inout: {detail}' if detail else
+ f'hid_generic_inout: the child was killed on its bound '
+ f'(rc {r.returncode}) -- a hidapi call did not return')
def test_device_usbtest(board):
@@ -1337,35 +1496,30 @@ def test_device_usbtest(board):
uid = board['uid']
def usbtest_enumerated():
- """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
+ # on the bus before the one read that can block.
+ return bool(hil_util.usb_scan(vid_pid=('cafe', '4010'), serial=uid))
end = time.monotonic() + enum_timeout()
seen = usbtest_enumerated()
- while time.monotonic() < end and seen is not True:
+ while time.monotonic() < end and not seen:
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 seen is not True:
+ if not seen:
# 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}' 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'{hil_report.REPORT_CELL["fail"]} 0/30')
+ # maxtasksperchild=1, so this worker only ever handled THIS board: a give-up here
+ # is about this device. Without the caveat a wedged-but-present DUT reads as a
+ # positive absence claim -- the conflation this whole path exists to avoid.
+ raise TestFail(f'no cafe:4010 device with serial {uid}{hil_util.strand_note()}',
+ metric=f'{hil_report.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)
+ time.sleep(USBTEST_SETTLE)
# --keep-binding is required for concurrent batteries: usbtest.py's cleanup unbinds
# EVERY usbtest-bound interface, killing a peer battery under USBTEST_PARALLEL > 1, and
@@ -1381,8 +1535,9 @@ def test_device_usbtest(board):
# 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
+ # board['name'] reflashes the wrong build on variant-only boards. Our run_cmd bound
+ # below RESERVES the whole ladder (usbtest.recovery_reserve), which is what lets the
+ # child run it straight through without an outer kill landing mid-flash and orphaning
# 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).
@@ -1394,14 +1549,12 @@ def test_device_usbtest(board):
# 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)
+ # ONE bound: run_cmd's kill below. It carries the recovery reserve only when a
+ # recovery can actually run, and only what THIS flasher's ladder can spend -- a board
+ # that cannot recover used to hold a pool worker AND its battery permit idle for a
+ # reserve it had no way to spend, under a usbtest width of 2.
+ outer = USBTEST_BATTERY_BUDGET + USBTEST_OVERSHOOT + (
+ usbtest.recovery_reserve(_rec_flasher) if recovery else 0)
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)
@@ -1410,12 +1563,11 @@ def test_device_usbtest(board):
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
+ # ship the RECOVERY flasher as `flasher`: usbtest.py and convoy_safe both 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
@@ -1453,6 +1605,20 @@ def test_device_usbtest(board):
raise TestFail(f'usbtest did not run: {detail}',
metric=f'{hil_report.REPORT_CELL["fail"]} 0/30')
+ return _usbtest_verdict(board, data, out, passed, failed, recovery,
+ _rec_flasher)
+
+
+def _usbtest_verdict(board: Board, data: dict, out: str, passed: int, failed: int,
+ recovery: bool, rec_flasher: dict) -> str:
+ """The report cell for a battery that produced JSON, or a TestFail carrying one.
+
+ Also latches board_wedged, which stops the REST of this board's examples: each would
+ flash THROUGH the poisoned usbfs node, block, survive SIGKILL and add another stray --
+ one wedge becoming one stray per remaining example, which is the convoy this whole
+ containment path exists to prevent.
+ """
+ global board_wedged
# 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
@@ -1461,22 +1627,22 @@ def test_device_usbtest(board):
# 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
+ # rec_flasher, NOT board['flasher']: recovery was decided against recover_flasher()
+ # in the caller, 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
+ # because unrecovered_hang is also set by the ambiguous abort, 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'))
+ + (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
+ # Every case passed and the device STILL wedged -- usbtest's 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
@@ -1665,7 +1831,7 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
def build_board(board: Board) -> tuple[str, int]:
"""Build firmware for this board via tools/build.py.
- Honors board config's variant list (name, defines, flags).
+ Honors board config's variant list.
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
@@ -1702,7 +1868,48 @@ def build_board(board: Board) -> tuple[str, int]:
return name, failed
-def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
+def _tests_for(board: Board) -> list:
+ """Which examples this board runs, in roster order.
+
+ Three sources, most specific first: an explicit -bt list for this board, a global -t
+ list filtered against what the board can actually do, or the roster's own capability
+ flags. The -t filter is not cosmetic -- without it a device-only board runs host/dual
+ tests whose `dev_attached` roster entry does not exist.
+ """
+ name = board['name']
+ if name in board_test:
+ return list(board_test[name])
+
+ board_tests = board.get('tests', {})
+ if test_only:
+ if 'only' in board_tests:
+ allowed = set(board_tests['only'])
+ return [t for t in test_only if t in allowed]
+ return [t for t in test_only
+ if board_tests.get(t.split('/', 1)[0]) is True]
+
+ if 'tests' not in board:
+ return []
+ test_list: list = []
+ if board_tests.get('device') is True:
+ test_list += list(device_tests)
+ if board_tests.get('dual') is True:
+ test_list += dual_tests
+ if board_tests.get('host') is True:
+ test_list += host_test
+ if 'only' in board_tests:
+ test_list = list(board_tests['only'])
+ for skip in board_tests.get('skip', []):
+ if skip in test_list:
+ test_list.remove(skip)
+ log_line(f'{name:25} {skip:30} ... Skip')
+ return test_list
+
+
+def test_board(board: Board) -> tuple:
+ # (name, err_count, failed_tests, rows, duration[, strays]) -- the board-LOCKED early
+ # return is 5 wide, the normal one 6. _stray_note reads index 5 behind a len() guard,
+ # so a field inserted anywhere before it silently reports a duration as a stray count.
swept = False
name = board['name']
flasher = board['flasher']
@@ -1719,38 +1926,7 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
# after the lock: flock wait behind a concurrent run is not board cost
t_board = time.monotonic()
try:
- 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, 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'])
- test_list = [t for t in test_only if t in allowed]
- else:
- for t in test_only:
- category = t.split('/', 1)[0]
- if board_tests.get(category) is True:
- test_list.append(t)
- else:
- if 'tests' in board:
- board_tests = board['tests']
- if board_tests.get('device') is True:
- test_list += list(device_tests)
- if board_tests.get('dual') is True:
- test_list += dual_tests
- if board_tests.get('host') is True:
- test_list += host_test
- if 'only' in board_tests:
- test_list = board_tests['only']
- if 'skip' in board_tests:
- for skip in board_tests['skip']:
- if skip in test_list:
- test_list.remove(skip)
- log_line(f'{name:25} {skip:30} ... Skip')
+ test_list = _tests_for(board)
err_count = 0
failed_tests = []
@@ -1859,12 +2035,10 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
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.
+ # LAST field: what this worker could not kill. Only the worker can answer it, 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)
+ rows, t_total, 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
@@ -1983,7 +2157,7 @@ def _stray_note(mret: list) -> str:
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]]
+ dirty = [(r[0], r[5]) for r in mret if len(r) > 5 and r[5]]
if not dirty:
return ''
total = sum(n for _, n in dirty)
@@ -1992,28 +2166,6 @@ def _stray_note(mret: list) -> str:
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')
-
-
# 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
@@ -2070,6 +2222,135 @@ def _abandon_exit(pool, mgr, abandoned: bool, err_count: int,
os._exit(min(err_count, 125) if err_count else 1)
+def _load_controller_hints() -> tuple[dict, dict]:
+ """The uid -> {name, pci, duration} cache, plus the uid -> pci view scheduling wants.
+
+ Best effort throughout: a missing, hand-edited or torn cache costs dispatch ORDER,
+ never the run.
+ """
+ hints: dict = {}
+ try:
+ with CONTROLLER_CACHE.open() as f:
+ loaded = json.load(f)
+ if isinstance(loaded, dict): # keep only the expected uid -> dict shape
+ hints = {k: v for k, v in loaded.items() if isinstance(v, dict)}
+ except (OSError, ValueError):
+ pass
+ return hints, {uid: h['pci'] for uid, h in hints.items() if h.get('pci')}
+
+
+def _save_controller_hints(hints: dict, mret: list, uid_of: dict, cmap) -> None:
+ """Fold this run's PCI resolutions and durations back into the cache, atomically.
+
+ Merge-on-write: another HIL job (the esp split) may have finished since our startup
+ read, so overlay only this run's boards rather than publishing our whole view.
+ """
+ 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: the 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
+ merged: dict = {}
+ 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
+ # overlay onto what the CACHE now holds, not onto our startup snapshot: another HIL
+ # job may have written a newer duration/pci for these boards since we read it
+ for name, *_ in mret:
+ uid = uid_of.get(name)
+ if uid is not None and uid in hints:
+ merged[uid] = {**merged.get(uid, {}), **hints[uid]}
+ 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)
+
+
+def _abort_report(reason: str, mret: list, config_boards: list, failed_fname: Path,
+ report_dir: Path, fresh: bool, health_banner: str,
+ timeout_secs: int | None = None) -> None:
+ """Keep what finished, name what did not, and get a report on disk. Never raises.
+
+ Both abort paths -- the pool guard expiring and a worker raising -- need exactly this,
+ and in this order. The re-run spec goes FIRST: a fresh run already unlinked it, and
+ leaving it unwritten is what made a GitHub re-run repeat the whole fleet. Only the
+ boards that never reported go in it.
+
+ The report follows, before anything that can block, and the caller raises afterwards
+ into the one containment path. `timeout_secs` adds the pool-guard fallback: when
+ accumulate_report itself fails -- an unwritable report dir, a torn JSON --
+ _abandon_exit can only stamp a report that EXISTS, so without it the artifact upload
+ finds nothing and the sticky PR comment keeps the previous push's green table under a
+ red job.
+ """
+ stuck = [b['name'] for b in config_boards if b['name'] not in {r[0] for r in mret}]
+ try:
+ _write_failed_spec(failed_fname, report_dir,
+ [(n, 1, [], None, 0) for n in stuck]
+ + [r for r in mret if r[1] > 0])
+ except Exception as werr: # noqa: BLE001 - it mkdir()s and open()s the very report dir
+ # the fallback below is FOR an unwritable/root-owned report dir; letting the spec
+ # raise here replaces the caller's RuntimeError, so the operator never sees the
+ # 'pool timed out' line and no report is written at all
+ print(f'warning: re-run spec failed: {type(werr).__name__}: {werr}', flush=True)
+ banner = (f"**HIL run {reason}.** {len(mret)} board(s) below finished and are this "
+ f"run's; {len(stuck)} never reported and are NOT in the table: "
+ f"{', '.join(stuck)}. The re-run spec covers those.\n")
+ try:
+ hil_report.accumulate_report(mret, report_dir, fresh, '',
+ health_banner + _stray_note(mret), caveat=banner)
+ return
+ except Exception as rerr: # noqa: BLE001 - the caller's raise must still happen
+ print(f'warning: partial report failed: {type(rerr).__name__}: {rerr}'
+ + '; falling back to the board list', flush=True)
+ try:
+ # banner=, or write_timeout_report's default caveat publishes 'No per-board
+ # results could be collected' onto a report where mret DID hold finished rows
+ # the CELL names the cause: a board the pool guard never reached did not
+ # "pool-timeout", and marking it so sends the reader after a guard that did not fire
+ hil_report.write_timeout_report(
+ report_dir, [b for b in config_boards if b['name'] in stuck],
+ timeout_secs or 0, banner=banner, prefix=health_banner,
+ cell=(hil_report.POOL_TIMEOUT_CELL if timeout_secs
+ else hil_report.RUN_ABORTED_CELL))
+ except Exception as re2: # noqa: BLE001
+ print(f'warning: fallback report failed too: {type(re2).__name__}: {re2}',
+ flush=True)
+
+
+def _start_pool(mgr, seed: str, hints_by_uid: dict):
+ """(cmap, pool). Split out so main()'s try/finally reads as one shape.
+
+ The Manager is created by the CALLER and passed in: Pool() forks, and after a convoy
+ that fork is what hits EAGAIN/ENOMEM. Creating the Manager here too would leave main()
+ with `mgr` still None while a live SyncManager child exists -- os._exit skips its
+ finalizer and the orphan holds the runner's stdout, so the job step never completes.
+
+ maxtasksperchild=1: a fresh worker per board makes cross-board contamination
+ structural rather than dependent on every module global being reset by hand
+ (board_wedged, _current_fw, hil_flash's warn-once sets). The extra fork is noise
+ against a flash+test cycle.
+ """
+ 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)
+ pool = Pool(processes=os.cpu_count() or 1, initializer=init_worker,
+ initargs=initargs, maxtasksperchild=1)
+ return cmap, pool
+
+
def main() -> None:
"""
Hardware test on specified boards
@@ -2141,6 +2422,56 @@ 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)]
+
+ # fail rtt misconfigurations before the first flash cycle -- but only for boards
+ # this run actually touches: one bad roster entry must not abort other runs' subsets
+ def _rtt_config_abort(msg: str):
+ # loud AND leaving evidence, like the no-boards branch below: exiting with no
+ # report at all lets the PR comment keep the previous push's stale table
+ print(f'ERROR: {msg}', flush=True)
+ rd = Path(os.environ.get('HIL_REPORT_DIR', '.'))
+ hil_report.mark_report_no_boards(rd, f'config error: {msg}', fresh=not args.accumulate)
+ sys.exit(1)
+
+ bad_logger = [e['name'] for e in config_boards if e.get('logger') not in (None, 'rtt')]
+ if bad_logger:
+ # only the exact string activates RTT handling; anything else would silently
+ # mean VCOM and reproduce the misleading 'No serial device found' failure
+ _rtt_config_abort(f'unknown "logger" value (only "rtt" is supported): {", ".join(bad_logger)}')
+ bad_rtt = [e['name'] for e in config_boards
+ if e.get('logger') == 'rtt' and e['flasher']['name'].lower() != 'jlink']
+ if bad_rtt:
+ # JlinkRtt speaks JLinkExe only (the OpenOCD RTT route is manual — rtt skill)
+ _rtt_config_abort(f'"logger": "rtt" needs a jlink flasher: {", ".join(bad_rtt)}')
+ rtt_no_logger_def = [e['name'] for e in config_boards
+ if e.get('logger') == 'rtt'
+ and any('LOGGER=rtt' not in (v.get('defines') or [])
+ for v in (e.get('variant') or [{}]))]
+ if rtt_no_logger_def:
+ # a prebuilt cmake-build-<board> configured with -DLOGGER=rtt is a legitimate
+ # build path the roster need not describe, so warn there -- but when this run is
+ # responsible for the firmware (--build, or CI where the hil-build job compiled
+ # the artifact from these same defines) the flashed image is UART-logger and every
+ # test times out as 'the target produced nothing'. An always-on define is
+ # expressed as a single self-named variant (see the Board comment).
+ msg = (f'"logger": "rtt" board has a variant without LOGGER=rtt in its defines '
+ f'({", ".join(rtt_no_logger_def)})')
+ if args.build or os.environ.get('GITHUB_ACTIONS'):
+ _rtt_config_abort(f'{msg} -- the firmware built for this run cannot serve the '
+ f'configured RTT console')
+ print(f'warning: {msg} -- fine for prebuilt example sets, wrong for --build/CI '
+ f'builds', flush=True)
+ rtt_fixture = [e['name'] for e in config_boards
+ if e.get('logger') == 'rtt'
+ and any(d.get('is_cdc') or d.get('is_msc')
+ for d in e.get('tests', {}).get('dev_attached', []))]
+ if rtt_fixture:
+ # interim guard, removed when the followup lands: cdc_msc_hid/msc_file_explorer
+ # still open the flasher VCOM directly and would die mid-run on an rtt board
+ _rtt_config_abort(f'"logger": "rtt" boards cannot carry is_cdc/is_msc fixtures yet '
+ f'(host cdc/msc tests bypass the RTT console — see '
+ f'the rtt harness-adoption doc in docs/superpowers/followup/): {", ".join(rtt_fixture)}')
+
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
@@ -2188,9 +2519,6 @@ def main() -> None:
report_dir = Path(os.environ.get('HIL_REPORT_DIR', '.'))
failed_fname = report_dir / (config_file.name + '.failed')
fresh = not args.accumulate
- # 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); '
@@ -2200,16 +2528,7 @@ def main() -> None:
# unattributable from the log alone
f'pool guard: {POOL_TIMEOUT}s')
- hints = {}
- try:
- with CONTROLLER_CACHE.open() as f:
- loaded = json.load(f)
- # tolerate a hand-edited/torn cache: keep only the expected uid -> dict shape
- if isinstance(loaded, dict):
- hints = {k: v for k, v in loaded.items() if isinstance(v, dict)}
- except (OSError, ValueError):
- pass
- hints_by_uid = {uid: h['pci'] for uid, h in hints.items() if h.get('pci')}
+ hints, hints_by_uid = _load_controller_hints()
config_boards = schedule_boards(config_boards, hints_by_uid)
log_line('dispatch order: ' + ', '.join(b['name'] for b in config_boards))
@@ -2240,20 +2559,11 @@ def main() -> None:
(report_dir / f).unlink(missing_ok=True)
failed_fname.unlink(missing_ok=True)
try:
+ # BOUND FIRST, in main's own scope: a Pool fork failure inside _start_pool must
+ # still leave a live Manager reachable by the finally below, or its child is
+ # orphaned holding the runner's stdout.
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)
+ cmap, pool = _start_pool(mgr, seed, hints_by_uid)
# 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
@@ -2270,43 +2580,13 @@ def main() -> None:
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
+ # RAISE afterwards 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:
- hil_report.accumulate_report(mret, report_dir, fresh, '',
- health_banner + _blind_note(mret)
- + _stray_note(mret), caveat=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_report.write_timeout_report(
- report_dir, [b for b in config_boards
- if b['name'] in stuck], POOL_TIMEOUT,
- prefix=health_banner)
- except Exception as re2: # noqa: BLE001
- print(f'warning: fallback report failed too: '
- f'{type(re2).__name__}: {re2}', flush=True)
+ mret = te.finished
+ _abort_report(f'abandoned: worker pool timed out after {POOL_TIMEOUT}s',
+ mret, config_boards, failed_fname, report_dir, fresh,
+ health_banner, timeout_secs=POOL_TIMEOUT)
_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)
@@ -2314,44 +2594,26 @@ def main() -> None:
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:
- hil_report.accumulate_report(mret, report_dir, fresh, '',
- health_banner + _blind_note(mret)
- + _stray_note(mret), caveat=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)
+ # handler guards. The drain means `mret` already holds every board that
+ # finished, so keep those rows and name only the ones still in flight.
+ _abort_report(f'aborted: a worker raised {type(e).__name__}: {e}',
+ mret, config_boards, failed_fname, report_dir, fresh,
+ health_banner)
raise
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
+ # Not `with Pool(...)`: its __exit__ joins the workers unbounded and hangs 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.
+ # and returns False when the pool is NOT cleanly closed.
#
- # 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.
+ # Sweep BEFORE shutdown: 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.
+ # Both calls stay guarded and neither exits: a raise here would skip
+ # accumulate_report and publish an empty report dir for a run whose boards all
+ # passed. pool_abandoned is 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
@@ -2379,33 +2641,8 @@ def main() -> None:
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)
+ _save_controller_hints(
+ hints, mret, {b['name']: b['uid'] for b in config['boards']}, cmap)
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
@@ -2421,8 +2658,7 @@ def main() -> None:
scoped = sorted(set(args.board) | set(board_test))
scope = f'{len(scoped)} board(s) — {", ".join(scoped)}' if scoped else ''
report = hil_report.accumulate_report(mret, report_dir, fresh, scope,
- health_banner + _blind_note(mret)
- + _stray_note(mret))
+ health_banner + _stray_note(mret))
print()
print(report)
print(f'\nReport written to {(report_dir / hil_report.REPORT_MD).resolve()}')
diff --git a/test/hil/test/stubs/hid.py b/test/hil/test/stubs/hid.py
new file mode 100644
index 000000000..20a6cccef
--- /dev/null
+++ b/test/hil/test/stubs/hid.py
@@ -0,0 +1,76 @@
+# SPDX-License-Identifier: MIT
+"""Scripted stand-in for cython-hidapi, for the HID_ECHO child tests.
+
+A real wedge cannot be manufactured on demand, so the failure modes are scripted here and
+selected with FAKE_HID_MODE. Mirrors test/stubs/pymtp.py, which does the same for libmtp.
+"""
+import ctypes
+import ctypes.util
+import os
+import time
+
+_MODE = os.environ.get('FAKE_HID_MODE', 'ok')
+_UID = os.environ.get('FAKE_HID_UID', 'CAFE01')
+
+
+def _gil_stall():
+ """Block forever WITHOUT releasing the GIL -- the shape cython-hidapi's bare
+ hid_open()/hid_close() calls have, and the one an in-process bound cannot touch.
+
+ PyDLL, not CDLL: CDLL releases the GIL around the call, which would make this the
+ easy case instead of the hard one. Resolved through find_library so a non-glibc libc
+ still works; PyDLL(None) is not usable here (its `sleep` returns immediately).
+ """
+ ctypes.PyDLL(ctypes.util.find_library('c') or 'libc.so.6').sleep(3600)
+_PID = int(os.environ.get('FAKE_HID_PID', '0x4012'), 16)
+
+
+def enumerate(vid=0, pid=0):
+ """Real hid.enumerate(vid, pid) filters on both ids -- 0 means "any" -- and returns a
+ 'path' key too. The filters are applied BEFORE the locked manufacturer/product reads,
+ which is why passing both narrows what a wedged peer can stall."""
+ if _MODE == 'wedged_enumerate':
+ # hidapi's hidraw backend reads `manufacturer`/`product` for every device it
+ # lists, both served under the device lock -- this is that stall.
+ while True:
+ time.sleep(3600)
+ if _MODE == 'absent':
+ return []
+ if vid not in (0, 0xCafe) or pid not in (0, _PID):
+ return []
+ return [{'serial_number': _UID, 'vendor_id': 0xCafe, 'product_id': _PID,
+ 'path': b'/dev/hidraw0'}]
+
+
+class device:
+ def __init__(self):
+ self._last = b''
+
+ def open(self, vid, pid, serial):
+ # HID_ECHO really does call this, and usb_autopm/hidraw can block in it, so the
+ # child must be bounded here too -- exercised by test_a_wedged_open_is_killed.
+ if _MODE == 'wedged_open':
+ while True:
+ time.sleep(3600)
+ if _MODE == 'wedged_open_gil':
+ # a thread-based bound is inert against this; only killing the process works
+ _gil_stall()
+
+ def write(self, report):
+ self._last = bytes(report)
+
+ def read(self, size, timeout_ms):
+ if _MODE == 'wedged_read':
+ while True:
+ time.sleep(3600)
+ if _MODE == 'short_read':
+ return list(self._last[1:4])
+ if _MODE == 'wrong_data':
+ return list(bytes(b ^ 0xFF for b in self._last[1:]))
+ return list(self._last[1:]) # the device echoes the payload, minus report ID
+
+ def close(self):
+ if _MODE == 'wedged_close':
+ # also GIL-holding in cython-hidapi, and it runs in HID_ECHO's finally on
+ # every failure path
+ _gil_stall()
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
index f3e000cb4..22fbde17b 100644
--- a/test/hil/test/test_ci_select.py
+++ b/test/hil/test/test_ci_select.py
@@ -499,6 +499,8 @@ class TestPortAndCoreRoleUseExtras(unittest.TestCase):
self.assertFalse(s['full'])
for board in boards:
tests = s['boards'][board]
+ if tests == 'all':
+ continue # a board whose whole allowed set is selected collapses to 'all'
self.assertIn('device/hid_composite_freertos', tests)
self.assertIn('device/cdc_msc_freertos', tests)
self.assertIn('device/audio_test_freertos', tests)
@@ -510,6 +512,8 @@ class TestPortAndCoreRoleUseExtras(unittest.TestCase):
self.assertFalse(s['full'])
for board in boards:
tests = s['boards'][board]
+ if tests == 'all':
+ continue # a board whose whole allowed set is selected collapses to 'all'
self.assertIn('device/hid_composite_freertos', tests)
self.assertIn('device/cdc_msc_freertos', tests)
self.assertIn('device/audio_test_freertos', tests)
@@ -955,7 +959,8 @@ class TestTheHarnessTestsAreNotTheHarness(unittest.TestCase):
def test_the_harness_own_tests_select_nothing_on_either_axis(self):
for p in ('test/hil/test/test_ci_select.py', 'test/hil/test/test_ci_metrics.py',
- 'test/hil/test/test_hil_bounded.py', 'test/hil/test/stubs/pymtp.py'):
+ 'test/hil/test/test_hil_bounded.py', 'test/hil/test/stubs/pymtp.py',
+ 'test/hil/test/stubs/hid.py'):
s = ci_select.classify([p], REPO, ROSTERS)
self.assertFalse(s['full'], p)
self.assertFalse(s['boards'], p)
@@ -979,12 +984,14 @@ class TestTheHarnessTestsAreNotTheHarness(unittest.TestCase):
out = subprocess.run(['git', 'ls-files', 'test/hil/test'], cwd=REPO,
capture_output=True, text=True, check=True)
self.assertEqual(sorted(out.stdout.split()), [
+ 'test/hil/test/stubs/hid.py',
'test/hil/test/stubs/pymtp.py',
'test/hil/test/test_ci_metrics.py',
'test/hil/test/test_ci_select.py',
'test/hil/test/test_hil_bounded.py',
'test/hil/test/test_hil_health.py',
'test/hil/test/test_hil_report.py',
+ 'test/hil/test/test_hil_rtt.py',
'test/hil/test/test_hil_util.py',
], 'test/hil/test/ gained or lost a file; it is carved out of rule 2, so confirm '
'the rig still does not read anything in there before updating this list')
diff --git a/test/hil/test/test_hil_bounded.py b/test/hil/test/test_hil_bounded.py
index 715d520db..c30c58cbd 100644
--- a/test/hil/test/test_hil_bounded.py
+++ b/test/hil/test/test_hil_bounded.py
@@ -39,7 +39,6 @@ serial_stub.SerialTimeoutException = type('SerialTimeoutException', (Exception,)
sys.modules.setdefault('serial', serial_stub)
import hil_flash
import hil_test
-from helper import hil_report
def write_script(path: Path, body: str) -> None:
@@ -47,6 +46,17 @@ def write_script(path: Path, body: str) -> None:
path.chmod(path.stat().st_mode | stat.S_IEXEC)
+def no_settle(case):
+ """Zero test_device_usbtest's post-flash settle for one test.
+
+ Real hardware needs it -- the enumeration can bounce once after a flash, and on
+ dual-port parts the stale same-serial node lingers. A fake rig has neither, and ten
+ tests drive that path, so leaving it real cost 30s of every suite run.
+ """
+ case.addCleanup(setattr, hil_test, 'USBTEST_SETTLE', hil_test.USBTEST_SETTLE)
+ hil_test.USBTEST_SETTLE = 0
+
+
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."""
@@ -64,7 +74,6 @@ def run_bounded(fn, timeout: float):
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()
@@ -78,7 +87,7 @@ class ReadDiskFile(unittest.TestCase):
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
+ hil_test._enum_timeout = 1 # the wait these tests must outlast; keep it small
self.bin = tmp / 'bin'
self.bin.mkdir()
self.addCleanup(os.environ.__setitem__, 'PATH', os.environ['PATH'])
@@ -113,7 +122,11 @@ class ReadDiskFile(unittest.TestCase):
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)
+ # BELOW one full _enum_timeout wait, not above it: "fails immediately" is the
+ # claim, and a bound of 1.5 against a 1s budget passes for code that spun the
+ # whole budget -- which is the regression this test exists to catch.
+ self.assertLess(time.monotonic() - t0, hil_test._enum_timeout,
+ 'read_disk_file spun the enumeration budget on a real answer')
self.assertIn('README.TXT', str(cm.exception))
def test_hung_mtype_cannot_hang_the_worker(self):
@@ -137,37 +150,60 @@ class CompactOutput(unittest.TestCase):
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."""
+ and the reserve that pays for them is derived per flasher (see the two tests
+ below), not pinned."""
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'):
+ for flag in ('--recover-board', '--recover-fw'):
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
+ def test_the_reserve_covers_every_step_of_its_own_ladder(self):
+ """Enumerated from the SIDE EFFECTS usbtest performs, so dropping a step from
+ recovery_reserve() fails here. Overrun means run_cmd's outer kill lands MID-FLASH
+ and orphans the flasher (start_new_session, so killpg misses it) on the probe.
+ """
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')
+ from helper import hil_util as _hu
+ # each bounded step costs its timeout PLUS run_cmd's post-SIGKILL reap
+ flash = usbtest.RECOVER_FLASH_TIMEOUT + _hu.REAP_GRACE
+ reset = usbtest.RECOVER_RESET_TIMEOUT + _hu.REAP_GRACE
+ fixed = 2 * usbtest.RECOVER_SETTLE + usbtest.RECOVER_OVERHEAD
+ rp = {'name': 'openocd', 'args': '-f target/rp2040.cfg'}
+ for flasher, steps in (
+ # an RP openocd board: reset, reflash, then Rescue-DP POR + one retry
+ (rp, reset + flash + 2 * flash + fixed),
+ # openocd on a NON-RP target: rescue_openocd has no RESCUE_CFG entry for
+ # it, so its two legs are time the board can never spend
+ ({'name': 'openocd', 'args': '-f target/wch-riscv.cfg'},
+ reset + flash + fixed),
+ # esptool: reset_esptool is a stub (no_op) and rescue refuses a
+ # non-openocd flasher, so ONE reflash is all it can ever spend
+ ({'name': 'esptool', 'args': ''}, flash + fixed)):
+ self.assertEqual(usbtest.recovery_reserve(flasher), steps,
+ f'{flasher} reserves time it cannot spend, or too little')
+
+ def test_the_reserve_leaves_room_for_the_work_no_step_bounds(self):
+ """The ladder's step timeouts do not cover the two /proc walks, the roster
+ json.loads, the child's first import, or the JSON print. With zero margin any
+ env-overridable bound moving up puts the outer killpg inside the reflash."""
+ import usbtest
+ self.assertGreater(usbtest.RECOVER_OVERHEAD, 0)
+ rp = {'name': 'openocd', 'args': '-f target/rp2350.cfg'}
+ bounded = (usbtest.RECOVER_RESET_TIMEOUT + 3 * usbtest.RECOVER_FLASH_TIMEOUT)
+ self.assertGreaterEqual(usbtest.recovery_reserve(rp) - bounded,
+ usbtest.RECOVER_OVERHEAD,
+ 'the reserve equals its own worst case with no margin')
+
+ def test_a_flasher_reserves_nothing_for_a_rescue_it_cannot_run(self):
+ """rescue_openocd returns False for anything but openocd, so reserving its two
+ legs elsewhere holds a pool worker AND a usbtest permit for 200s of dead time."""
+ import usbtest
+ self.assertLess(usbtest.recovery_reserve({'name': 'esptool', 'args': ''}),
+ usbtest.recovery_reserve({'name': 'openocd',
+ 'args': '-f target/rp2040.cfg'}))
class UsbtestRunHelper(unittest.TestCase):
@@ -314,7 +350,7 @@ class _MtpFakeRig:
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
+ hil_test._enum_timeout = 1 # the wait these tests must outlast; keep it small
# the session scratch files land in cwd
self.addCleanup(os.chdir, os.getcwd())
os.chdir(tmp)
@@ -327,7 +363,6 @@ class _MtpFakeRig:
os.environ[k] = v
[email protected](os.name == 'nt', 'POSIX shell fakes')
@unittest.skipIf(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,
@@ -401,188 +436,9 @@ class ConvoySafeFlasher(unittest.TestCase):
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."""
+ serialized the whole fleet the moment a single board could not be resolved."""
def setUp(self):
import threading
@@ -642,8 +498,7 @@ class ThroughputPayloadBound(unittest.TestCase):
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'):
+ for speed in (None, '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))
@@ -700,61 +555,6 @@ class FindDeviceCache(unittest.TestCase):
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
@@ -807,45 +607,6 @@ class WedgedPidsFailsClosed(unittest.TestCase):
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.
@@ -900,16 +661,16 @@ class MtpGioFallthrough(unittest.TestCase):
t0 = time.monotonic()
r = subprocess.run([sys.executable,
str(Path(TEST_DIR).parents[0] / 'mtp_test.py'),
- '--uid', 'CAFE01', '--timeout', '3'],
+ '--uid', 'CAFE01', '--timeout', '1'],
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.assertLess(elapsed, 30, f'did not honour --timeout 1 ({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,
+ self.assertGreater(elapsed, 0.8,
f'exited without polling ({elapsed:.1f}s) -- it crashed')
self.assertNotIn('Traceback', r.stderr)
self.assertIn('MTP device not found', r.stdout + r.stderr)
@@ -973,55 +734,6 @@ class RunWhileContract(unittest.TestCase):
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
@@ -1042,19 +754,13 @@ class UsbScanIsTheOneWalk(unittest.TestCase):
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):
+ def _dev(self, name, vid, pid, serial='S1'):
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')
+ (d / 'serial').write_text(serial + '\n')
return d
def _scan(self, **kw):
@@ -1065,85 +771,16 @@ class UsbScanIsTheOneWalk(unittest.TestCase):
return self.hil_util.usb_scan(**kw)
def test_a_mismatched_vid_pid_costs_no_serial_read(self):
+ """`serial` is the ONE attribute here served under the device lock, so it is the
+ one that can block on a wedged device. Filtering on the lock-free descriptor pair
+ first is what keeps a scan for our board off every other board's locked read."""
self._dev('1-1', '1234', '5678')
self._dev('1-2', 'cafe', '4010', serial='UID1')
- devs, unknown = self._scan(vid_pid=('cafe', '4010'))
+ devs = 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 --
@@ -1189,11 +826,9 @@ class AbandonExitSurvivesAFailedFork(unittest.TestCase):
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."""
+ """run_cmd's kill is the ONE bound, and it must carry a recovery reserve only when a
+ recovery can actually run. Otherwise a board on a path that cannot recover holds a pool
+ worker and its battery permit idle for the difference, under a usbtest width of 2."""
def _invoke(self, flasher, skip_flash=False):
from contextlib import contextmanager
@@ -1222,10 +857,7 @@ class UsbtestOuterBoundIsOneValue(unittest.TestCase):
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_test, 'USBTEST_SETTLE', 0) # see no_settle
patch(hil_lock, 'usbtest_permit', contextmanager(_permit))
patch(hil_test, 'skip_flash', skip_flash)
patch(hil_test, '_current_fw', '/tmp/fw.elf')
@@ -1234,24 +866,30 @@ class UsbtestOuterBoundIsOneValue(unittest.TestCase):
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)
+ import usbtest
+ flasher = {'name': 'openocd', 'vid_pid': '0x1366 0x1024',
+ 'args': '-f target/rp2040.cfg'}
+ seen = self._invoke(flasher)
+ want = (hil_test.USBTEST_BATTERY_BUDGET + hil_test.USBTEST_OVERSHOOT
+ + usbtest.recovery_reserve(flasher))
self.assertEqual(seen['timeout'], want)
+ def test_the_reserve_follows_the_board_not_a_fleet_constant(self):
+ """Two convoy-safe openocd boards, one RP and one not: the non-RP board cannot
+ run rescue_openocd, so reserving its two legs holds a pool worker and a usbtest
+ permit for 200s of dead time."""
+ rp = self._invoke({'name': 'openocd', 'vid_pid': '0x1366 0x1024',
+ 'args': '-f target/rp2040.cfg'})
+ wch = self._invoke({'name': 'openocd', 'vid_pid': '0x1366 0x1024',
+ 'args': '-f target/wch-riscv.cfg'})
+ self.assertLess(wch['timeout'], rp['timeout'])
+
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)
+ # It does not carry the RECOVERY reserve it cannot spend
+ self.assertEqual(seen['timeout'],
+ hil_test.USBTEST_BATTERY_BUDGET + hil_test.USBTEST_OVERSHOOT)
# ...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
@@ -1259,12 +897,15 @@ class UsbtestOuterBoundIsOneValue(unittest.TestCase):
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,
+ self.assertGreaterEqual(seen['timeout'] - budget, case_timeout,
'the outer kill can land mid-case, before the JSON')
def test_skip_flash_still_bounds_the_child(self):
+ """--skip-flash disables recovery, so the child must not be given a reserve it
+ cannot spend -- but it MUST still be bounded."""
seen = self._invoke({'name': 'openocd', 'vid_pid': '0x1366 0x1024'}, skip_flash=True)
- self.assertEqual(self._outer_flag(seen['cmd']), seen['timeout'])
+ self.assertEqual(seen['timeout'],
+ hil_test.USBTEST_BATTERY_BUDGET + hil_test.USBTEST_OVERSHOOT)
class UsbtestRetryPolicy(unittest.TestCase):
@@ -1333,6 +974,7 @@ class UsbtestOuterKillStaysRetryable(unittest.TestCase):
from helper import hil_util as _hu
patch(_hu, 'glob', types.SimpleNamespace(glob=lambda p: [str(dev)]))
+ patch(hil_test, 'USBTEST_SETTLE', 0) # see no_settle
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
@@ -1662,60 +1304,6 @@ class StagingCoversEveryBoardForm(unittest.TestCase):
"last run's re-run spec survived a green run")
-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_report.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
@@ -1820,13 +1408,14 @@ class WedgedBoardCosts(unittest.TestCase):
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`
+ FAILED (convoy-safe boards -- max32666fthr HUNG in the 08-14 run), the `ambiguous`
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 = ''
+ no_settle(self)
def _run(self, stdout, rc=0):
from helper import hil_lock, hil_util
@@ -1869,12 +1458,13 @@ class WedgedBoardCannotReportAPass(unittest.TestCase):
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
+ usbtest's `ambiguous` abort fires 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 = ''
+ no_settle(self)
def _cell(self, js):
"""Returns ('pass', cell) or ('fail', message)."""
@@ -1911,5 +1501,321 @@ class WedgedBoardCannotReportAPass(unittest.TestCase):
self.assertIn('30/30', cell)
+def _gil_stall_available() -> bool:
+ """Whether the hid stub can simulate a GIL-HOLDING stall on this host.
+
+ It needs a libc with sleep(3) loaded through ctypes.PyDLL. Everywhere the HIL harness
+ actually runs that is present; where it is not, the two tests that depend on it skip
+ rather than fail, because their subject is the bound, not ctypes.
+ """
+ import ctypes
+ import ctypes.util
+ try:
+ ctypes.PyDLL(ctypes.util.find_library('c') or 'libc.so.6')
+ return True
+ except OSError:
+ return False
+
+
+class HidEchoRunsInAChild(unittest.TestCase):
+ """hidapi's blocking calls hold the GIL -- cython-hidapi wraps hid_enumerate in
+ `with nogil` but calls hid_open and hid_close bare -- so a daemon thread cannot bound
+ them: the waiter parks off-GIL but must reacquire the GIL to return, which the stuck
+ thread never yields. Only a child process can be killed regardless, which is what
+ run_cmd's killpg does."""
+
+ def _run(self, mode, uid='CAFE01', budget='0', timeout=20, pid=None):
+ saved = {k: os.environ.get(k) for k in ('FAKE_HID_MODE', 'FAKE_HID_UID',
+ 'FAKE_HID_PID', 'PYTHONPATH',
+ 'PYTHONSAFEPATH')}
+
+ def restore():
+ for k, v in saved.items():
+ os.environ.pop(k, None) if v is None else os.environ.__setitem__(k, v)
+ self.addCleanup(restore)
+ os.environ['FAKE_HID_MODE'] = mode
+ os.environ['FAKE_HID_UID'] = uid
+ stubs = os.path.join(TEST_DIR, 'stubs')
+ pp = saved['PYTHONPATH']
+ os.environ['PYTHONPATH'] = stubs if not pp else f'{stubs}:{pp}'
+ # `python3 -c` puts the cwd at sys.path[0], AHEAD of PYTHONPATH, so any hid.py
+ # reachable from the suite's cwd would displace the stub and every mode-driven
+ # test below would pass or fail for the wrong reason. Safe-path mode drops it --
+ # the same practice _MtpFakeRig documents.
+ os.environ['PYTHONSAFEPATH'] = '1'
+ from helper import hil_util
+ want = pid or f'{hil_test.HID_INOUT_PID:#06x}'
+ return hil_util.run_cmd(
+ [sys.executable, '-c', hil_test.HID_ECHO, uid, budget, want],
+ timeout=timeout, split_stderr=True, quiet=True)
+
+ def _stderr(self, r):
+ from helper import hil_util
+ return hil_util.cmd_stdout_text(r.stderr)
+
+ def test_a_healthy_device_passes(self):
+ r = self._run('ok')
+ self.assertEqual(r.returncode, 0, self._stderr(r))
+
+ def test_the_pid_matches_the_example(self):
+ """The walk filters on BOTH ids, and hidapi applies them before the locked
+ manufacturer/product reads. Six examples in this tree expose a HID interface under
+ VID cafe, so a stale PID here silently widens the walk back to all of them -- and
+ nothing else would fail. Pinned against the descriptor rather than restated."""
+ import re
+ src = (Path(TEST_DIR).parents[2]
+ / 'examples/device/hid_generic_inout/src/usb_descriptors.c').read_text()
+ m = re.search(r'#define\s+USB_PID\s+(0x[0-9a-fA-F]+)', src)
+ self.assertIsNotNone(m, 'hid_generic_inout no longer defines USB_PID')
+ self.assertEqual(hil_test.HID_INOUT_PID, int(m.group(1), 16),
+ 'HID_INOUT_PID drifted from the example descriptor')
+
+ def test_a_peer_running_another_example_is_filtered_out(self):
+ """The point of the PID filter: a wedged sibling on a different example never
+ reaches the locked reads at all."""
+ r = self._run('ok', pid='0x400f') # hid_composite, not ours
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('HID device not found', self._stderr(r))
+
+ @unittest.skipUnless(_gil_stall_available(), 'no libc for a GIL-holding stall')
+ def test_a_gil_holding_stall_is_still_killed(self):
+ """THE case an in-process bound cannot cover. hid_open is not `with nogil`, so a
+ thread-based guard is inert there; the child is killed anyway."""
+ t0 = time.monotonic()
+ r = self._run('wedged_open_gil', timeout=2)
+ self.assertEqual(r.returncode, 124,
+ 'a GIL-holding hidapi stall must still be killed on the bound')
+ self.assertLess(time.monotonic() - t0, 20, 'run_cmd did not bound the child')
+
+ def test_a_wedged_enumerate_is_killed_on_the_bound(self):
+ r = self._run('wedged_enumerate', timeout=2)
+ self.assertEqual(r.returncode, 124)
+
+ @unittest.skipUnless(_gil_stall_available(), 'no libc for a GIL-holding stall')
+ def test_a_wedged_close_is_killed_on_the_bound(self):
+ """close() runs in the child's finally on EVERY failure path and is also
+ GIL-holding; hidraw_release takes the same rwsem hidraw_open needs."""
+ r = self._run('wedged_close', timeout=3)
+ self.assertEqual(r.returncode, 124)
+
+ def test_an_absent_device_reports_why(self):
+ r = self._run('absent')
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('HID device not found', self._stderr(r))
+
+ def test_a_bad_echo_reports_both_payloads(self):
+ r = self._run('wrong_data')
+ self.assertNotEqual(r.returncode, 0)
+ msg = self._stderr(r)
+ self.assertIn('wrong data', msg)
+ self.assertIn('sent', msg)
+ self.assertIn('received', msg)
+
+ def test_a_short_echo_is_not_read_as_a_pass(self):
+ r = self._run('short_read')
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('short read', self._stderr(r))
+
+
+class StrayNoteSurvivesTheTupleWidth(unittest.TestCase):
+ """_stray_note reads r[5] -- and three producers build this tuple at three widths, so
+ `len(r) > 5 and r[5]` reads a WRONG SLOT rather than raising if a field is ever
+ inserted. The live handoff pr3840-mret-board-result.md proposes exactly that, and the
+ report would then say "no strays" while probes and usbfs nodes stay held into the next
+ job. The index changed once already in this branch (r[6] -> r[5])."""
+
+ def test_it_names_the_board_and_the_count(self):
+ wide = ('dirty', 1, [], [], 9.0, 2)
+ clean = ('fine', 0, [], [], 8.0, 0)
+ note = hil_test._stray_note([wide, clean])
+ self.assertIn('dirty (2)', note)
+ self.assertIn('2 process(es)', note)
+ self.assertNotIn('fine', note, 'a clean board must not appear in the note')
+
+ def test_a_narrow_row_from_the_timeout_path_is_not_misread(self):
+ """The abort paths synthesise 5-field rows for boards that never reported."""
+ self.assertEqual(hil_test._stray_note([('stuck', 1, [], None, 0)]), '')
+ self.assertEqual(hil_test._stray_note([('fine', 0, [], [], 8.0, 0)]), '')
+
+ def test_the_slot_it_reads_is_the_slot_test_board_writes(self):
+ """Pins the index against the producer, so inserting a field fails HERE rather
+ than silently reporting a duration as a stray count."""
+ 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 == 'test_board')
+ widths = sorted({len(n.value.elts) for n in ast.walk(fn)
+ if isinstance(n, ast.Return) and isinstance(n.value, ast.Tuple)})
+ # the board-LOCKED early return is 5 wide and carries no stray count; the normal
+ # one is 6, with strays last
+ self.assertEqual(widths, [5, 6],
+ 'the result tuple changed width; _stray_note reads index 5')
+
+
+class MixedWidthRowsSurviveTheReportWriters(unittest.TestCase):
+ """_abort_report hands `[(n, 1, [], None, 0) for n in stuck] + [r for r in mret ...]`
+ to both writers -- 5-field synthetic rows mixed with 6-field worker rows. Every other
+ test uses uniform widths, so replacing either `*_` unpack with a fixed-width one keeps
+ the suite green and raises only INSIDE the containment path, where a raise costs every
+ board's results."""
+
+ def _mixed(self):
+ return [('stuck', 1, [], None, 0), # synthetic, 5 wide
+ ('ran', 1, ['device/dfu'],
+ [('ran', {'device/dfu': '❌ boom'}, '8s')], 8.0, 2)] # worker, 6 wide
+
+ def test_the_rerun_spec_accepts_both_widths(self):
+ with TemporaryDirectory() as td:
+ rd = Path(td)
+ hil_test._write_failed_spec(rd / 'c.json.failed', rd, self._mixed())
+ spec = (rd / 'c.json.failed').read_text()
+ self.assertIn('stuck', spec)
+ self.assertIn('ran', spec)
+
+ def test_the_cell_names_the_cause_of_the_abort(self):
+ """A board the pool guard never reached did not "pool-timeout". Marking it so
+ sends whoever reads the table after a guard that never fired."""
+ from helper import hil_report
+ real = hil_report.accumulate_report
+
+ def render(reason, secs):
+ hil_report.accumulate_report = lambda *a, **k: (_ for _ in ()).throw(
+ OSError('report dir unwritable'))
+ try:
+ with TemporaryDirectory() as td:
+ rd = Path(td)
+ hil_test._abort_report(reason, [], [{'name': 'boardA'}],
+ rd / 'c.failed', rd, True, '',
+ timeout_secs=secs)
+ return (rd / hil_report.REPORT_MD).read_text()
+ finally:
+ hil_report.accumulate_report = real
+
+ guard = render('abandoned: worker pool timed out after 3600s', 3600)
+ self.assertIn(hil_report.POOL_TIMEOUT_CELL, guard)
+ raised = render('aborted: a worker raised ValueError: x', None)
+ self.assertIn(hil_report.RUN_ABORTED_CELL, raised)
+ self.assertNotIn(hil_report.POOL_TIMEOUT_CELL, raised,
+ 'a run that aborted on a raise is not a pool timeout')
+ # and the fallback must still fire on BOTH paths -- that is what it is for
+ for md in (guard, raised):
+ self.assertIn('boardA', md)
+
+ def test_only_the_rerun_spec_sees_the_synthetic_rows(self):
+ """accumulate_report gets `mret` alone -- worker rows, always 4th field a real
+ list. Widening _abort_report to hand it the synthetic list too would crash the
+ containment path: those rows carry rows=None and render_matrix iterates it."""
+ 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 == '_abort_report')
+ calls = {ast.unparse(n.func): ast.unparse(n)
+ for n in ast.walk(fn) if isinstance(n, ast.Call)
+ and ast.unparse(n.func).endswith(('_write_failed_spec',
+ 'accumulate_report'))}
+ self.assertEqual(
+ ast.unparse(ast.parse(calls['hil_report.accumulate_report']).body[0]
+ ).split('(', 1)[1].split(',')[0], 'mret',
+ 'accumulate_report must receive worker rows only -- the synthetic rows carry '
+ 'rows=None and render_matrix iterates that field')
+ self.assertIn('stuck', calls['_write_failed_spec'],
+ 'the re-run spec must still name the boards that never reported')
+
+
+class UsbtestAbsentDeviceVerdict(unittest.TestCase):
+ """The arm that fails BEFORE usbtest_permit: an absent device must not queue on the
+ battery mutex for minutes just to have usbtest.py report "no device", and the cell
+ needs the 0/30 denominator or the row reads as a bare failure."""
+
+ def setUp(self):
+ self.addCleanup(setattr, hil_test, 'board_wedged', hil_test.board_wedged)
+ hil_test.board_wedged = ''
+ no_settle(self)
+ from helper import hil_lock, hil_util
+ self.addCleanup(setattr, hil_util, 'usb_scan', hil_util.usb_scan)
+ hil_util.usb_scan = lambda **k: [] # a readable bus, no such device
+ self.addCleanup(setattr, hil_test, '_enum_timeout', hil_test._enum_timeout)
+ hil_test._enum_timeout = 0
+ self.addCleanup(setattr, hil_lock, 'usbtest_permit', hil_lock.usbtest_permit)
+ from contextlib import contextmanager
+
+ def boom(uid):
+ raise AssertionError('took the battery permit for an absent device')
+ yield
+ hil_lock.usbtest_permit = contextmanager(boom)
+
+ def test_a_readable_bus_without_the_device_says_absent_with_a_denominator(self):
+ with self.assertRaises(hil_test.TestFail) as cm:
+ hil_test.test_device_usbtest({'name': 'b', 'uid': 'NOPE',
+ 'flasher': {'name': 'stlink', 'uid': 'X'}})
+ self.assertIn('no cafe:4010 device', str(cm.exception))
+ self.assertIn('0/30', cm.exception.metric)
+
+ def test_a_scan_that_gave_up_says_could_not_tell_instead(self):
+ """The conflation this whole path exists to avoid: an unreadable DUT is not an
+ absent one, and the bare string sends a maintainer after a firmware regression on
+ hardware that is merely wedged."""
+ from helper import hil_util
+ self.addCleanup(setattr, hil_util, '_ever_stranded', hil_util._ever_stranded)
+ hil_util._ever_stranded = True
+ with self.assertRaises(hil_test.TestFail) as cm:
+ hil_test.test_device_usbtest({'name': 'b', 'uid': 'NOPE',
+ 'flasher': {'name': 'stlink', 'uid': 'X'}})
+ self.assertIn('could not tell', str(cm.exception))
+
+
+class UsbtestStartupDoesNotClaimAbsenceBlind(unittest.TestCase):
+ """usbtest.py's own startup lookup, the sibling of the arm above. hil_test relays its
+ stderr verbatim into the report cell, so a positive 'no cafe:4010 device' from a scan
+ that gave up is the same conflation one process further out. Structural because the
+ exit sits mid-main(), behind argparse and the testusb probe."""
+
+ def test_the_sysfs_backed_absence_claims_carry_the_note(self):
+ """Both claims that a bounded read can turn into a false absence. The printer one
+ was missed: read_sysfs folds a timed-out `serial` into None, so a wedged-but-
+ enumerated printer read as 'Printer device not found' -- an enumeration verdict for
+ hardware that is merely unreadable. The MIDI lookup is deliberately NOT here: it
+ globs /dev/snd/by-id and readlinks it, so no bounded read can blind it."""
+ import ast
+ tree = ast.parse(Path(hil_test.__file__).read_text())
+ claims = [ast.unparse(n) for n in ast.walk(tree)
+ if isinstance(n, (ast.Assert, ast.Raise))
+ and ('Printer device not found' in ast.unparse(n)
+ or 'no cafe:4010 device' in ast.unparse(n))]
+ self.assertEqual(len(claims), 2, 'a sysfs-backed absence claim moved or was added')
+ for c in claims:
+ self.assertIn('strand_note', c, f'absence claimed without the note: {c[:70]}')
+
+ def test_the_absence_exit_carries_the_stranded_caveat(self):
+ import ast
+ import usbtest
+ tree = ast.parse(Path(usbtest.__file__).read_text())
+ exits = [n for n in ast.walk(tree)
+ if isinstance(n, ast.Call) and ast.unparse(n.func) == 'sys.exit'
+ and 'no {VID}:{PID} device' in ast.unparse(n)]
+ self.assertEqual(len(exits), 1, 'the absence exit moved; retarget this test')
+ self.assertIn('strand_note', ast.unparse(exits[0]),
+ 'usbtest claims absence without consulting sysfs_stranded()')
+
+
+class UsbtestGlobalCleanupStaysProcessWide(unittest.TestCase):
+ """The strand flag has TWO consumers at different scopes. The per-case verdict is
+ per-DUT -- a peer that stranded must not make OUR board report wedged. But the finally
+ block's cleanup is GLOBAL: remove_id plus an unbind of every interface under the
+ usbtest driver, including that peer's. Those writes take the uninterruptible
+ device_lock, so the global path has to stay gated on the process-wide question."""
+
+ def test_the_global_unbind_consults_the_process_wide_flag(self):
+ import ast
+ import usbtest
+ tree = ast.parse(Path(usbtest.__file__).read_text())
+ fins = [n for n in ast.walk(tree) if isinstance(n, ast.Try) and n.finalbody
+ and 'remove_id' in ast.unparse(ast.Module(body=n.finalbody, type_ignores=[]))]
+ self.assertEqual(len(fins), 1, 'the cleanup finally moved; retarget this test')
+ body = ast.unparse(ast.Module(body=fins[0].finalbody, type_ignores=[]))
+ self.assertIn('sysfs_stranded', body,
+ 'global remove_id/unbind runs without the process-wide strand gate')
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/test/hil/test/test_hil_health.py b/test/hil/test/test_hil_health.py
index c7864f568..ceecc8d37 100644
--- a/test/hil/test/test_hil_health.py
+++ b/test/hil/test/test_hil_health.py
@@ -515,51 +515,54 @@ class PermitReleasesOnlyWhatItTook(unittest.TestCase):
'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."""
+class RecoveryUsesAResetOnlyWhenThereIsARealOne(unittest.TestCase):
+ """usbtest's recovery runs the reset unconditionally before the reflash -- it is
+ non-destructive (the wedged firmware survives for autopsy), writes no flash, cannot
+ brick SWD the way a bad park image has (mimxrt1064_evk, max32666fthr), and is measured
+ at 128-129 ms against a full erase+program.
+
+ Two things still gate it, and both are what this pins: a flasher may have no reset
+ primitive at all, and reset_esptool/reset_lm4flash return rc 0 WITHOUT resetting
+ anything. Running those makes the log say "resetting <board> via <flasher>" for a step
+ that did nothing. wedged_pids() arbitrates either way, so behaviour was always right --
+ the record was not, and a false record is what keeps having to be unpicked."""
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'])
+ # PRODUCTION, not a copy: re-implementing the screen here let the real gate be
+ # deleted with the suite still green, which is the failure mode this pins.
+ self._reset_fn = usbtest.reset_primitive
- def test_no_budget_at_all_yields_nothing(self):
- self.assertEqual(self.u.recovery_steps('openocd', time_left=1), [])
+ def test_a_stub_that_resets_nothing_is_not_claimed(self):
+ for name in ('esptool', 'lm4flash'):
+ self.assertIsNone(self._reset_fn(name),
+ f'reset_{name} returns rc 0 without resetting; claiming it '
+ f'puts a step that did nothing in the record')
- 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'])
+ def test_a_real_reset_primitive_is_used(self):
+ for name in ('openocd', 'jlink', 'stlink'):
+ self.assertIsNotNone(self._reset_fn(name))
+ def test_a_flasher_with_no_reset_primitive_goes_straight_to_the_reflash(self):
+ self.assertIsNone(self._reset_fn('nosuchflasher'))
-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):
+ def test_the_reset_is_attempted_before_the_reflash(self):
+ """Order matters and now lives only in main()'s inline ladder, where no test
+ reaches it -- swapping the two blocks kept the suite green. Reset first is
+ non-destructive: the firmware under test survives for autopsy, no flash is
+ written, and it cannot brick SWD the way a bad park image has on mimxrt1064_evk
+ and max32666fthr."""
+ import ast
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'])
+ src = Path(usbtest.__file__).read_text()
+ fn = next(n for n in ast.walk(ast.parse(src))
+ if isinstance(n, ast.FunctionDef) and n.name == 'main')
+ seg = ast.get_source_segment(src, fn)
+ reset_at = seg.index('reset_fn = reset_primitive(')
+ flash_at = seg.index("flash_fn(board, args.recover_fw")
+ self.assertLess(reset_at, flash_at,
+ 'the reflash is attempted before the non-destructive reset')
- 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):
diff --git a/test/hil/test/test_hil_report.py b/test/hil/test/test_hil_report.py
index 9ab39bde3..7c7a097ef 100644
--- a/test/hil/test/test_hil_report.py
+++ b/test/hil/test/test_hil_report.py
@@ -962,6 +962,17 @@ class PoolTimeoutOutranksAStaleLock(unittest.TestCase):
self.assertFalse(r['locked'], 'a wedge was published as lock contention')
self.assertFalse(r['pass'])
+ def test_a_run_aborted_board_is_not_published_as_lock_contention(self):
+ """run-aborted is written by the same _abort_report path as pool-timeout, for a
+ board the guard never reached. It has to outrank a stale lock cell for the same
+ reason -- otherwise hil-validate.js re-runs a board whose worker RAISED."""
+ doc = {'rows': [{'board': 'boardX',
+ 'cells': {'board-locked': 'fail', 'run-aborted': 'fail'},
+ 'duration': None}], 'banner': '', 'caveat': '', 'scope': ''}
+ r = hil_report.summarize({'boards': [{'name': 'boardX'}]}, ['boardX'], doc)['results'][0]
+ self.assertFalse(r['locked'], 'an aborted run was published as lock contention')
+ self.assertFalse(r['pass'])
+
class NoBoardsExitRespectsFreshness(unittest.TestCase):
def test_a_fresh_run_does_not_republish_the_previous_rows(self):
diff --git a/test/hil/test/test_hil_rtt.py b/test/hil/test/test_hil_rtt.py
new file mode 100644
index 000000000..3a07f13ec
--- /dev/null
+++ b/test/hil/test/test_hil_rtt.py
@@ -0,0 +1,506 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for hil_util.JlinkRtt and the rtt.py CLI against a fake JLinkExe
+# on PATH — real subprocesses and sockets, no hardware, 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_rtt.py
+import os
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from contextlib import suppress as contextlib_suppress
+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
+
+CLI = Path(__file__).resolve().parents[3] / 'tools' / 'rtt.py'
+
+# Serves -RTTTelnetPort like J-Link Commander: greets, echoes input uppercased, exits on
+# stdin 'exit' (JlinkRtt.close()'s contract). FAKE_JLINK_MODE=die_after_greet sends the
+# greeting then drops the connection and exits — the probe-unplug/crash case;
+# FAKE_JLINK_MODE=tick also streams a line every 50 ms — the continuous-capture case.
+FAKE_JLINK = '''#!/usr/bin/env python3
+import os, socket, sys, threading, time
+port = int(sys.argv[sys.argv.index('-RTTTelnetPort') + 1])
+srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+srv.bind(('127.0.0.1', port)); srv.listen(1)
+mode = os.environ.get('FAKE_JLINK_MODE', '')
+def serve():
+ conn, _ = srv.accept()
+ # the real server sends its banner AT CONNECT, before the control block is
+ # found — target data only flows later; the CLI's -i gate must not release
+ # on the banner
+ conn.sendall(b'SEGGER J-Link fake - Real time terminal output\\r\\n'
+ b'J-Link FakeProbe V1.0, SN=000\\r\\nProcess: JLinkExe\\r\\n')
+ if mode == 'banner_only':
+ while True:
+ if not conn.recv(4096): os._exit(0)
+ if mode == 'rst':
+ import struct
+ conn.recv(4096) # wait for the client to speak, then reset the connection
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
+ conn.close(); os._exit(0)
+ if mode == 'late_cb':
+ # models JLinkExe before it finds the control block: client bytes sent in
+ # this window are silently dropped, output starts only after the "attach"
+ end = time.time() + 1.0
+ conn.setblocking(False)
+ while time.time() < end:
+ try:
+ conn.recv(4096) # discard early input like the real server
+ except OSError:
+ pass
+ time.sleep(0.05)
+ conn.setblocking(True)
+ conn.sendall(b'hello from target\\r\\n')
+ if mode == 'die_after_greet':
+ conn.close(); os._exit(0)
+ if mode == 'tick':
+ def tick():
+ try:
+ while True:
+ time.sleep(0.05); conn.sendall(b'tick\\r\\n')
+ except OSError:
+ pass
+ threading.Thread(target=tick, daemon=True).start()
+ while True:
+ d = conn.recv(4096)
+ if not d: return
+ conn.sendall(d.upper())
+threading.Thread(target=serve, daemon=True).start()
+for line in sys.stdin:
+ if line.strip() == 'exit': break
+'''
+
+BOARD = {'flasher': {'uid': '000', 'args': '-device FAKE'}}
+
+
[email protected](os.name == 'nt', 'POSIX PATH/exec semantics')
+class JlinkRttFakeProbe(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls._dir = tempfile.TemporaryDirectory()
+ fake = Path(cls._dir.name) / 'JLinkExe'
+ fake.write_text(FAKE_JLINK)
+ fake.chmod(0o755)
+ cls._path = f'{cls._dir.name}{os.pathsep}{os.environ["PATH"]}'
+
+ @classmethod
+ def tearDownClass(cls):
+ cls._dir.cleanup()
+
+ def _fake_path(self):
+ # register the restore BEFORE mutating, then prepend the fake tool dir
+ self.addCleanup(os.environ.__setitem__, 'PATH', os.environ['PATH'])
+ os.environ['PATH'] = self._path
+
+ def _console(self, mode=''):
+ self._fake_path()
+ if mode:
+ os.environ['FAKE_JLINK_MODE'] = mode
+ self.addCleanup(os.environ.pop, 'FAKE_JLINK_MODE', None)
+ con = hil_util.JlinkRtt(BOARD, timeout=0.1)
+ self.addCleanup(con.close)
+ return con
+
+ def _read_until(self, con, want, timeout=3):
+ out = b''
+ end = time.monotonic() + timeout
+ while want not in out and time.monotonic() < end:
+ out += con.read(con.in_waiting or 1)
+ return out
+
+ def test_read_and_echo_write(self):
+ con = self._console()
+ self.assertIn(b'hello from target', self._read_until(con, b'hello from target'))
+ self.assertEqual(con.write(b'ping'), 4)
+ self.assertIn(b'PING', self._read_until(con, b'PING'))
+
+ def test_eof_latched_when_server_dies(self):
+ con = self._console(mode='die_after_greet')
+ self._read_until(con, b'hello from target')
+ end = time.monotonic() + 3
+ while not con.eof and time.monotonic() < end:
+ time.sleep(0.05)
+ self.assertTrue(con.eof) # dead server is detected, not spun on
+ t0 = time.monotonic()
+ self.assertEqual(con.read(64), b'') # empty, paced like a serial timeout
+ elapsed = time.monotonic() - t0
+ self.assertLess(elapsed, 0.5) # bounded by the 0.1 s timeout, not hung
+ self.assertGreater(elapsed, 0.02) # ...but not a busy-spin fast return
+ con.timeout = None # pyserial's block-forever mode must
+ t0 = time.monotonic() # ALSO pace (0.1 s default), not spin
+ self.assertEqual(con.read(64), b'')
+ elapsed = time.monotonic() - t0
+ self.assertLess(elapsed, 0.5)
+ self.assertGreater(elapsed, 0.02)
+ con.timeout = 0.1
+
+ def test_reset_input_buffer(self):
+ con = self._console()
+ self._read_until(con, b'hello from target')
+ con.write(b'x')
+ time.sleep(0.3)
+ con.reset_input_buffer()
+ self.assertEqual(con.in_waiting, 0)
+
+ def test_write_after_close_raises_runtimeerror(self):
+ con = self._console()
+ con.close()
+ with self.assertRaises(RuntimeError):
+ con.write(b'x')
+
+ def test_write_after_server_death_raises(self):
+ # TCP accepts one send after peer death — write() must refuse instead of
+ # "succeeding" into the void
+ con = self._console(mode='die_after_greet')
+ self._read_until(con, b'hello from target')
+ end = time.monotonic() + 3
+ while not con.eof and time.monotonic() < end:
+ time.sleep(0.05)
+ with self.assertRaises(RuntimeError):
+ con.write(b'ping')
+
+ def test_read_after_close_raises_runtimeerror(self):
+ con = self._console()
+ self._read_until(con, b'hello from target')
+ con.close()
+ with self.assertRaises(RuntimeError):
+ con.read(1)
+
+ def test_missing_jlinkexe_raises_runtimeerror(self):
+ self._fake_path()
+ os.environ['PATH'] = self._dir.name # no python3 either, but JLinkExe fails first
+ os.rename(f'{self._dir.name}/JLinkExe', f'{self._dir.name}/JLinkExe.off')
+ self.addCleanup(os.rename, f'{self._dir.name}/JLinkExe.off', f'{self._dir.name}/JLinkExe')
+ with self.assertRaises(RuntimeError):
+ hil_util.JlinkRtt(BOARD, timeout=0.1)
+
+ def test_close_reaps_the_server(self):
+ con = self._console()
+ proc = con._proc
+ con.close()
+ self.assertIsNotNone(proc.poll()) # no zombie, no probe held
+
+ def test_cli_exits_when_server_dies(self):
+ # --seconds 0 must end on server EOF (rc 1), not hang forever
+ env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='die_after_greet')
+ r = subprocess.run([sys.executable, str(CLI),
+ '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '0'],
+ env=env, capture_output=True, timeout=20)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(b'hello from target', r.stdout)
+ self.assertIn(b'server closed', r.stderr)
+
+ def test_peer_reset_latches_eof(self):
+ # a killed server closes with RST when bytes are unread; the read side must
+ # LATCH eof (so the harness's `assert not ser.eof` triage fires) and never
+ # leak ConnectionResetError/ValueError to in_waiting/eof callers
+ con = self._console(mode='rst')
+ # rst mode sends only the banner (it RSTs on first input) -- wait for the
+ # banner tail, not target output that never comes
+ self._read_until(con, b'Process: JLinkExe')
+ con.write(b'x') # fake resets the connection on input
+ end = time.monotonic() + 3
+ try:
+ while not con.eof and time.monotonic() < end:
+ con.in_waiting # must not raise across the RST
+ time.sleep(0.05)
+ except Exception as e: # noqa: BLE001 - the regression this guards
+ self.fail(f'{type(e).__name__} escaped the latch-only contract: {e}')
+ self.assertTrue(con.eof)
+ with self.assertRaises(hil_util.RttError):
+ con.write(b'y') # dead server refuses writes
+
+ def test_write_timeout_env_rejects_inf(self):
+ # hil_util's twin rejects inf for the same reason: an unbounded write is what
+ # this knob exists to bound
+ import importlib.util as ilu
+ from pathlib import Path as _P
+ spec = ilu.spec_from_file_location('rtt_env_probe', _P(CLI))
+ mod = ilu.module_from_spec(spec)
+ old = os.environ.get('HIL_SERIAL_WRITE_TIMEOUT')
+ os.environ['HIL_SERIAL_WRITE_TIMEOUT'] = 'inf'
+ self.addCleanup(lambda: os.environ.__setitem__('HIL_SERIAL_WRITE_TIMEOUT', old)
+ if old is not None else os.environ.pop('HIL_SERIAL_WRITE_TIMEOUT', None))
+ spec.loader.exec_module(mod)
+ self.assertEqual(mod.RTT_WRITE_TIMEOUT, 10)
+
+ def test_cli_rejects_bad_seconds_and_jlink_channel(self):
+ def run(*a):
+ return subprocess.run([sys.executable, str(CLI), *a], capture_output=True, timeout=15)
+ for bad in ('-5', 'nan'):
+ r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', bad)
+ self.assertEqual(r.returncode, 2, f'--seconds {bad} was accepted')
+ # the jlink telnet route serves channel 0 only; asking for another is an error,
+ # not silence (--dump can read any ring, so it stays allowed there)
+ r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--channel', '1')
+ self.assertEqual(r.returncode, 2)
+ self.assertIn(b'channel 0 only', r.stderr)
+ # a negative index would walk backwards off aUp[] (dump route included)
+ r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--channel', '-1')
+ self.assertEqual(r.returncode, 2)
+ self.assertIn(b'>= 0', r.stderr)
+
+ def test_pyserial_surface_contracts(self):
+ con = self._console()
+ self._read_until(con, b'hello from target')
+ con.write(b'abcdef')
+ self._read_until(con, b'ABC') # echo queued
+ before = con.in_waiting
+ self.assertEqual(con.read(0), b'') # pyserial: consumes nothing
+ self.assertEqual(con.read(-1), b'') # never hand over/destroy bytes
+ self.assertEqual(con.in_waiting, before)
+ con.timeout = None # pyserial: block until satisfied
+ con.write(b'xy') # fresh echo guarantees the read returns
+ self.assertEqual(len(con.read(2)), 2)
+ con.timeout = 0.1
+ con.close()
+ with self.assertRaises(hil_util.RttError):
+ con.in_waiting # closed console reports closed, not healthy
+ self.assertTrue(con.eof)
+
+ def test_context_manager_closes(self):
+ self._fake_path()
+ with hil_util.JlinkRtt(BOARD, timeout=0.1) as con:
+ proc = con._proc
+ self.assertIsNotNone(proc.poll()) # __exit__ released the probe
+
+ def test_staging_and_banner_coupling(self):
+ # tripwires for couplings no import-walk can see:
+ # (a) hil_ci.sh must stage tools/rtt.py -- hil_util exec_module's it, so an
+ # unstaged rig tree kills every harness import
+ hil_ci = (Path(__file__).resolve().parents[1] / 'hil_ci.sh').read_text()
+ self.assertIn('tools/rtt.py', hil_ci)
+ # (b) the shared RTT banner filter must drop ALL THREE J-Link banner lines,
+ # including the middle one, which is the PROBE MODEL string and in
+ # libjlinkarm carries no 'SEGGER ' prefix (J-Link OH3, J-Trace H9...)
+ banner_re = hil_util.RTT_BANNER_RE
+ for line in ('SEGGER J-Link V9.66 - Real time terminal output',
+ 'SEGGER J-Link LPC-Link 2 V1.0, SN=611000000',
+ 'J-Link OH3 V1.0, SN=123456789',
+ 'J-Trace H9 V2.0, SN=123456789002',
+ 'Process: JLinkExe'):
+ self.assertTrue(banner_re.match(line), f'banner line not filtered: {line!r}')
+ for line in ('Hello from TinyUSB', 'USBD init on controller 0',
+ 'ID 1a86:8010 SN 7FD88F0604B5', 'echo:p'):
+ self.assertFalse(banner_re.match(line), f'target line wrongly filtered: {line!r}')
+
+ def test_pool_check_dead_rtt_board_is_not_alive(self):
+ # JLinkExe's banner alone must not score a dead board 'alive': pool_check's
+ # rtt aliveness judges only target bytes (the bug: unfiltered, the banner
+ # made `not boardtest_output(data)` true on the first poll)
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ from helper import hil_pool_check
+ # a dead board burns the whole poll window; the verdict is the same at 0.5 s
+ self.addCleanup(setattr, hil_pool_check, 'SERIAL_WAIT', hil_pool_check.SERIAL_WAIT)
+ hil_pool_check.SERIAL_WAIT = 0.5
+ self._fake_path()
+ os.environ['FAKE_JLINK_MODE'] = 'banner_only'
+ self.addCleanup(os.environ.pop, 'FAKE_JLINK_MODE', None)
+ board = dict(BOARD, name='deadboard', logger='rtt')
+ got = hil_pool_check.check_host_serial(board, do_reset=False, want_hello=True)
+ self.assertEqual(got, b'') # dead, not "alive on banner"
+
+ def test_cli_arg_contract(self):
+ # --backend is explicit (no default); vid-pid is openocd-only; the openocd
+ # backend accepts --addr instead of --elf and --vid-pid instead of --probe
+ def run(*a, inp=b''):
+ return subprocess.run([sys.executable, str(CLI), *a],
+ input=inp, capture_output=True, timeout=15)
+ r = run('--probe', '000', '--device', 'FAKE') # no --backend
+ self.assertEqual(r.returncode, 2)
+ self.assertIn(b'--backend', r.stderr)
+ r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--vid-pid', '0x1 0x2')
+ self.assertEqual(r.returncode, 2) # vid-pid is openocd-only
+ r = run('--backend', 'openocd', '--cfg', '-f x.cfg', '--addr', '0x20000000')
+ self.assertEqual(r.returncode, 2) # needs --probe or --vid-pid
+ self.assertIn(b'vid-pid', r.stderr)
+ r = run('--backend', 'openocd', '--probe', '000', '--cfg', '-f x.cfg', '--addr', 'nothex')
+ self.assertEqual(r.returncode, 2)
+ self.assertIn(b'hex', r.stderr)
+
+ def test_cli_interactive_echo(self):
+ env = dict(os.environ, PATH=self._path)
+ r = subprocess.run([sys.executable, str(CLI),
+ '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '2', '-i'],
+ env=env, input=b'hi', capture_output=True, timeout=20)
+ self.assertEqual(r.returncode, 0)
+ self.assertIn(b'HI', r.stdout) # bytes forwarded without needing a newline
+ self.assertNotIn(b'never forwarded', r.stderr) # forwarding happened: no false alarm
+
+ def test_cli_interactive_input_held_until_output(self):
+ # input piped at process start must survive the server's control-block hunt
+ # (the real JLinkExe drops client bytes until the block is found — measured
+ # on the rig: instant 'ping' lost, delayed 'ping' echoed)
+ env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='late_cb')
+ r = subprocess.run([sys.executable, str(CLI),
+ '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '3', '-i'],
+ env=env, input=b'hi', capture_output=True, timeout=25)
+ self.assertEqual(r.returncode, 0)
+ self.assertIn(b'HI', r.stdout)
+
+ def test_cli_interactive_no_input_diagnostic(self):
+ # -i with stdin closed immediately: the diagnostic must say stdin was never
+ # forwarded (true), keyed on actual forwarding -- not on the attach gate,
+ # which releases after 5 s and forwards anyway on longer runs
+ env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='banner_only')
+ r = subprocess.run([sys.executable, str(CLI),
+ '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '1', '-i'],
+ env=env, input=b'', capture_output=True, timeout=20)
+ self.assertEqual(r.returncode, 0)
+ self.assertIn(b'never forwarded', r.stderr)
+ self.assertIn(b'no target output', r.stderr)
+
+ def test_cli_downstream_pipe_close(self):
+ # a real `rtt.py | head`-style consumer: close the read end mid-stream
+ # and the CLI must exit 0 via its BrokenPipe path, not traceback (this test
+ # fails if the handler is removed — subprocess.run capture can't cover it)
+ env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='tick')
+ p = subprocess.Popen([sys.executable, str(CLI),
+ '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '8'],
+ env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ p.stdout.read(10) # let it stream a little
+ p.stdout.close() # downstream hangs up
+ rc = p.wait(timeout=20)
+ err = p.stderr.read()
+ p.stderr.close()
+ self.assertEqual(rc, 0, err)
+ self.assertNotIn(b'Traceback', err)
+
+ def test_cli_feeder_races_shutdown(self):
+ # a feeder still writing when --seconds expires must not crash the CLI
+ # (pump thread vs close() race: historically tracebacks and SIGABRT rc 134)
+ env = dict(os.environ, PATH=self._path)
+ for _ in range(3):
+ p = subprocess.Popen([sys.executable, str(CLI),
+ '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '1', '-i'],
+ env=env, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE)
+ try:
+ while True:
+ p.stdin.write(b'hi\n')
+ p.stdin.flush()
+ time.sleep(0.01)
+ except (BrokenPipeError, OSError):
+ pass
+ rc = p.wait(timeout=20)
+ err = p.stderr.read()
+ p.stderr.close()
+ with contextlib_suppress(OSError, ValueError):
+ p.stdin.close()
+ self.assertEqual(rc, 0, err)
+ self.assertNotIn(b'Exception in thread', err)
+
+
+
+class StripBanner(unittest.TestCase):
+ # both harness consumers (device_info verdict, pool_check aliveness) judge
+ # target-aliveness through this ONE filter -- pin its shape here
+ def test_drops_banner_keeps_target(self):
+ raw = (b'SEGGER J-Link V9.66 - Real time terminal output\r\n'
+ b'J-Link OH3 V1.0, SN=123456789\r\nProcess: JLinkExe\r\n'
+ b'Hello from TinyUSB\r\n')
+ self.assertEqual(hil_util.strip_banner(raw), b'Hello from TinyUSB')
+
+ def test_complete_only_drops_split_banner_fragment(self):
+ # a poll loop can catch the banner mid-line at a read boundary; the
+ # fragment must not defeat the prefix regex and score as target output
+ frag = b'SEGGER J-Link V9.66 - Real time terminal output\r\nProce'
+ self.assertEqual(hil_util.strip_banner(frag, complete_only=True), b'')
+ # the final verdict keeps a genuine unterminated target tail
+ self.assertEqual(hil_util.strip_banner(b'tud_task\r\nrunn'), b'tud_task\nrunn')
+ self.assertEqual(hil_util.strip_banner(b'', complete_only=True), b'')
+
+
+# Serves like `openocd ... -c "rtt server start PORT CH"`: parses the port from its
+# single shell-quoted command line, greets, echoes uppercased. No banner (matches the
+# real openocd rtt server, which sends target data only).
+FAKE_OPENOCD = '''#!/usr/bin/env python3
+import os, re, socket, sys, threading, time
+if os.environ.get('FAKE_OPENOCD_ARGV'):
+ open(os.environ['FAKE_OPENOCD_ARGV'], 'w').write(' '.join(sys.argv))
+port = int(re.search(r'rtt server start (\\d+)', ' '.join(sys.argv)).group(1))
+srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+srv.bind(('127.0.0.1', port)); srv.listen(1)
+conn, _ = srv.accept()
+conn.sendall(b'hello from target\\r\\n')
+while True:
+ d = conn.recv(4096)
+ if not d: break
+ conn.sendall(d.upper())
+'''
+
+
[email protected](os.name == 'nt', 'POSIX PATH/exec semantics')
+class OpenocdRttFakeProbe(unittest.TestCase):
+ """The openocd-backend class shares its whole read/write/eof contract with
+ JlinkRtt via the base class (covered above); this exercises the parts it owns:
+ spawn/connect, echo round-trip, teardown."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls._dir = tempfile.TemporaryDirectory()
+ fake = Path(cls._dir.name) / 'openocd'
+ fake.write_text(FAKE_OPENOCD)
+ fake.chmod(0o755)
+ cls._path = f'{cls._dir.name}{os.pathsep}{os.environ["PATH"]}'
+
+ @classmethod
+ def tearDownClass(cls):
+ cls._dir.cleanup()
+
+ def _fake_path(self):
+ self.addCleanup(os.environ.__setitem__, 'PATH', os.environ['PATH'])
+ os.environ['PATH'] = self._path
+
+ def test_reset_before_attach_shapes_the_command(self):
+ # SystemView-style consumers need the server draining WHEN the target boots
+ # (its Init record is emitted once); the opt-in flag must put `reset run`
+ # between init and rtt setup, and must not appear otherwise
+ self._fake_path()
+ argv_file = os.path.join(self._dir.name, 'argv.txt')
+ os.environ['FAKE_OPENOCD_ARGV'] = argv_file
+ self.addCleanup(os.environ.pop, 'FAKE_OPENOCD_ARGV', None)
+ for flag, want in ((True, True), (False, False)):
+ con = hil_util.OpenocdRtt('-f fake.cfg', 0x20000000, 1, serial_no='000',
+ reset_before_attach=flag)
+ try:
+ argv = Path(argv_file).read_text()
+ finally:
+ con.close()
+ self.assertEqual('reset run' in argv, want, argv)
+ if want: # ordering is the whole point: reset, settle, THEN attach
+ self.assertLess(argv.index('reset run'), argv.index('rtt setup'), argv)
+ self.assertIn('sleep 2000', argv)
+ self.assertIn('rtt server start', argv)
+ self.assertTrue(argv.rstrip().endswith('1'), argv) # channel threaded through
+
+ def test_openocd_route_echo_and_teardown(self):
+ self._fake_path()
+ con = hil_util.OpenocdRtt('-f fake.cfg', 0x20000000, 0,
+ serial_no='000', vid_pid='0x1234 0x5678')
+ self.addCleanup(con.close)
+ out = b''
+ end = time.monotonic() + 3
+ while b'hello from target' not in out and time.monotonic() < end:
+ out += con.read(con.in_waiting or 1)
+ self.assertIn(b'hello from target', out)
+ con.write(b'ping')
+ end = time.monotonic() + 3
+ while b'PING' not in out and time.monotonic() < end:
+ out += con.read(con.in_waiting or 1)
+ self.assertIn(b'PING', out)
+ proc = con._proc
+ con.close()
+ self.assertIsNotNone(proc.poll()) # no zombie, no probe held
+ with self.assertRaises(RuntimeError):
+ con.write(b'x') # same post-close contract as JlinkRtt
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/hil/test/test_hil_util.py b/test/hil/test/test_hil_util.py
index c95e20b6d..17abe52aa 100644
--- a/test/hil/test/test_hil_util.py
+++ b/test/hil/test/test_hil_util.py
@@ -6,11 +6,11 @@
# python3 test/hil/test/test_hil_util.py
import io
import os
-import shutil
-import tempfile
import sys
import time
+import threading
import unittest
+from tempfile import TemporaryDirectory
from contextlib import redirect_stdout
from pathlib import Path
@@ -19,7 +19,6 @@ 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')
@@ -146,9 +145,13 @@ class BottomLayer(unittest.TestCase):
# 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
+ # ../../tools/rtt: hil_util exec_module's it at import (helper/hil_util.py's
+ # loader block), so a non-stdlib import THERE kills ci_select on the bare
+ # runner just as surely -- and the spec_from_file_location call is invisible to
+ # the ast.Import walk below, which is why it must be listed explicitly
for mod in ('helper/hil_util', 'hil_flash', '../../tools/ci_select',
'helper/hil_health', 'helper/hil_lock', 'helper/hil_pool_check',
- '../../tools/build', '../../tools/build_utils'):
+ '../../tools/build', '../../tools/build_utils', '../../tools/rtt'):
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
@@ -164,52 +167,6 @@ class BottomLayer(unittest.TestCase):
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
@@ -227,5 +184,265 @@ class RunAlongsideKeepsStderrOffThePayload(unittest.TestCase):
'child stderr leaked into the payload stream')
+class RunCmdCleanupShape(unittest.TestCase):
+ """run_cmd's two cleanup paths, asserted structurally.
+
+ Both must kill the process GROUP: start_new_session puts the child in its own group, so
+ a flasher run through a shell keeps children a p.kill() cannot reach, and on the
+ BaseException path the child never receives the terminal's SIGINT either.
+
+ Structural rather than behavioural on purpose. Driving a real SIGINT into a blocked
+ communicate() from a unit test is timing-dependent, and a flaky guard on this block is
+ worse than none -- while what actually breaks it is an edit that rebinds a branch. Both
+ times this block has been mis-edited, an `else:` ended up attached to the `try` instead
+ of the `if` it belonged to, so `p.kill()` ran when killpg had SUCCEEDED and its
+ ProcessLookupError masked the caller's exception. That is a shape, and shapes are
+ exactly what an AST can pin.
+ """
+
+ def _run_cmd_ast(self):
+ import ast
+ src = Path(hil_util.__file__).read_text()
+ return next(n for n in ast.walk(ast.parse(src))
+ if isinstance(n, ast.FunctionDef) and n.name == 'run_cmd')
+
+ def test_no_cleanup_try_has_an_else(self):
+ import ast
+ for n in ast.walk(self._run_cmd_ast()):
+ if isinstance(n, ast.Try) and n.orelse:
+ self.fail(f'try/else at line {n.lineno}: an else here runs when the kill '
+ f'SUCCEEDED, and its ProcessLookupError masks the caller\'s '
+ f'exception -- this block has been mis-edited that way twice')
+
+ def test_both_cleanup_paths_kill_the_group(self):
+ import ast
+ fn = self._run_cmd_ast()
+ killers = [getattr(c.func, 'attr', '') for c in ast.walk(fn)
+ if isinstance(c, ast.Call) and getattr(c.func, 'attr', '') in
+ ('killpg', 'kill')]
+ self.assertEqual(killers.count('killpg'), 2,
+ 'both the timeout and the BaseException path must killpg')
+ self.assertEqual(killers.count('kill'), 0,
+ 'p.kill() reaches only the direct child; a flasher run through a '
+ 'shell keeps grandchildren it cannot touch')
+
+ def test_the_interrupt_path_reraises(self):
+ import ast
+ fn = self._run_cmd_ast()
+ base = [h for n in ast.walk(fn) if isinstance(n, ast.Try) for h in n.handlers
+ if isinstance(h.type, ast.Name) and h.type.id == 'BaseException']
+ self.assertTrue(base, 'the BaseException cleanup path is gone')
+ for h in base:
+ self.assertTrue(any(isinstance(x, ast.Raise) for x in ast.walk(h)),
+ 'the interrupt path must re-raise, or Ctrl-C is swallowed')
+
+
+class BoundedReadForGuardlessCallers(unittest.TestCase):
+ """`serial` is served under the device lock a wedged usbfs ioctl holds, so the read is
+ bounded BY DEFAULT -- not opt-in. usb_scan reads it on every device matching the VID to
+ find the one it wants, and hil_lock.controller_of does that from controller_permit on
+ essentially every board, so one wedged DUT would stall every worker rather than one.
+ hil_pool_check has no guard behind it at all."""
+
+ def setUp(self):
+ from helper import hil_util
+ self.hil_util = hil_util
+ # the pre-commit hook runs all four suites in ONE interpreter, so capture and
+ # restore rather than assuming these start (or end) empty
+ for name in ('_stranded', '_strand_hits'):
+ self.addCleanup(setattr, hil_util, name, dict(getattr(hil_util, name)))
+ getattr(hil_util, name).clear()
+ self.addCleanup(setattr, hil_util, '_ever_stranded', hil_util._ever_stranded)
+ hil_util._ever_stranded = False
+ self.td = TemporaryDirectory()
+ self.addCleanup(self.td.cleanup)
+ self.fifo = os.path.join(self.td.name, 'serial')
+ os.mkfifo(self.fifo) # a read that never answers
+
+ def test_a_wedged_attribute_gives_up_instead_of_hanging(self):
+ t0 = time.monotonic()
+ self.assertIsNone(self.hil_util.read_sysfs(self.fifo, timeout=0.3))
+ self.assertLess(time.monotonic() - t0, 5, 'the bounded read did not give up')
+
+ def test_the_bound_is_the_default_not_an_opt_in(self):
+ """usb_scan reads `serial` on every device matching the VID to find the one it
+ wants, and hil_lock's controller_of does that from controller_permit on
+ essentially every board -- so an opt-in bound that ONE call site forgets lets a
+ single wedged DUT stall every worker, not one. Three call sites forgot it once."""
+ import inspect
+ for fn in (self.hil_util.read_sysfs, self.hil_util.usb_scan):
+ default = inspect.signature(fn).parameters['timeout'].default
+ self.assertEqual(default, self.hil_util.SYSFS_READ_GRACE,
+ f'{fn.__name__} must be bounded without being asked')
+ t0 = time.monotonic()
+ self.assertIsNone(self.hil_util.read_sysfs(self.fifo)) # no timeout= passed
+ self.assertLess(time.monotonic() - t0, 5, 'the default path did not bound')
+
+ def test_a_node_that_returns_during_the_grace_is_not_memoised_as_wedged(self):
+ """The inode must be captured BEFORE the reader starts. Stat it afterwards and a
+ board that came back mid-read has its brand-new HEALTHY inode recorded as the
+ wedged one -- only a SECOND re-enumeration could ever clear it, and hil_pool_check
+ would report a successful recovery as still off the bus."""
+ def swap():
+ time.sleep(0.15)
+ os.unlink(self.fifo)
+ Path(self.fifo).write_text('CAFE01\n')
+
+ threading.Thread(target=swap, daemon=True).start()
+ self.hil_util.read_sysfs(self.fifo, timeout=0.6)
+ self.assertEqual(self.hil_util.read_sysfs(self.fifo, timeout=1), 'CAFE01',
+ 'the healthy new inode was recorded as the wedged one')
+
+ def test_concurrent_readers_of_one_path_spend_one_credit(self):
+ """hil_pool_check polls one bus from four threads. Counting each READER let four
+ threads on ONE wedged device spend four of the process budget between them --
+ latching on the single wedge the tool was run to find."""
+ ts = [threading.Thread(target=lambda: self.hil_util.read_sysfs(self.fifo, timeout=0.3))
+ for _ in range(4)]
+ [t.start() for t in ts]
+ [t.join() for t in ts]
+ self.assertEqual(len(self.hil_util._stranded), 1)
+ self.assertEqual(self.hil_util._strand_hits[self.fifo], 1,
+ 'four readers of one path spent four credits')
+
+ def test_a_flapping_wedged_device_cannot_leak_without_bound(self):
+ """The inode all-clear re-arms on every re-enumeration, so a device that flaps
+ while STILL wedged strands again each pass -- a thread and an fd per cycle."""
+ for _ in range(self.hil_util._PATH_STRAND_MAX + 4):
+ self.hil_util.read_sysfs(self.fifo, timeout=0.2)
+ os.unlink(self.fifo)
+ os.mkfifo(self.fifo) # back on the same path, still wedged
+ self.assertEqual(self.hil_util._strand_hits[self.fifo],
+ self.hil_util._PATH_STRAND_MAX,
+ 'a flapping device kept stranding past its per-path cap')
+
+ def test_a_value_that_arrived_at_the_deadline_is_not_a_strand(self):
+ """`out` is checked BEFORE is_alive(): a reader can deposit its value and still be
+ alive for a moment after join() returns. Counting that as a strand blacklists a
+ healthy attribute by inode forever AND latches sysfs_stranded for the process."""
+ good = Path(self.td.name) / 'idVendor'
+ good.write_text('cafe\n')
+ real_thread = threading.Thread
+
+ class Lingering(real_thread): # deposits, then outlives the join
+ def run(self):
+ super().run()
+ time.sleep(2)
+
+ self.hil_util.threading.Thread = Lingering
+ self.addCleanup(setattr, self.hil_util.threading, 'Thread', real_thread)
+ self.assertEqual(self.hil_util.read_sysfs(str(good), timeout=0.3), 'cafe')
+ self.assertNotIn(str(good), self.hil_util._stranded)
+ self.assertFalse(self.hil_util.sysfs_stranded())
+
+ def test_path_stranded_answers_per_device_not_per_process(self):
+ """usbtest decides whether to run lock-taking cleanup on this result; the sticky
+ process-wide flag would let any peer's wedge answer for our board."""
+ other = Path(self.td.name) / 'peer'
+ other.write_text('PEER\n')
+ self.hil_util.read_sysfs(self.fifo, timeout=0.3)
+ self.assertTrue(self.hil_util.path_stranded(self.fifo))
+ self.assertFalse(self.hil_util.path_stranded(str(other)))
+ self.assertTrue(self.hil_util.sysfs_stranded(), 'the process-wide flag is sticky')
+
+ def test_a_refused_read_is_stranded_not_vouched_for(self):
+ """usbtest fails CLOSED on path_stranded() before running remove_id/unbind, which
+ take the uninterruptible device_lock. Past _STRAND_MAX read_sysfs answers None
+ WITHOUT looking -- so answering False there hands that guard a fabricated
+ all-clear for a device nobody read, and the lock-taking cleanup runs on a wedge."""
+ self.hil_util._stranded.update(
+ {f'/sys/fake/{i}': i for i in range(self.hil_util._STRAND_MAX)})
+ self.assertIsNone(self.hil_util.read_sysfs(self.fifo, timeout=0.3))
+ self.assertTrue(self.hil_util.path_stranded(self.fifo),
+ 'a path the reader refused to open was reported readable-and-absent')
+
+ def test_a_stat_that_races_the_reader_still_memoises(self):
+ """The pre-read stat is the memo KEY, and it can fail while the open that follows
+ succeeds and blocks -- a node replaced between the two. Without a key the give-up
+ records nothing, so hil_pool_check's next poll starts another permanent thread and
+ fd for the same path, and repeats it every pass."""
+ real_stat = self.hil_util.os.stat
+ calls = []
+
+ def flaky(path, *a, **kw):
+ calls.append(path)
+ if len(calls) == 1: # only the pre-read stat loses the race
+ raise OSError('vanished between stat and open')
+ return real_stat(path, *a, **kw)
+
+ self.addCleanup(setattr, self.hil_util.os, 'stat', real_stat)
+ self.hil_util.os.stat = flaky
+ self.assertIsNone(self.hil_util.read_sysfs(self.fifo, timeout=0.3))
+ self.assertIn(self.fifo, self.hil_util._stranded,
+ 'a lost stat race leaks a fresh reader on every later poll')
+
+ def test_a_successful_read_clears_an_earlier_refusal(self):
+ """_refused feeds path_stranded(), which usbtest reads to tell "cannot tell" from
+ a real disconnect. Left sticky, a board that recovered and then genuinely left the
+ bus is classified as an unrecovered wedge for the rest of the process."""
+ good = Path(self.td.name) / 'serial2'
+ good.write_text('ABC123\n')
+ self.hil_util._refused.add(str(good))
+ self.addCleanup(self.hil_util._refused.discard, str(good))
+ self.assertEqual(self.hil_util.read_sysfs(str(good), timeout=0.3), 'ABC123')
+ self.assertFalse(self.hil_util.path_stranded(str(good)),
+ 'a path that answered is still reported unreadable')
+
+ def test_a_recovered_device_is_seen_again_on_the_same_busport(self):
+ """THE recovery flow: hil_pool_check resets or reflashes a wedged board, then
+ wait_device polls find_device -> scan_usb for the NEW inode. A busport does not
+ change when the board comes back on the same physical port, so a path-only
+ blacklist would make that poll look at everything except the device it is waiting
+ for -- the board recovers physically and the tool reports it gone for the rest of
+ the run. A re-enumeration destroys the kernfs node, so a changed inode is the
+ all-clear."""
+ self.assertIsNone(self.hil_util.read_sysfs(self.fifo, timeout=0.3))
+ # re-enumeration: same path, new node
+ os.unlink(self.fifo)
+ Path(self.fifo).write_text('CAFE01\n')
+ self.assertEqual(self.hil_util.read_sysfs(self.fifo, timeout=1), 'CAFE01',
+ 'a board that came back on the same busport stayed blacklisted')
+
+ def test_the_caveat_stays_true_after_a_recovery(self):
+ """Rows collected while the device was unreadable keep whatever they said, so the
+ footer must still warn even once the memo has cleared."""
+ self.hil_util.read_sysfs(self.fifo, timeout=0.3)
+ os.unlink(self.fifo)
+ Path(self.fifo).write_text('CAFE01\n')
+ self.hil_util.read_sysfs(self.fifo, timeout=1)
+ self.assertTrue(self.hil_util.sysfs_stranded())
+
+ def test_a_stranded_path_is_never_read_twice(self):
+ """Each expiry strands a thread and an fd for the life of the process, and
+ hil_pool_check POLLS -- wait_device re-scans every 0.5s until its budget runs
+ out. Re-reading would leak one pair per poll."""
+ self.hil_util.read_sysfs(self.fifo, timeout=0.3)
+ t0 = time.monotonic()
+ for _ in range(5):
+ self.assertIsNone(self.hil_util.read_sysfs(self.fifo, timeout=0.3))
+ self.assertLess(time.monotonic() - t0, 0.3,
+ 'repeat reads of a known-stranded path paid the grace again')
+
+ def test_the_caller_can_say_the_table_may_be_wrong(self):
+ self.assertFalse(self.hil_util.sysfs_stranded())
+ self.hil_util.read_sysfs(self.fifo, timeout=0.3)
+ self.assertTrue(self.hil_util.sysfs_stranded(),
+ 'nothing would tell the operator a missing row may be this tool '
+ 'losing sight of healthy hardware')
+
+ def test_a_healthy_attribute_is_not_blacklisted(self):
+ good = os.path.join(self.td.name, 'idVendor')
+ Path(good).write_text('cafe\n')
+ for _ in range(3):
+ self.assertEqual(self.hil_util.read_sysfs(good, timeout=1), 'cafe')
+ self.assertFalse(self.hil_util.sysfs_stranded())
+
+ def test_without_a_timeout_the_read_stays_plain(self):
+ good = os.path.join(self.td.name, 'busnum')
+ Path(good).write_text('3\n')
+ self.assertEqual(self.hil_util.read_sysfs(good), '3')
+ self.assertIsNone(self.hil_util.read_sysfs(os.path.join(self.td.name, 'nope')))
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/test/hil/usbtest.py b/test/hil/usbtest.py
index 485e9e0e4..d23217417 100755
--- a/test/hil/usbtest.py
+++ b/test/hil/usbtest.py
@@ -47,31 +47,65 @@ RECOVER_FLASH_TIMEOUT = 90 # bound on the post-hang reflash; typical flash is 1
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`.
+RECOVER_SETTLE = 5 # after each step, to let a freed ioctl unwind
+# The ladder's UNBOUNDED work, which no step timeout covers: two wedged_pids() /proc walks,
+# json.loads of the roster entry, the child's first `import hil_flash`, convoy_safe, the
+# BUDGET back-fill and the JSON print. The deleted _time_left() carried this as a bare
+# '- 35'. Without it the reserve equals its own worst case exactly, and HIL_CMD_TIMEOUT and
+# HIL_USBTEST_BATTERY_BUDGET are both env-overridable -- any of them moving up puts
+# run_cmd's killpg back inside the reflash, orphaning the flasher on the probe.
+RECOVER_OVERHEAD = 40
- 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).
- 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.
+def recovery_reserve(flasher: dict | str) -> int:
+ """Seconds this flasher's post-hang ladder can actually spend.
+
+ Every bounded step can cost its own timeout PLUS run_cmd's post-SIGKILL reap, so the
+ caller must count REAP_GRACE per step or its outer killpg lands mid-reflash and
+ ORPHANS the flasher on the probe. Derived rather than pinned: the predecessor was an
+ independent 250s that could not contain its own ladder, which is why the child used to
+ re-decide before every step and skipped most of them on a real hang.
+
+ Per FLASHER, not one number for the fleet: the Rescue-DP legs are openocd-only
+ (hil_flash.rescue_openocd returns False for anything else), and a stub reset is
+ screened out by reset_primitive -- so an esptool board reserving them would hold a
+ pool worker and a usbtest permit for 200s it can never spend.
"""
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
+ from helper import hil_util
+ if isinstance(flasher, str):
+ flasher = {'name': flasher, 'args': ''}
+ name = (flasher.get('name') or '').lower()
+
+ def step(bound):
+ return bound + hil_util.REAP_GRACE
+
+ total = step(RECOVER_FLASH_TIMEOUT) + 2 * RECOVER_SETTLE + RECOVER_OVERHEAD
+ if reset_primitive(name):
+ total += step(RECOVER_RESET_TIMEOUT)
+ # The ARGS, not just the name: rescue_openocd also needs the target cfg to be an RP
+ # one (RESCUE_CFG), so the five WCH/max32666 openocd boards on this rig can never run
+ # it. Reserving its two legs for them holds a pool worker and a usbtest permit for
+ # 200s of dead time -- the same waste the esptool case exists to remove.
+ if name == 'openocd' and any(cfg in (flasher.get('args') or '')
+ for cfg in hil_flash.RESCUE_CFG):
+ total += 2 * step(RECOVER_FLASH_TIMEOUT) # Rescue-DP POR + one retry
+ return total
+
+
+def reset_primitive(flasher_name: str):
+ """The flasher's probe-reset callable, or None when there is nothing real to run.
+
+ Two things gate it. A flasher may have no reset_* at all, and reset_esptool /
+ reset_lm4flash return rc 0 WITHOUT resetting anything -- running those makes the log
+ say "resetting <board> via <flasher>" for a step that did nothing. wedged_pids()
+ arbitrates the outcome either way, so behaviour was always right; the RECORD was not.
+ """
+ import hil_flash # deferred: stdlib-only unless recovery actually runs
+ fn = getattr(hil_flash, f'reset_{flasher_name.lower()}', None)
+ return None if getattr(fn, 'no_op', False) else fn
+
+
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,
@@ -190,18 +224,21 @@ def sysfs_write(path, data, check=True):
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.
+def _hu():
+ """The helper module, imported lazily like every other helper use in this file."""
+ from helper import hil_util
+ return hil_util
- 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.
- """
+
+SERIAL_GRACE = 1.0 # tighter than hil_util's shared default on purpose: find_device
+ # re-scans every cafe:4010 peer after each of ~30 cases and inside
+ # the 8s startup poll, so N unreadable peers cost N x this per scan
+
+
+def _read_sysfs(path):
+ """The attribute's value, or None. See hil_util.read_sysfs for why `serial` can block."""
from helper import hil_util
- return hil_util.read_sysfs(str(path), grace)
+ return hil_util.read_sysfs(str(path), SERIAL_GRACE)
_DEV_CACHE: dict = {} # serial -> sysname, see find_device
@@ -222,7 +259,7 @@ def _reread(sysname, serial):
if ((d / 'idVendor').read_text().strip() != VID
or (d / 'idProduct').read_text().strip() != PID):
return None
- dev_serial = _read_sysfs_bounded(d / 'serial')
+ dev_serial = _read_sysfs(d / 'serial')
if not isinstance(dev_serial, str) or dev_serial.lower() != serial.lower():
return None # gone, mismatched, or unconfirmable -> full scan decides
return {
@@ -253,19 +290,16 @@ def find_device(serial, first=False):
if hit:
return hit
_DEV_CACHE.pop(serial.lower(), None)
- matches, inconclusive = [], []
+ matches = []
for dev in SYS_USB.iterdir():
try:
if (dev / 'idVendor').read_text().strip() != VID or \
(dev / 'idProduct').read_text().strip() != PID:
continue
- # 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
+ # idVendor/idProduct are cached descriptors; `serial` is served under
+ # device_lock(), so on a wedged DUT this read blocks until the wedge clears.
+ # Contained by the caller's bound, not prevented here -- see hil_util.read_sysfs.
+ dev_serial = _read_sysfs(dev / 'serial')
if dev_serial is None:
continue
if serial and dev_serial.lower() != serial.lower():
@@ -281,10 +315,7 @@ def find_device(serial, first=False):
except (OSError, ValueError):
continue
if not matches:
- # "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
+ return None
if serial and len(matches) == 1:
_DEV_CACHE[serial.lower()] = matches[0]['sysname']
if len(matches) > 1 and not first:
@@ -533,13 +564,11 @@ def main():
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')
+ 'Callers that impose their own outer bound set this BELOW it, '
+ 'reserving the remainder for the post-hang recovery -- see '
+ 'recovery_reserve() for what that ladder costs')
args = p.parse_args()
t_start = time.monotonic()
sys.stdout.reconfigure(line_buffering=True) # per-case results visible when piped/logged
@@ -554,23 +583,23 @@ def main():
deadline = time.monotonic() + 8
while True:
dev = find_device(args.serial)
- # 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()):
+ # find_device returns a device or {'ambiguous': [...]}. Screening for the marker
+ # matters: without it the next statement subscripts dev['tier'] -> KeyError, no
+ # JSON on stdout, and hil_test reports "usbtest did not run / 0-30".
+ if dev and 'ambiguous' not in dev:
break
if time.monotonic() > deadline:
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 ''))
+ # a bounded `serial` read that gave up looks exactly like a disconnect from
+ # here, and hil_test relays this line verbatim into the report cell. The
+ # sticky process-wide flag is the RIGHT question at startup -- nothing but
+ # this scan has read anything yet -- unlike mid-battery, where a peer that
+ # stranded at case 2 would answer for our board at case 29.
+ sys.exit(f'no {VID}:{PID} device'
+ + (f' with serial {args.serial}' if args.serial else '')
+ + _hu().strand_note())
time.sleep(0.5)
# a stale/foreign device advertising an out-of-range tier must not silently run an
@@ -638,31 +667,16 @@ def main():
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
+ # Both steps are bounded (RECOVER_RESET_TIMEOUT / RECOVER_FLASH_TIMEOUT)
+ # and the caller RESERVES room for both -- hil_test derives its
+ # bound from recovery_reserve(). No re-derivation here:
+ # the old per-step "does it still fit?" arithmetic carried an unexplained
+ # 35s fudge for costs paid downstream, and nobody could re-derive it.
try:
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
@@ -680,25 +694,33 @@ def main():
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):
+ # RESET FIRST: a probe reset fails the in-flight URB at the source just
+ # as a reflash does, but it is non-destructive -- the firmware under test
+ # survives for autopsy -- writes no flash, and cannot brick SWD the way a
+ # bad park image has (mimxrt1064_evk, max32666fthr). Measured ~130 ms.
+ # wedged_pids is the arbiter either way: reset_esptool is a stub that
+ # returns rc 0 without resetting anything, so an exit code proves nothing.
+ reset_fn = reset_primitive(fname)
+ if reset_fn:
print(f'auto-recovering: resetting {bname} via {fname} probe '
f'(non-destructive; reflash only if this does not clear it)',
file=sys.stderr)
+ # Inspect the signature rather than catching TypeError around the
+ # call: a TypeError raised INSIDE the primitive would re-run it with
+ # no bound (run_cmd's 180s CMD_TIMEOUT, against a 40s reserve), and a
+ # raise from that retry does not reach the sibling except Exception --
+ # it unwinds past the recovery block, so the battery exits on a
+ # traceback with no JSON and ~29 real verdicts are discarded.
+ import inspect
+ kw = ({'timeout': RECOVER_RESET_TIMEOUT}
+ if 'timeout' in inspect.signature(reset_fn).parameters else {})
try:
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
+ reset_fn(board, **kw)
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
+ time.sleep(RECOVER_SETTLE) # let the freed ioctl unwind
stuck, complete = wedged_pids(dev['node'])
if complete and not stuck:
print('probe reset cleared the wedge; skipping the reflash '
@@ -706,10 +728,6 @@ def main():
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 '
@@ -734,10 +752,9 @@ def main():
# 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)
+ 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)
@@ -754,7 +771,7 @@ def main():
# 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)
+ time.sleep(RECOVER_SETTLE)
# 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'])
@@ -788,16 +805,24 @@ def main():
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
if not live:
+ # ABSENT vs UNREADABLE: a bounded `serial` read that gave up looks exactly
+ # like a disconnect from here, and the difference decides whether the
+ # cleanup below runs. remove_id/unbind take the UNINTERRUPTIBLE
+ # device_lock (see the driver-registry note above), so performing them
+ # against a device that is merely unreadable -- i.e. probably wedged --
+ # deadlocks the bus rather than tidying up. Fail CLOSED: if anything gave
+ # up during this scan, treat it as the wedge it probably is, which also
+ # keeps the recovery and the board_wedged latch in play.
+ # OUR device's own attribute, not the process-wide sysfs_stranded():
+ # that flag is sticky and every DUT here is cafe:4010, so a peer that
+ # stranded at case 2 would make a genuine disconnect at case 29 report as
+ # an unrecovered wedge for the rest of the run.
+ if _hu().path_stranded(str(SYS_USB / dev['sysname'] / 'serial')):
+ abort_reason = (f'cannot tell whether the device is still present '
+ f'after case {num}: its serial read gave up')
+ unrecovered_hang = True
+ break
# 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}'
@@ -830,12 +855,27 @@ def main():
'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: 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)
+ # PROCESS-WIDE, unlike the per-case verdict above. That one is per-DUT on
+ # purpose -- a peer that stranded must not make OUR board report wedged.
+ # This cleanup is GLOBAL: it unbinds every interface under the driver,
+ # including the peer we could not read, and unbind takes the
+ # uninterruptible device_lock. Narrowing this gate to path_stranded()
+ # would add a driver-registry writer to an existing wedge.
+ # INSIDE keep_binding rather than before it: hil_test always passes that
+ # flag, so a check further out announced a skip of cleanup that was never
+ # going to run -- one line of noise ahead of the real cause in every
+ # stranded row. ONE line for the same reason: the finally runs before
+ # SystemExit's message reaches stderr.
+ if _hu().sysfs_stranded():
+ print('cleanup skipped: a sysfs read gave up, so unbind could take a '
+ 'wedged device lock', file=sys.stderr)
+ else:
+ sysfs_write(DRIVER / 'remove_id', f'{VID} {PID}', check=False)
+ # 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
+ for intf in DRIVER.glob('*:*'):
+ sysfs_write(DRIVER / 'unbind', intf.name, check=False)
except SystemExit:
pass
@@ -849,7 +889,7 @@ def main():
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
+ # failed, the ambiguous 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) - len(notrun),
diff --git a/tools/ci_select.py b/tools/ci_select.py
index ca9d54c27..1526f2064 100755
--- a/tools/ci_select.py
+++ b/tools/ci_select.py
@@ -132,7 +132,11 @@ _METRICS_RE = re.compile(
r'^(tools/metrics[^/]*\.py$|\.github/scripts/metrics_[^/]*\.py$)')
_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/|\.github/scripts/|'
+ # tools/rtt.py is part of the harness, not a standalone tool: hil_util imports it
+ # at module load, so a break in it breaks every rig run the same way a test/hil/
+ # edit can (the pre-commit hil-test hook runs its unit tests for the same reason)
+ r'test/hil/|tools/rtt\.py$|'
+ r'\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|'
# generates the whole CircleCI matrix, same authority as .github/**
r'\.circleci/|'
# rule 16 says `tools/build*.py`; name the two siblings the glob implies. Both
@@ -764,6 +768,16 @@ def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel,
# only the tests whose example builds the lib, and only those the rig runs
tests = {e for e in lib_examples(lib, repo_root)
if any(e in pool for pool in ALL_TESTS.values()) or e in extras}
+ if lib == 'SEGGER_RTT':
+ # no example names this lib, but a board whose roster entry says
+ # "logger": "rtt" (variant defines LOGGER=rtt) reads EVERY test's console
+ # through it -- a break here silently breaks all of that board's rows
+ rtt_boards = [b['name'] for b in roster_boards if b.get('logger') == 'rtt']
+ if rtt_boards:
+ s.roles.update(('device', 'host'))
+ s.add(rtt_boards, 'all',
+ f'{path}: SEGGER_RTT is the rtt console on {rtt_boards} -> all tests')
+ return
if not tests:
s.reasons.append(f'{path}: lib {lib} used by no HIL test, no contribution')
return
@@ -1139,9 +1153,13 @@ def _classify_build_one(path, repo_root, s: _BSel, get_deps_families=None):
lib = m.group(1)
exs = lib_examples(lib, repo_root)
if not exs:
- # empty means empty: no example's build pulls this lib in, so no build
- # compiles it (lib/SEGGER_RTT is only reached through LOGGER=rtt, which
- # no CI build sets)
+ # empty means empty: no example's build pulls this lib in, so no MAIN-
+ # matrix build compiles it. (lib/SEGGER_RTT is reached through LOGGER=rtt,
+ # which the main matrix never sets; the hil-build legs set it only for
+ # roster boards whose variant defines carry it, via the HIL SEGGER_RTT rule.
+ # No committed CI roster has such a board yet, so a SEGGER_RTT edit is
+ # currently neither built nor HIL-tested by CI -- verify vendor bumps
+ # manually until a rig board adopts "logger": "rtt".)
s.reasons.append(f'{path}: lib {lib} built by no example, no contribution')
return
s.add(all_bsp_families(repo_root), exs, f'{path}: lib {lib} -> {sorted(exs)}')
diff --git a/tools/rtt.py b/tools/rtt.py
new file mode 100644
index 000000000..e3aef36f2
--- /dev/null
+++ b/tools/rtt.py
@@ -0,0 +1,727 @@
+#!/usr/bin/env python3
+"""RTT console/capture over a debug probe — importable classes + CLI (the rtt
+skill's SKILL.md is the manual).
+
+Three routes (see the skill's transport matrix for which route a probe gets).
+--backend is always explicit:
+
+ J-Link route (console/capture, channel 0 only)
+ rtt.py --backend jlink --probe <sn> --device <JLINK_DEVICE> [--seconds N] [-i]
+ OpenOCD route (native probes: ST-Link/CMSIS-DAP; console/capture, any channel)
+ rtt.py --backend openocd [--probe <sn>] [--vid-pid "0xVVVV 0xPPPP"] \\
+ --cfg "-f interface/stlink.cfg -f target/stm32h7x.cfg" \\
+ (--elf <flashed.elf> | --addr 0x2000xxxx) [--channel N] [--seconds N] [-i]
+ [--reset-before-attach] # capture from the target's boot (SystemView)
+ Post-mortem ring dump (J-Link, no halt — debug-AP reads)
+ rtt.py --backend jlink --dump <out.bin> --probe <sn> --device <JLINK_DEVICE> \\
+ (--elf <flashed.elf> | --addr 0x...)
+
+The probe is owned for the whole run: flash and reset BEFORE starting this, never
+reset the target while it is attached. Pin the probe: rigs and benches run
+several (jlink: --probe serial; openocd: --probe and/or --vid-pid).
+
+The classes (JlinkRtt for J-Link, OpenocdRtt for openocd-driven probes) expose
+the slice of pyserial the HIL harness uses — read/in_waiting/write/close/timeout,
+reset_input_buffer, context-manager use, plus an `eof` latch — and are imported
+by test/hil/helper/hil_util.py, so this file is HARNESS-CRITICAL: a change here
+is classified like a test/hil/ harness change (tools/ci_select.py) and runs the
+console unit tests (pre-commit hil-test hook, test/hil/test/test_hil_rtt.py).
+Stdlib only — hil_util imports this file, never the other way around.
+"""
+import argparse
+import contextlib
+import os
+import re
+import select
+import shlex
+import signal
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+
+class RttError(RuntimeError):
+ """Every way a console can break: stall, closed, dead or reset server.
+
+ A RuntimeError subclass so existing `except RuntimeError` callers keep working,
+ but named so the harness can tell a console failure from an unrelated
+ NotImplementedError / 'dictionary changed size during iteration' and stop
+ reporting harness bugs as board failures."""
+
+
+def _pos_float_env(name: str, default: float) -> float:
+ # mirrors hil_util.pos_float_env, including its rejection of inf/nan: an infinite
+ # write timeout is an unbounded write, the very thing this knob exists to bound
+ raw = os.environ.get(name)
+ if raw is None:
+ return default
+ try:
+ v = float(raw)
+ except ValueError:
+ print(f'warning: {name} is not a number; using {default}', file=sys.stderr, flush=True)
+ return default
+ 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
+
+
+# whole-call deadline for write() — same env knob as the harness's serial twin
+RTT_WRITE_TIMEOUT = _pos_float_env('HIL_SERIAL_WRITE_TIMEOUT', 10)
+
+# J-Link Commander's telnet greeting, sent at connect BEFORE (or without) the control
+# block being found: never target output. Three lines; the middle one is the PROBE
+# MODEL string, which in libjlinkarm carries no 'SEGGER ' prefix (J-Link OH3,
+# J-Trace H9, ...) though some builds do prefix it — match both shapes. Consumers
+# judging "did the target speak" must strip these lines first.
+RTT_BANNER_RE = re.compile(r'^(SEGGER J-|J-Link[ 0-9]|J-Trace[ 0-9]|Process:\s)')
+
+
+def strip_banner(data: bytes, complete_only: bool = False) -> bytes:
+ """Target bytes only: drop the J-Link server banner lines and blanks.
+
+ Both harness consumers (hil_test's device_info verdict, hil_pool_check's
+ aliveness score) must judge "did the target speak" through this one filter,
+ or the same byte stream scores differently per consumer. complete_only=True
+ additionally drops a trailing unterminated line — for poll loops judging a
+ growing buffer, where a banner FRAGMENT at a read boundary (b'SEGG', b'Proce')
+ would defeat the prefix regex and count as target output; the final verdict
+ after the window should pass complete_only=False to keep a genuine
+ unterminated tail."""
+ lines = data.splitlines(keepends=False)
+ if complete_only and data and not data.endswith((b'\n', b'\r')) and lines:
+ lines = lines[:-1]
+ return b'\n'.join(l for l in lines
+ if l.strip() and not RTT_BANNER_RE.match(l.decode('utf-8', errors='ignore')))
+
+
+def free_ports(count: int) -> list:
+ """Bind ephemeral ports and hand back the numbers. Boards run in parallel, so the
+ RTT/GDB ports cannot be the SEGGER defaults or two boards collide.
+
+ Known TOCTOU: the port is free when released here, but another process can claim
+ it before the server binds it. Accepted — the server binds the port itself, so
+ there is no fd to hand over. The post-connect re-poll catches the common outcome
+ (our server lost the bind and died); a foreign listener that stays alive is not
+ detectable here and would need the connected peer to be validated."""
+ socks = []
+ try:
+ for _ in range(count):
+ s = socket.socket()
+ s.bind(('127.0.0.1', 0))
+ socks.append(s)
+ return [s.getsockname()[1] for s in socks]
+ finally:
+ for s in socks:
+ s.close()
+
+
+def nm_rtt_addr(elf: str, nm: str = None) -> int:
+ """Control-block address from the FLASHED elf's symbol table. --addr is the way
+ out when nm cannot read the file (another architecture, no toolchain)."""
+ nm = nm or os.environ.get('RTT_NM', 'arm-none-eabi-nm')
+ try:
+ r = subprocess.run([nm, elf], capture_output=True, text=True, timeout=30)
+ except FileNotFoundError:
+ raise SystemExit(f'{nm} not on PATH — set RTT_NM=<your-nm>, or pass --addr')
+ except subprocess.TimeoutExpired:
+ raise SystemExit(f'{nm} did not finish reading {elf} in 30 s — pass --addr instead')
+ if r.returncode != 0:
+ raise SystemExit(f'{nm} could not read {elf}: {r.stderr.strip()[:200]}\n'
+ f'(wrong architecture? set RTT_NM=<your-nm>, or pass --addr)')
+ for line in r.stdout.splitlines():
+ # "<addr> <type> _SEGGER_RTT": a defined data symbol only — an undefined one
+ # (" U _SEGGER_RTT") has no address and would int('U', 16)
+ m = re.match(r'^([0-9a-fA-F]+)\s+[bBdD]\s+_SEGGER_RTT$', line.strip())
+ if m:
+ return int(m.group(1), 16)
+ raise SystemExit(f'no defined _SEGGER_RTT symbol in {elf} — was it built with LOGGER=rtt?')
+
+
+class _SocketRtt:
+ """Shared console core: a TCP socket onto an RTT server owned by self._proc.
+
+ Subclasses build their server argv and call _spawn() + _connect() in __init__.
+ One failure contract: RttError for every way the console can break (stall,
+ closed, dead server) — callers are written for exactly it. A dead or resetting
+ server LATCHES `eof` rather than raising from the read side, so read loops and
+ the harness's `assert not ser.eof` triage see it without an exception racing
+ them to a generic handler."""
+
+ server = 'RTT server' # for error messages
+
+ def __init__(self, timeout: float = 0.1):
+ self.timeout = timeout
+ self._buf = b''
+ self._eof = False
+ self._sock = None
+ self._proc = None
+ self._log = None
+ self._lock = threading.Lock() # _buf is touched by the CLI pump thread too
+
+ def _spawn(self, cmd: list, stdin=None) -> None:
+ # server output spools to a temp file: a PIPE nobody drains blocks a
+ # single-threaded server once 64 KiB of log accumulates (openocd at
+ # polling_interval 1 against a resetting target fills that in minutes) and
+ # the console goes silent with no error; the file also feeds _server_tail
+ self._log = tempfile.NamedTemporaryFile(prefix='rtt-server-', suffix='.log')
+ try:
+ self._proc = subprocess.Popen(cmd, stdin=stdin, stdout=self._log,
+ stderr=subprocess.STDOUT, start_new_session=True)
+ except FileNotFoundError as e:
+ self.close()
+ raise RttError(f'RTT console: {e.filename or cmd[0]} not on PATH') from e
+ except BaseException:
+ # any other spawn failure (PermissionError...) must not leak the log fd
+ self.close()
+ raise
+
+ def _connect(self, port: int) -> None:
+ try:
+ deadline = time.monotonic() + 15
+ while time.monotonic() < deadline:
+ try:
+ self._sock = socket.create_connection(('127.0.0.1', port), timeout=2)
+ break
+ except OSError:
+ if self._proc.poll() is not None:
+ break
+ time.sleep(0.2)
+ if self._sock is None:
+ tail = self._server_tail()
+ self.close()
+ raise RttError(f'RTT console: {self.server} did not serve port {port}{tail}')
+ if self._proc.poll() is not None:
+ # the connect succeeded but our server is dead: a foreign process claimed
+ # the port in the free_ports window — refuse a console wired to a stranger
+ self.close()
+ raise RttError(f'RTT console: {self.server} died after connect (port {port} hijacked?)')
+ self._sock.setblocking(False)
+ except (KeyboardInterrupt, SystemExit):
+ # a signal mid-construction must not orphan the server we just spawned
+ self.close()
+ raise
+
+ def _server_tail(self) -> str:
+ log = getattr(self, '_log', None)
+ if log:
+ with contextlib.suppress(OSError, ValueError):
+ with open(log.name, 'rb') as fh:
+ tail = fh.read()[-400:].decode(errors='replace')
+ if tail:
+ return ' — ' + tail
+ return ''
+
+ def _drain(self) -> None:
+ # LATCH, never raise: a peer reset or a socket closed under us ends the
+ # stream exactly like an orderly EOF. Raising here raced the harness's
+ # `assert not ser.eof` triage into a generic handler that re-flashes the
+ # board, and leaked ConnectionResetError/ValueError to in_waiting callers.
+ # the WHOLE body under the lock, not just the append: the CLI's -i pump thread
+ # and the read loop drain the same socket concurrently, and recv->append being
+ # non-atomic let chunks land out of order (measured: transposed 64-byte
+ # segments in 3/6 stress trials)
+ try:
+ with self._lock:
+ while self._sock and select.select([self._sock], [], [], 0)[0]:
+ try:
+ chunk = self._sock.recv(65536)
+ except (BlockingIOError, InterruptedError):
+ return
+ if not chunk:
+ self._eof = True
+ return
+ self._buf += chunk
+ except (OSError, ValueError, TypeError, AttributeError):
+ self._eof = True
+
+ @property
+ def eof(self) -> bool:
+ """True once the server hung up AND everything it sent has been read out."""
+ if self._sock is None:
+ return True
+ self._drain()
+ return self._eof and not self._buf
+
+ @property
+ def in_waiting(self) -> int:
+ if self._sock is None:
+ # pyserial raises on a closed port; answering "N bytes waiting" from a
+ # closed dead console would let a caller bug look like a healthy board
+ raise RttError('RTT console is closed')
+ self._drain()
+ return len(self._buf)
+
+ def read(self, size: int = 1) -> bytes:
+ if size is None or size <= 0:
+ # pyserial's read(0) returns b'' and consumes nothing; a negative size
+ # must not silently hand over (or destroy) buffered bytes
+ return b''
+ if self._sock is None:
+ raise RttError('RTT console is closed')
+ self._drain()
+ deadline = None if self.timeout is None else time.monotonic() + self.timeout
+ while (len(self._buf) < size and not self._eof
+ and (deadline is None or time.monotonic() < deadline)):
+ time.sleep(0.005)
+ self._drain()
+ if self._eof and len(self._buf) < size:
+ # dead server: pace the empty returns like a serial timeout would, so a
+ # caller's read loop cannot busy-spin at 100% CPU (416k empty reads/s
+ # measured unpaced). timeout=None deliberately diverges from pyserial's
+ # block-forever: the eof latch makes "server is gone" knowable, and an
+ # eternal block on it helps nobody -- paced empties + .eof is the contract.
+ pace = self.timeout if self.timeout is not None else 0.1
+ remaining = (deadline - time.monotonic()) if deadline is not None else pace
+ time.sleep(max(0.0, min(remaining, pace)))
+ with self._lock:
+ out, self._buf = self._buf[:size], self._buf[size:]
+ return out
+
+ def reset_input_buffer(self) -> None:
+ # pyserial surface: the host tests flush pre-reset backlog through this
+ if self._sock is None:
+ raise RttError('RTT console is closed')
+ self._drain()
+ with self._lock:
+ self._buf = b''
+
+ def write(self, data: bytes) -> int:
+ # select+send, not sendall(): the socket is non-blocking for reads, and sendall()
+ # on a non-blocking socket raises BlockingIOError as soon as the send buffer is
+ # full, with no count of what already went out -- a caller cannot resume without
+ # duplicating bytes. Same reason serial_write_all treats a short write as fatal.
+ sock = self._sock # snapshot: close() from another thread nulls the attribute
+ if sock is None:
+ raise RttError('RTT console is closed')
+ self._drain()
+ if self._eof:
+ # TCP accepts exactly one send after peer death — without this the bytes
+ # would "succeed" into the void and the read timeout gets blamed on the target
+ raise RttError(f'RTT console write to a dead server ({self.server} gone)')
+ sent = 0
+ deadline = time.monotonic() + RTT_WRITE_TIMEOUT
+ while sent < len(data):
+ if time.monotonic() > deadline:
+ raise RttError(f'RTT console write stalled after {sent}/{len(data)} bytes')
+ try:
+ if not select.select([], [sock], [], 0.1)[1]:
+ continue
+ sent += sock.send(data[sent:])
+ except (BlockingIOError, InterruptedError):
+ continue
+ except (OSError, ValueError, TypeError, AttributeError) as e:
+ # peer death (BrokenPipe/ConnectionReset) or the socket closed under us
+ # mid-call: keep the class's one failure contract
+ raise RttError(f'RTT console write failed after {sent}/{len(data)} bytes: {e}') from e
+ return sent
+
+ def _gentle_stop(self, proc) -> None:
+ """Subclass hook: ask the server to exit before the group takedown."""
+
+ def close(self) -> None:
+ self._eof = True # latch: post-close eof reads True, like a hung-up server
+ if getattr(self, '_sock', None):
+ self._sock.close()
+ self._sock = None
+ with self._lock:
+ self._buf = b'' # pyserial contract: nothing is readable after close
+ proc = getattr(self, '_proc', None)
+ if proc:
+ if proc.poll() is None:
+ self._gentle_stop(proc)
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ proc.wait(timeout=5)
+ if proc and proc.poll() is None:
+ # own session (start_new_session), so the group takedown gets the server and
+ # anything it spawned; leaving one alive would hold the probe for the next test
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ proc.wait(timeout=5)
+ except (ProcessLookupError, PermissionError):
+ pass
+ except subprocess.TimeoutExpired:
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.killpg(proc.pid, signal.SIGKILL)
+ # reap, or the server stays a zombie for the caller's lifetime
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ proc.wait(timeout=2)
+ if proc:
+ for pipe in (proc.stdin, proc.stdout):
+ if pipe:
+ with contextlib.suppress(OSError, ValueError):
+ pipe.close()
+ # the server spool file: one fd plus a /tmp file per console, and the server
+ # grows it while alive -- GC is not a release policy on a rig
+ log = getattr(self, '_log', None)
+ if log:
+ with contextlib.suppress(OSError, ValueError):
+ log.close()
+ self._log = None
+
+ # a console dropped without close() must not hold the probe for the process's life
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ self.close()
+
+ def __del__(self):
+ with contextlib.suppress(Exception):
+ self.close()
+
+
+class JlinkRtt(_SocketRtt):
+ """Bidirectional console over SEGGER RTT channel 0, for J-Link probes (the only
+ console on boards whose probe has no VCOM or whose BSP has no UART).
+
+ J-Link Commander (JLinkExe) owns the probe and serves RTT channel 0 on
+ -RTTTelnetPort -- what JLinkRTTClient talks to, minus its banner. It keeps
+ hunting for the control block and streams whatever the buffer already holds,
+ where JLinkRTTLogger searches once when it attaches and gives up. It also
+ carries input, which the host tests that drive a menu need.
+
+ The probe is held for as long as this is open, so flashing and resetting the
+ board must happen before it is created or after close(). Select the probe by
+ serial: rigs run more than one."""
+
+ server = 'JLinkExe'
+
+ def __init__(self, board: dict, timeout: float = 0.1):
+ super().__init__(timeout)
+ flasher = board['flasher']
+ args = shlex.split(flasher.get('args', ''))
+ if '-device' not in args:
+ # fail with the real cause now: JLinkExe without a device blocks prompting
+ # and would surface 15 s later as a misleading port error
+ raise RttError(f'RTT console: no -device in flasher args: {flasher.get("args")!r}')
+ port = free_ports(1)[0]
+ # defaults first, the roster's args after so they can override (-if jtag,
+ # -JLinkScriptFile, an explicit -speed). NOTE: hil_flash orders it the other
+ # way (roster args first, its own -if/-speed last, so ITS defaults win) --
+ # a roster override honored here is ignored by flash/reset; align them if a
+ # roster ever carries such args. -ExitOnError makes a failed target connect
+ # EXIT Commander
+ # (a clean error with the log tail) instead of leaving a banner-only console
+ cmd = ['JLinkExe', '-USB', str(flasher['uid']), '-if', 'swd',
+ '-JTAGConf', '-1,-1', '-speed', 'auto', '-NoGui', '1',
+ '-ExitOnError', '1', '-AutoConnect', '1',
+ *args, '-RTTTelnetPort', str(port)]
+ # stdin stays open: Commander exits when it runs out of input; close() writes
+ # 'exit' there.
+ self._spawn(cmd, stdin=subprocess.PIPE)
+ self._connect(port)
+
+ def _gentle_stop(self, proc) -> None:
+ with contextlib.suppress(OSError, ValueError):
+ proc.stdin.write(b'exit\n')
+ proc.stdin.flush()
+ # close our pipe end in its own suppress: a BrokenPipe on the write above must
+ # not skip it (the base close also closes it for the server-already-dead path)
+ with contextlib.suppress(OSError, ValueError):
+ proc.stdin.close()
+
+
+class OpenocdRtt(_SocketRtt):
+ """The console surface over an openocd `rtt server` (native probes:
+ ST-Link/CMSIS-DAP — never point openocd at ea4088's LPC-Link2, measured to
+ knock that probe off USB; other J-Link-OB probes untested).
+
+ Exact control-block address (never a full-RAM scan), polling_interval 1
+ (default 100 ms polling loses most of a busy stream), attach WITHOUT reset —
+ flash and reset before starting; `rtt start` needs the block to exist.
+ reset_before_attach opts into an in-session reset for streams that only
+ decode from byte 0 (SystemView)."""
+
+ server = 'openocd'
+
+ def __init__(self, cfg: str, addr: int, channel: int, serial_no: str = None,
+ vid_pid: str = None, timeout: float = 0.1, reset_before_attach: bool = False):
+ super().__init__(timeout)
+ port = free_ports(1)[0]
+ # argv, never a shell string: cfg/serial/vid_pid come from roster JSON and the
+ # command line, and a '$', backtick or quote in any of them would otherwise be
+ # substituted by the shell or break out of it
+ cmd = ['openocd', '-c', 'tcl_port disabled', '-c', 'gdb_port disabled',
+ '-c', 'telnet_port disabled']
+ # probe pin: vid_pid keeps discovery from opening foreign usbfs nodes (a
+ # wedged one hangs the open), serial disambiguates same-model probes —
+ # both before the -f scripts, like hil_flash does
+ if vid_pid:
+ if not re.fullmatch(r'0x[0-9a-fA-F]{1,4} 0x[0-9a-fA-F]{1,4}', vid_pid.strip()):
+ # openocd only WARNS and exits 0 on a malformed value, so the pin
+ # silently does not apply and discovery reopens every usbfs node --
+ # the convoy hil_flash.valid_vid_pid exists to stop
+ raise RttError(f'--vid-pid must be "0xVVVV 0xPPPP", got {vid_pid!r}')
+ cmd += ['-c', f'adapter usb vid_pid {vid_pid.strip()}']
+ if serial_no:
+ cmd += ['-c', f'adapter serial {serial_no}']
+ cmd += shlex.split(cfg)
+ cmd += ['-c', 'init']
+ # opt-in: reset the target INSIDE this session, give it 2 s to boot, THEN
+ # attach and drain. The order is forced: `rtt start` needs the control block
+ # to already exist in RAM (the firmware creates it at init), and attaching
+ # ahead of the reset would latch the PREVIOUS run's stale block. Byte 0 still
+ # reaches the consumer because NO_BLOCK_SKIP retains the ring's HEAD: a boot
+ # burst bigger than the ring loses its tail until the drain catches up, never
+ # its first bytes -- which is the part a boot-anchored decoder needs
+ # (SystemView's Init record, carrying the timestamp frequency, is emitted once
+ # at boot; a mid-flight attach yields a stream no decoder can lock onto; size
+ # BUFFER_SIZE_UP to the boot burst if the tail matters too). Costs the tool's
+ # usual no-reset invariant, and is unsafe on parts where an in-session reset
+ # leaves the core held (SAMD5x DSU) or perturbs the target (WCH SDI).
+ if reset_before_attach:
+ cmd += ['-c', 'reset run', '-c', 'sleep 2000']
+ cmd += ['-c', f'rtt setup 0x{addr:x} 0x800 "SEGGER RTT"',
+ '-c', 'rtt polling_interval 1', '-c', 'rtt start',
+ '-c', f'rtt server start {port} {channel}']
+ self._spawn(cmd)
+ self._connect(port)
+
+ def _gentle_stop(self, proc) -> None:
+ # no stdin channel to ask openocd to exit, and it keeps its listener up after
+ # the client disconnects: go straight to the group takedown instead of blocking
+ # the base class's 5 s wait on a process that has no reason to leave
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.killpg(proc.pid, signal.SIGTERM)
+
+
+def dump_ring(probe: str, device: str, addr: int, out_path: str, channel: int = 0) -> int:
+ """Post-mortem: read aUp[channel]'s ring over the debug AP (no halt) via JLinkExe.
+ NO_BLOCK_SKIP means an undrained ring holds the FIRST KB after boot, not the
+ tail — interpretation rules in the target-debug skill."""
+ if re.search(r'[\s"\']', out_path):
+ raise SystemExit(f'--dump path must not contain whitespace or quotes: {out_path!r} '
+ f'(it is spliced into a JLinkExe script line)')
+ # a stale file from an earlier run must not satisfy the success check below
+ with contextlib.suppress(OSError):
+ os.remove(out_path)
+ # SEGGER_RTT_CB: acID[16], MaxNumUpBuffers, MaxNumDownBuffers, then aUp[] at 0x18,
+ # each ring 6 words {sName, pBuffer, SizeOfBuffer, WrOff, RdOff, Flags}. Read the
+ # counts with the descriptor so an out-of-range channel is rejected instead of
+ # reading whatever RAM follows the array.
+ jlink = ['JLinkExe', '-USB', probe, '-device', device, '-if', 'swd',
+ '-speed', '4000', '-NoGui', '1', '-AutoConnect', '1']
+
+ def _jlink_run(script: str):
+ # same clean-exit contract as nm_rtt_addr/_spawn: a missing binary or a wedged
+ # probe must not reach the CLI as a traceback
+ try:
+ return subprocess.run(jlink, input=script, capture_output=True, text=True, timeout=60)
+ except FileNotFoundError:
+ raise SystemExit('JLinkExe not on PATH — the --dump route needs J-Link Commander')
+ except subprocess.TimeoutExpired:
+ raise SystemExit('JLinkExe did not finish in 60 s — probe wedged or target unreachable?')
+
+ script = f'mem32 {addr + 0x10:#x}, 2\nmem32 {addr + 0x18 + channel * 24:#x}, 6\nexit\n'
+ r = _jlink_run(script)
+ words = []
+ for line in r.stdout.splitlines():
+ # UNANCHORED: when the script arrives on stdin, some JLinkExe versions glue
+ # the 'J-Link>' prompt onto the result line with no newline between
+ m = re.search(r'([0-9A-Fa-f]{8}) = ((?:[0-9A-Fa-f]{8} ?)+)$', line.strip())
+ if m:
+ words += [int(w, 16) for w in m.group(2).split()]
+ if len(words) < 8:
+ print(r.stdout[-500:], file=sys.stderr)
+ raise SystemExit(f'could not read the aUp[{channel}] descriptor — wrong control block address?')
+ max_up = words[0]
+ if not 0 < max_up <= 32:
+ raise SystemExit(f'control block at {addr:#x} looks uninitialized '
+ f'(MaxNumUpBuffers={max_up}) — the target has not written to RTT yet, '
+ f'or the address is wrong')
+ if channel >= max_up:
+ raise SystemExit(f'--channel {channel}: this firmware has {max_up} up-buffer(s) (0..{max_up - 1})')
+ _, pbuf, size, wroff, rdoff, _ = words[2:8]
+ if not pbuf or not size:
+ raise SystemExit(f'up-buffer {channel} is not initialized (pBuffer={pbuf:#x} size={size}) — '
+ f'the target has not written to it yet')
+ script = f'savebin {out_path}, {pbuf:#x}, {size:#x}\nexit\n'
+ _jlink_run(script)
+ # JLinkExe exits 0 even when a command inside its script fails, so the only proof
+ # savebin worked is the file itself: it must hold the WHOLE ring, since a read that
+ # dies partway (probe disconnect, unreadable address) still leaves a short file that
+ # would otherwise be reported as a complete dump. Removing it also keeps the
+ # invariant above -- no stale file can satisfy a later run's check.
+ got = os.path.getsize(out_path) if os.path.exists(out_path) else 0
+ if got < size:
+ with contextlib.suppress(OSError):
+ os.remove(out_path)
+ if got == 0:
+ raise SystemExit(f'savebin produced no data at {out_path} — probe or address problem')
+ raise SystemExit(f'savebin wrote {got}/{size} B to {out_path} (truncated dump removed) '
+ f'— probe or address problem')
+ print(f'ring: {size} B at {pbuf:#x}, WrOff={wroff:#x} RdOff={rdoff:#x} -> {out_path}\n'
+ f'valid bytes wrap at WrOff; default NO_BLOCK_SKIP holds the FIRST data after '
+ f'boot, not the tail', file=sys.stderr)
+ return 0
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument('--backend', choices=['jlink', 'openocd'], required=True,
+ help='transport route — explicit, no default (skill transport matrix)')
+ ap.add_argument('--probe', help='probe serial (JLinkExe -USB / openocd "adapter serial")')
+ ap.add_argument('--vid-pid', help='openocd probe pin by USB IDs, e.g. "0x2e8a 0x000c" '
+ '(with or instead of --probe)')
+ ap.add_argument('--device', help='JLINK_DEVICE from board.cmake/family.cmake (jlink backend)')
+ ap.add_argument('--cfg', help='openocd -f/-c args, e.g. "-f interface/stlink.cfg -f target/stm32h7x.cfg"')
+ ap.add_argument('--elf', help='the FLASHED elf: exact _SEGGER_RTT address via nm (openocd/--dump)')
+ ap.add_argument('--addr', help='SEGGER RTT control block address (hex), instead of --elf')
+ ap.add_argument('--channel', type=int, default=0, help='up-buffer index (0 console, 1 SysView)')
+ ap.add_argument('--seconds', type=float, default=0, help='capture duration; 0 = until Ctrl-C/EOF')
+ ap.add_argument('-i', '--interactive', action='store_true', help='forward stdin to the target')
+ ap.add_argument('--reset-before-attach', action='store_true',
+ help='openocd: reset the target inside the capture session so the '
+ 'server is draining when it boots (needed for streams that must '
+ 'include the boot preamble, e.g. SystemView); unsafe on SAMD5x/WCH')
+ ap.add_argument('--dump', metavar='OUT.bin',
+ help='post-mortem ring dump (jlink backend; needs --elf or --addr)')
+ args = ap.parse_args()
+
+ if args.seconds < 0 or args.seconds != args.seconds: # negative or nan
+ ap.error(f'--seconds must be >= 0 (0 = until Ctrl-C/EOF), got {args.seconds}')
+ if args.channel < 0:
+ # a negative index would walk backwards off aUp[] into the control-block
+ # header and read garbage as a descriptor
+ ap.error(f'--channel must be >= 0, got {args.channel}')
+
+ def rtt_addr():
+ if args.addr:
+ try:
+ return int(args.addr, 16)
+ except ValueError:
+ ap.error(f'--addr must be hex, got {args.addr!r}')
+ if args.elf:
+ return nm_rtt_addr(args.elf)
+ ap.error('need --elf (flashed elf, address via nm) or --addr')
+
+ if args.backend == 'jlink':
+ if args.reset_before_attach:
+ ap.error('--reset-before-attach is openocd-only (the J-Link route attaches '
+ 'to a running target; flash and reset before starting it)')
+ if args.channel and not args.dump:
+ # -RTTTelnetPort serves the Terminal buffer only; --dump can read any ring
+ ap.error('the jlink backend streams channel 0 only (use --backend openocd '
+ 'for another channel, or --dump to read one)')
+ if args.vid_pid:
+ ap.error('--vid-pid is openocd-only; J-Link probes are selected by serial (--probe)')
+ if not (args.probe and args.device):
+ ap.error('the jlink backend needs --probe and --device')
+ elif not (args.probe or args.vid_pid):
+ ap.error('the openocd backend needs --probe and/or --vid-pid')
+
+ if args.dump:
+ if args.backend != 'jlink':
+ ap.error('--dump uses the jlink backend (debug-AP reads via JLinkExe)')
+ return dump_ring(args.probe, args.device, rtt_addr(), args.dump, args.channel)
+
+ # install BEFORE the console exists: an external `timeout`/kill during the
+ # up-to-15 s connect window must still reach the cleanup below, or the openocd
+ # route leaves a server holding the probe and the port (JLinkExe would exit on
+ # stdin EOF; openocd has no such channel and its own session shields it)
+ def _terminate(signum, _frame):
+ raise KeyboardInterrupt
+ for _sig in (signal.SIGTERM, signal.SIGHUP):
+ with contextlib.suppress(ValueError, OSError):
+ signal.signal(_sig, _terminate)
+
+ try:
+ if args.backend == 'openocd':
+ if not args.cfg:
+ ap.error('--backend openocd needs --cfg')
+ con = OpenocdRtt(args.cfg, rtt_addr(), args.channel,
+ serial_no=args.probe, vid_pid=args.vid_pid,
+ reset_before_attach=args.reset_before_attach)
+ else:
+ con = JlinkRtt({'flasher': {'uid': args.probe, 'args': f'-device {args.device}'}},
+ timeout=0.1)
+ except RttError as e:
+ print(e, file=sys.stderr)
+ return 1
+ except KeyboardInterrupt:
+ return 130 # constructors clean up after themselves on the way out
+
+ saw_output = threading.Event()
+ forwarded = threading.Event()
+ if args.interactive:
+ def pump_stdin():
+ # Hold input until the capture side has seen TARGET output (or 5 s for a
+ # quiet firmware): the J-Link telnet route silently DROPS client bytes
+ # until Commander locates the control block, so input forwarded at attach
+ # vanishes (measured on the rig: instant 'ping' lost, delayed 'ping'
+ # echoed). The gate must ignore the server's own banner — it arrives at
+ # connect, BEFORE the block is found. Raw os.read, not sys.stdin.buffer:
+ # bytes with no newline wait, and no BufferedReader lock — a daemon
+ # thread blocked holding that lock at interpreter shutdown aborts
+ # CPython (_enter_buffered_busy).
+ saw_output.wait(5)
+ try:
+ while True:
+ data = os.read(0, 4096)
+ if not data:
+ return
+ con.write(data)
+ forwarded.set()
+ except (RttError, OSError, ValueError):
+ return # console closed/stalled/dead; capture side reports the state
+ threading.Thread(target=pump_stdin, daemon=True).start()
+
+ deadline = time.monotonic() + args.seconds if args.seconds else None
+ rc = 0
+ seen = b'' # pre-release accumulator for the banner check only
+ try:
+ while deadline is None or time.monotonic() < deadline:
+ try:
+ chunk = con.read(con.in_waiting or 1)
+ except RttError as e:
+ print(f'rtt: {e}', file=sys.stderr)
+ rc = 1
+ break
+ if chunk:
+ if args.interactive and not saw_output.is_set():
+ # target data = anything past the J-Link banner's final line
+ # ('Process: <name>'); the openocd server has no banner
+ seen = (seen + chunk)[-65536:]
+ if args.backend != 'jlink':
+ saw_output.set()
+ else:
+ i = seen.find(b'Process: ')
+ j = seen.find(b'\n', i) if i >= 0 else -1
+ if j >= 0 and len(seen) > j + 1:
+ saw_output.set()
+ sys.stdout.buffer.write(chunk)
+ sys.stdout.buffer.flush()
+ elif con.eof:
+ print('rtt: server closed the connection', file=sys.stderr)
+ rc = 1
+ break
+ except KeyboardInterrupt:
+ pass
+ except BrokenPipeError:
+ # downstream consumer (head/grep -m) closed the pipe: a normal way to end a
+ # capture, not an error. Point stdout at devnull so interpreter shutdown does
+ # not raise on the final implicit flush.
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
+ finally:
+ if args.interactive and not forwarded.is_set():
+ # only claim what is true: the gate releases after 5 s and forwards anyway,
+ # so "never forwarded" must come from the forwarded flag, not the gate
+ print('rtt: -i stdin was never forwarded to the target (no input arrived, '
+ 'or the console closed first)', file=sys.stderr)
+ if args.interactive and not saw_output.is_set():
+ print('rtt: no target output within the window', file=sys.stderr)
+ # a late TERM landing during the up-to-12 s teardown must not skip the kill
+ # escalation and orphan the server -- cleanup is committed at this point
+ for _sig in (signal.SIGTERM, signal.SIGHUP):
+ with contextlib.suppress(ValueError, OSError):
+ signal.signal(_sig, signal.SIG_IGN)
+ con.close()
+ return rc
+
+
+if __name__ == '__main__':
+ sys.exit(main())