summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/hil/helper/hil_health.py48
-rwxr-xr-xtest/hil/helper/hil_lock.py15
-rw-r--r--test/hil/helper/hil_pool_check.py111
-rw-r--r--test/hil/helper/hil_report.py578
-rwxr-xr-xtest/hil/helper/hil_select.py524
-rw-r--r--test/hil/helper/hil_util.py478
-rw-r--r--test/hil/hil_ci.sh348
-rwxr-xr-xtest/hil/hil_flash.py6
-rwxr-xr-xtest/hil/hil_test.py1128
-rw-r--r--test/hil/test/stubs/hid.py76
-rw-r--r--test/hil/test/test_ci_metrics.py581
-rw-r--r--test/hil/test/test_ci_select.py2602
-rw-r--r--test/hil/test/test_hil_bounded.py1200
-rw-r--r--test/hil/test/test_hil_health.py118
-rw-r--r--test/hil/test/test_hil_report.py1173
-rw-r--r--test/hil/test/test_hil_rtt.py506
-rw-r--r--test/hil/test/test_hil_select.py689
-rw-r--r--test/hil/test/test_hil_util.py332
-rw-r--r--test/hil/tinyusb.json27
-rwxr-xr-xtest/hil/usbtest.py274
20 files changed, 7879 insertions, 2935 deletions
diff --git a/test/hil/helper/hil_health.py b/test/hil/helper/hil_health.py
index 92f0accc8..d78d0f220 100644
--- a/test/hil/helper/hil_health.py
+++ b/test/hil/helper/hil_health.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
-"""Shutting a wedged HIL run down: kill what the workers spawned, then report.
+"""Shutting a wedged HIL run down: kill what the workers spawned.
A device whose usbfs node is held by a D-state process cannot be freed -- SIGKILL is not
delivered in uninterruptible sleep -- so the goal is never to fix the rig from here. It is
@@ -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)
@@ -341,40 +336,3 @@ def kill_pool_children(pool, *extra) -> int:
# SIGKILL is asynchronous and a D-state task ignores it: only a confirmed survivor
# justifies the caller's power-cycle wording
return len(_kill_and_confirm(killed_pids)) if killed_pids else 0
-
-
-def write_timeout_report(report_dir: Path, boards, secs: int, md_name: str,
- banner: str = '', prefix: str = '') -> None:
- """Leave a report behind when the worker pool has to be abandoned.
-
- map_async is all-or-nothing, so a timeout loses every per-board result and the report
- dir would stay empty with no reason for the failure. Any prior attempt's markdown is
- kept below the banner."""
- # `prefix` carries the preflight rig-health verdict: the timeout aborts before
- # accumulate_report, so without it the report loses the one line saying WHY the pool
- # never finished. The '\n' stops Markdown lazy continuation pulling the banner into
- # the blockquote.
- try:
- # Built INSIDE the try: a roster entry without a 'name' key raises KeyError while
- # assembling the board list, and outside the try that escaped and stranded the
- # runner -- which is exactly what the broad handler below exists to prevent.
- head = (prefix + '\n' if prefix else '') + (banner or (
- f'**HIL run abandoned: worker pool timed out after {secs}s.**\n\n'
- f'No per-board results could be collected for this attempt, so the '
- f'table below (if any) is from an earlier one. Boards dispatched:\n\n'
- + '\n'.join(f'- {b.get("name", "?")}' for b in boards) + '\n'))
- report_dir.mkdir(parents=True, exist_ok=True)
- md_path = report_dir / md_name
- # Its own handler so it cannot take the write down with it: a report torn by an
- # attempt killed mid-write raises UnicodeDecodeError (a ValueError, and prior
- # reports always contain status emoji), which under a shared try skipped the write
- # entirely. Losing the old table is a nicety; losing the banner is the failure.
- try:
- prior = md_path.read_text(encoding='utf-8') if md_path.is_file() else ''
- except (OSError, ValueError):
- prior = ''
- md_path.write_text(head + (f'\n{prior}' if prior else ''), encoding='utf-8')
- except Exception as e: # noqa: BLE001
- # Deliberately broad: this is the first statement of the pool-abandon path, so ANY
- # escape skips kill_pool_children and os._exit and strands the runner.
- _p(f'warning: cannot write {md_name} to {report_dir}: {e}', flush=True)
diff --git a/test/hil/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 371aff1e1..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']] = {
@@ -328,7 +328,8 @@ def flash(board: dict, fw, allow_recovery: bool, probe_port: str, note: list) ->
if rc == 0:
return True
if rc == 127: # flasher binary missing: retries/probe recovery can't fix env
- note.append(f'flasher tool missing ({err}) — esptool needs the ESP-IDF env (get-idf)'
+ note.append(f'flasher tool missing ({err}) — esptool needs the ESP-IDF env '
+ f'(. "$IDF_PATH/export.sh")'
if board['flasher']['name'].lower() == 'esptool' else
f'flasher tool missing: {err}')
return False
@@ -359,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)
@@ -432,7 +473,7 @@ def build_example(board: dict, variant: str, example: str) -> int:
cmd = ['idf.py', '-C', f'examples/{example}',
'-B', f'cmake-build/cmake-build-{vcfg["name"]}/{example}',
'-G', 'Ninja', f'-DBOARD={name}', 'build']
- for d in board.get('build', {}).get('args', []) + vcfg.get('defines', []):
+ for d in vcfg.get('defines', []):
cmd.insert(-1, f'-D{d}')
if vcfg.get('flags'):
cmd.insert(-1, f'-DCFLAGS_CLI={vcfg["flags"]}')
@@ -445,8 +486,6 @@ def build_example(board: dict, variant: str, example: str) -> int:
cmd = [sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'build.py'),
'-b', name, '-T', Path(example).name,
'-j', str(max(1, (os.cpu_count() or _jobs) // _jobs))]
- for d in board.get('build', {}).get('args', []):
- cmd += ['-D', d]
if vcfg['name'] != name:
cmd += ['--build-name', vcfg['name']]
for d in vcfg.get('defines', []):
@@ -487,7 +526,8 @@ def ensure_fw(board: dict, variant: str, example: str, note: list):
rc = build_example(board, variant, example)
if rc == 127 and board['flasher']['name'].lower() == 'esptool':
_builds[key] = (None, 'no-env')
- note.append(f'cannot build {base}: ESP-IDF env missing (get-idf)')
+ note.append(f'cannot build {base}: ESP-IDF env missing '
+ f'(. "$IDF_PATH/export.sh")')
return None
if rc == 124: # hung build: a deps/cache retry cannot cure it, don't double the stall
_builds[key] = (None, 'timeout')
@@ -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
new file mode 100644
index 000000000..c93c8e6a1
--- /dev/null
+++ b/test/hil/helper/hil_report.py
@@ -0,0 +1,578 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+"""The HIL report document: one owner for hil_report.json and hil_report.md.
+
+The markdown IS a rendering of the sidecar -- every writer goes through render_report(), so
+a table can never contain something the JSON does not. This module owns the whole life of
+that document: the cell vocabulary, the one classifier both artifacts share, rendering, the
+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, 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
+ module is ALSO run as a script (hil-operator.md invokes it by path), and under
+ PYTHONSAFEPATH=1 -- which the suite's own MTP fixtures set -- sys.path[0] is not the
+ script dir, so any sibling import dies before argparse runs. Five lines beat that."""
+ try:
+ print(*args, **kwargs)
+ except (OSError, ValueError):
+ # ValueError too: printing to a CLOSED stream raises "I/O operation on closed
+ # file", and escaping here skips the containment path's os._exit.
+ pass
+
+REPORT_MD = 'hil_report.md'
+REPORT_JSON = 'hil_report.json'
+# The status vocabulary, shared by the code that WRITES a cell (hil_test's test runners) and
+# the code that reads one back (cell_state). One dict, so the human's table and the agent's
+# verdict cannot drift apart.
+REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '⚪'}
+BOUNDARY_CELL = 'same-PID boundary'
+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:
+ """(doc, readable) for the sidecar, coerced to the canonical shape.
+
+ hil_ci.sh uploads a sidecar as the --accumulate merge base, so a non-conforming one is
+ reachable from OUTSIDE the harness -- and every writer here runs on a path where a
+ TypeError costs the whole report. Coerce once, at the boundary, instead of guarding
+ each use: `banner: null` used to kill a fully successful run with a traceback and no
+ artifact at all, and `cells: null` sent write_timeout_report down its fallback so a
+ board that ate the whole pool guard was published as a pass.
+
+ `readable` is False only when a sidecar EXISTS but could not be parsed, or is absent --
+ both mean its rows are unrecoverable, which callers use to avoid destroying a markdown
+ that may still hold them."""
+ jpath = report_dir / REPORT_JSON
+ if not jpath.is_file():
+ return {'rows': [], 'banner': '', 'scope': '', 'caveat': ''}, False
+ try:
+ raw = json.loads(jpath.read_text())
+ if not isinstance(raw, dict):
+ raise ValueError('sidecar is not an object')
+ except (OSError, ValueError, TypeError):
+ return {'rows': [], 'banner': '', 'scope': '', 'caveat': ''}, False
+ rows = []
+ # isinstance, not `or []`: a sidecar with `rows: 1` iterates an int and raises outside
+ # the parse handler above.
+ for r in (raw.get('rows') if isinstance(raw.get('rows'), list) else []):
+ if not isinstance(r, dict) or 'board' not in r:
+ continue
+ cells = r.get('cells')
+ dur = r.get('duration')
+ # VALUES as well as keys: render_matrix does REPORT_CELL.get(v, v), which raises
+ # TypeError on an unhashable value, and cell_state does v.startswith. A non-str
+ # cell is corrupt, and dropping it renders blank -- "not run" -- which is the
+ # honest reading. Coercing it to str would make it classify as a PASS.
+ rows.append({'board': str(r['board']),
+ 'cells': {str(k): v for k, v in cells.items() if isinstance(v, str)}
+ if isinstance(cells, dict) else {},
+ 'duration': dur if isinstance(dur, str) else None})
+ text = lambda k: raw[k] if isinstance(raw.get(k), str) else ''
+ return {'rows': rows, 'banner': text('banner'), 'scope': text('scope'),
+ 'caveat': text('caveat')}, True
+
+
+def cell_state(v) -> str:
+ """'pass' | 'fail' | 'skip' for one report cell.
+
+ THE classifier -- the markdown tally and the per-board verdict both call this, so they
+ cannot disagree. 'fail' or a fail-icon prefix is a failure, 'skip' or a skip-icon prefix
+ is a skip, and EVERYTHING ELSE is a pass. That last arm is load-bearing: a passing test
+ may return a plain metric string ('480.0 MBps') that lands in the cell unprefixed, while
+ failures are guaranteed marked -- TestFail's docstring pins that its metric is
+ icon-prefixed precisely so render and tally treat it as a failure. Classifying unknown
+ shapes as fail here would publish a green table as a red verdict.
+
+ isinstance-guarded: cells are usually str but a caller may hand over None or a number,
+ and .startswith on those raises inside a report writer that must not raise."""
+ if v == 'fail' or (isinstance(v, str) and v.startswith(REPORT_CELL['fail'])):
+ return 'fail'
+ if v == 'skip' or (isinstance(v, str) and v.startswith(REPORT_CELL['skip'])):
+ return 'skip'
+ return 'pass'
+
+
+def render_matrix(rows_all: list) -> str:
+ """Render rows (list of (row_label, {example: status}, duration)) as an aligned
+ markdown matrix: columns = tests (bare names) centered, boards left-aligned,
+ per-row duration as the trailing column."""
+ seen = set()
+ for _, cells, _ in rows_all:
+ seen.update(cells)
+ if not seen:
+ return 'No tests were run.'
+
+ # metric-bearing columns pinned first, the rest alphabetical: stable regardless of the
+ # shuffled execution order
+ pinned = ['usbtest', 'cdc_msc_throughput', 'msc_file_explorer', 'msc_file_explorer_freertos']
+
+ def col_key(t):
+ name = t.rsplit('/', 1)[-1]
+ return (pinned.index(name) if name in pinned else len(pinned), name, t)
+
+ columns = sorted(seen, key=col_key)
+ headers = [c.rsplit('/', 1)[-1] for c in columns] + ['duration'] # bare example names
+
+ def cell(cells, col):
+ v = cells.get(col)
+ if v is None:
+ return ''
+ return REPORT_CELL.get(v, v) # status symbol, or a metric string (e.g. speed) verbatim
+
+ rows_vals = [(lbl, [cell(cells, c) for c in columns] + [dur or ''])
+ for lbl, cells, dur in rows_all]
+ board_hdr = 'Board'
+ # 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 = [_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)
+ sep = '| ' + '-' * board_w + ' | ' + ' | '.join(':' + '-' * (w - 2) + ':' for w in col_w) + ' |'
+ body = [line(lbl, vals) for lbl, vals in rows_vals]
+
+ # tally run cells (not-run cells are absent from the dicts). A cell is a bare status or
+ # a metric string carrying its own icon ("❌ 29/30"), so classify by the leading icon --
+ # through cell_state, the same call the per-board verdict makes.
+ kinds = [cell_state(v) for _, cells, _ in rows_all for v in cells.values()]
+ failed = kinds.count('fail')
+ skipped = kinds.count('skip')
+ passed = kinds.count('pass')
+ summary = (f'**{REPORT_CELL["pass"]} {passed} passed · {REPORT_CELL["fail"]} {failed} failed · '
+ f'{REPORT_CELL["skip"]} {skipped} skipped · blank not run**')
+
+ return summary + '\n\n' + '\n'.join([header, sep] + body)
+
+
+def render_report(doc: dict) -> str:
+ """The markdown IS a rendering of the sidecar. Every writer goes through here, so a
+ table can never contain something the JSON does not."""
+ # .get throughout, not subscripts: mark_report_abandoned renders a sidecar it did NOT
+ # write (hil_ci.sh reuses a persistent REMOTE_DIR, so it may be an older version's or
+ # a torn one) on the way to os._exit, and a KeyError there is not in its handler --
+ # it would unwind into multiprocessing's unbounded join and hang the runner it is
+ # trying to free. Same reason summarize() below reads cells as `r.get('cells') or {}`.
+ md = render_matrix([(r.get('board', '?'), r.get('cells') or {}, r.get('duration'))
+ for r in doc.get('rows') or [] if isinstance(r, dict)])
+ if doc.get('scope'):
+ # a scoped run's small table is otherwise indistinguishable from a full one, and
+ # it replaces the previous full table in the sticky PR comment
+ md = f'_Scoped run: {doc["scope"]}. Boards/tests not listed were not run._\n\n' + md
+ # banner, then caveat: a rig-health caveat outranks the table AND the scope note, and an
+ # abandon notice outranks even that -- the top of the report is where hil/SKILL.md tells
+ # the agent to look
+ if doc.get('banner'):
+ md = doc['banner'] + '\n' + md
+ if doc.get('caveat'):
+ md = doc['caveat'] + '\n' + md
+ return md
+
+
+def write_report(report_dir: Path, doc: dict) -> None:
+ """Write both artifacts from one document.
+
+ RAISES on failure, deliberately: every caller is on a path whose own handler exists to
+ report exactly this (write_timeout_report's _p warning, hil_test's fallback-of-the-
+ fallback). Swallowing OSError here made both of those dead code, so an unwritable or
+ root-owned report dir produced no artifact AND no message.
+
+ Renders BEFORE writing anything: committing the JSON first and then raising in
+ render_report left a sidecar saying "abandoned" beside a markdown still reading as a
+ clean green table -- the one invariant this module exists to hold."""
+ md = render_report(doc) + '\n'
+ report_dir.mkdir(parents=True, exist_ok=True)
+ (report_dir / REPORT_JSON).write_text(json.dumps(doc, indent=2) + '\n')
+ (report_dir / REPORT_MD).write_text(md, encoding='utf-8')
+
+
+def _abandon_notice(why: str) -> str:
+ # Wording is a CONTRACT: .claude/skills/hil/SKILL.md pins this banner as the case where
+ # "the table below IS this run's ... Report the results AND the abandonment". Calling
+ # the table partial would send the reading agent to re-run boards that already passed.
+ return (f'**HIL run abandoned: {why}** The table below was collected before the '
+ f'abandon; treat board results as unverified.\n')
+
+
+def _already_abandoned(doc: dict) -> bool:
+ """Whether THIS attempt already recorded how it ended.
+
+ `caveat` only. It used to check `banner` too, because hil_test.py folded its abandon
+ notices in there -- but banner is carried across an --accumulate retry by design, so a
+ stale notice from an earlier attempt silenced a genuinely new abandon and the run's own
+ failure went unrecorded. banner now carries rig HEALTH (which describes the conditions
+ the cells were collected under, and so must persist); caveat carries the run's OUTCOME
+ (which must not)."""
+ return '**HIL run ab' in doc.get('caveat', '')
+
+
+def _stamp_markdown(report_dir: Path, notice: str) -> None:
+ """Last line of defence: prepend the notice to the markdown itself.
+
+ pr_comment.yml cats only hil_report.md, so a path that gives up here publishes a clean
+ green table under an abandoned, non-zero job. Master did this unconditionally."""
+ mpath = report_dir / REPORT_MD
+ if not mpath.is_file():
+ return
+ # errors='replace' and catch ValueError: a torn report or a LANG=C locale raises
+ # UnicodeDecodeError -- NOT an OSError -- straight past os._exit.
+ body = mpath.read_text(encoding='utf-8', errors='replace')
+ if '**HIL run ab' not in body[:2000]:
+ mpath.write_text(notice + '\n' + body, encoding='utf-8')
+
+
+def mark_report_abandoned(report_dir: Path, why: str) -> None:
+ """Stamp an existing report as abandoned, in BOTH artifacts.
+
+ Best-effort and silent: this runs while the interpreter is being torn down, and an
+ exception here hangs the process in multiprocessing's unbounded join()."""
+ notice = _abandon_notice(why)
+ try:
+ doc, readable = _load(report_dir)
+ if readable:
+ if _already_abandoned(doc):
+ return # whoever got there first wins, WRITE included
+ doc['caveat'] = notice
+ write_report(report_dir, doc)
+ return
+ except (OSError, ValueError, TypeError, AttributeError):
+ pass # fall through -- a failure here must not cost the stamp entirely
+ # Unreadable sidecar, or the document write failed. Either way the markdown is what
+ # the PR comment reads, so stamp it directly rather than giving up.
+ try:
+ _stamp_markdown(report_dir, notice)
+ except (OSError, ValueError, TypeError, AttributeError):
+ pass
+
+
+def mark_report_no_boards(report_dir: Path, msg: str, fresh: bool = True) -> None:
+ """Record that the board filters intersected to nothing.
+
+ `fresh` mirrors hil_test's own flag, because this runs BEFORE the fresh wipe: without
+ it a fresh run whose filter emptied re-published the PREVIOUS run's green rows under
+ this run's red job -- the stale-table failure it exists to prevent. An --accumulate run
+ keeps them, since nothing this attempt did invalidates them."""
+ try:
+ doc, _ = _load(report_dir)
+ if not fresh and _already_abandoned(doc):
+ # SKILL.md gives the two notices OPPOSITE rules, and an abandon outranks a
+ # filter that matched nothing -- do not overwrite the record of a failed run.
+ # Only while ACCUMULATING, though: this runs before the fresh wipe, so guarding
+ # a fresh run would leave the previous attempt's rows AND its abandon notice
+ # published as this run's.
+ return
+ # A fresh run carries NOTHING from the prior sidecar -- rows, banner and scope
+ # alike, matching accumulate_report, which builds from an empty prior when fresh.
+ # Resetting only rows republished a stale rig-health note and a stale scope line
+ # under this run's notice, from a leftover or uploaded sidecar.
+ prior = {'rows': [], 'banner': '', 'scope': ''} if fresh else doc
+ write_report(report_dir, {'rows': prior['rows'], 'banner': prior['banner'],
+ 'scope': prior['scope'],
+ 'caveat': f'**HIL run selected no boards.** {msg}\n'})
+ except (OSError, ValueError, TypeError, AttributeError):
+ pass # loud on stdout already; the exit code is what the job reads
+
+
+def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = '',
+ banner: str = '', caveat: str = '') -> str:
+ """Merge this run's results into json in report_dir, then (re)write
+ the markdown matrix to md. `fresh` (a first run, no --accumulate)
+ starts a new report; otherwise a re-run accumulates so boards/tests that
+ already passed are preserved while re-run cells are updated. `scope` names the
+ board filter, if any, so a scoped table is not mistaken for a full one.
+ Returns the md.
+
+ `mret` is hil_test.py's worker-result shape (name, err, fts, rows, ...), so this one
+ function knows something about its caller that the rest of the module does not. Folding
+ mret into rows could live in hil_test and only the merge here, but that would rewrite
+ the subtle parts -- stale board-locked clearing, BOUNDARY_CELL dropping, duration=None
+ preservation -- for a tidier seam. Data-shape coupling, not an import cycle."""
+ # ONE canonical load: a sidecar reaching here may have been uploaded by hil_ci.sh as
+ # the merge base, so it is untrusted input. `banner` carries forward -- it describes
+ # the conditions the earlier cells were collected under, and the .failed spec re-runs
+ # only FAILURES so those passes are never re-earned. `caveat` does NOT: it records how
+ # a RUN ENDED, and this attempt has not ended yet. Carrying it made a clean retry
+ # publish "HIL run abandoned" over a run where nothing was abandoned.
+ prior = {'rows': [], 'banner': ''}
+ if not fresh:
+ prior, _ = _load(report_dir)
+ acc = {r['board']: [dict(r['cells']), r['duration']] for r in prior['rows']}
+ prior_banner = prior['banner']
+
+ # current cells override prior for boards/tests that ran; a filtered run reports
+ # duration None, keeping the previous full-run value
+ for name, _, _, rows, *_ in mret:
+ if rows and not any(LOCKED_CELL in cells for _, cells, _ in rows):
+ # board ran for real: clear a stale lock-failure cell (its row is keyed by
+ # board name; test rows may be variant names)
+ stale = acc.get(name)
+ if stale is not None:
+ stale[0].pop(LOCKED_CELL, None)
+ # and the pool-timeout mark: write_timeout_report stamps it on a board that
+ # 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
+ del acc[name]
+ for row_label, cells, dur in rows:
+ 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:
+ row[0].pop(BOUNDARY_CELL, None)
+ row[0].update(cells)
+ if dur is not None:
+ row[1] = dur
+
+ report_dir.mkdir(parents=True, exist_ok=True)
+ # by LINE, deduped: attempts repeat the same caveat far more often than they add a new
+ # one, and three copies of the D-state note reads as three incidents
+ seen, merged = set(), []
+ for line in (prior_banner + banner).splitlines():
+ if line.strip() and line not in seen:
+ seen.add(line)
+ merged.append(line)
+ banner = '\n'.join(merged) + '\n' if merged else ''
+ doc = {'rows': [{'board': k, 'cells': c, 'duration': d} for k, (c, d) in acc.items()],
+ 'banner': banner, 'scope': scope, 'caveat': caveat}
+ # through write_report, not hand-rolled: writing the JSON and only then rendering is
+ # the ordering write_report exists to forbid -- a render failure left the sidecar ahead
+ # of the markdown, which is the one invariant this module holds.
+ write_report(report_dir, doc)
+ return render_report(doc)
+
+
+def _write_stuck_over_prior_md(report_dir: Path, doc: dict) -> None:
+ """Sidecar unrecoverable: rebuild it from the stuck rows alone, but leave the
+ markdown's existing table beneath the caveat rather than throwing real results away.
+
+ The one place the md-is-a-rendering-of-the-json invariant is deliberately suspended,
+ because there is no readable json left for it to be a rendering of."""
+ try:
+ prior = (report_dir / REPORT_MD).read_text(encoding='utf-8')
+ except (OSError, ValueError):
+ prior = ''
+ # Say so explicitly: those rows exist only as rendered text, so no later --accumulate
+ # can merge them back. Claiming the sidecar represents them would be false.
+ note = ('_The table below is a previous attempt\'s rendered output. The sidecar could '
+ 'not be read, so those rows are NOT in it and will not survive another run._\n')
+ head = (doc['banner'] + '\n' if doc['banner'] else '') + doc['caveat'] + '\n' + note
+ body = prior if prior.strip() else render_matrix(
+ [(r['board'], r['cells'], r['duration']) for r in doc['rows']])
+ report_dir.mkdir(parents=True, exist_ok=True)
+ (report_dir / REPORT_JSON).write_text(json.dumps(doc, indent=2) + '\n')
+ (report_dir / REPORT_MD).write_text(head + '\n' + body, encoding='utf-8')
+
+
+def write_timeout_report(report_dir: Path, boards, secs: int,
+ 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
+ dir would stay empty with no reason for the failure. Any prior attempt's rows are kept
+ and each stuck board is marked with a POOL_TIMEOUT_CELL beside them.
+
+ `prefix` is the preflight rig-health verdict and goes to the BANNER, where rig health
+ lives and where an --accumulate retry carries it forward; the abandon notice goes to
+ the caveat, which does not carry. Folding both into the caveat is what made a clean
+ retry report an abandonment that had not happened."""
+ try:
+ # names INSIDE the try: a roster entry that is not a dict raises here, and outside
+ # it that escaped and stranded the runner.
+ names = [b.get('name', '?') if isinstance(b, dict) else '?' for b in boards]
+ 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 {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']
+ by_board = {r['board']: r for r in rows}
+ for name in names:
+ row = by_board.get(name)
+ if row is None:
+ 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'][cell] = 'fail'
+ out = {'rows': rows, 'scope': doc['scope'], 'caveat': caveat,
+ 'banner': ((doc['banner'] + prefix) if prefix not in doc['banner']
+ else doc['banner'])}
+ if not readable and (report_dir / REPORT_MD).is_file():
+ # `readable` covers ABSENT as well as torn: an absent sidecar beside an intact
+ # markdown used to re-render from the stuck row alone and destroy real results.
+ _write_stuck_over_prior_md(report_dir, out)
+ return
+ write_report(report_dir, out)
+ except Exception as e: # noqa: BLE001
+ # Deliberately broad: this is the first statement of the pool-abandon path, so ANY
+ # escape skips kill_pool_children and os._exit and strands the runner.
+ _p(f'warning: cannot write {REPORT_MD} to {report_dir}: {e}', flush=True)
+ try:
+ # Same wording as above and the same guarded name extraction -- the fallback
+ # used to re-derive b.get("name") outside any try and raise identically, so a
+ # malformed roster left NO artifact at all.
+ names = [b.get('name', '?') if isinstance(b, dict) else '?' for b in boards]
+ head = (prefix + '\n' if prefix else '') + (banner or (
+ f'**HIL run abandoned: worker pool timed out after {secs}s.**\n\n'
+ f'No per-board results could be collected for this attempt, so the table '
+ f'below (if any) is from an earlier one. Boards dispatched:\n\n'
+ + '\n'.join(f'- {n}' for n in names) + '\n'))
+ try:
+ prior = (report_dir / REPORT_MD).read_text(encoding='utf-8')
+ except (OSError, ValueError):
+ prior = ''
+ report_dir.mkdir(parents=True, exist_ok=True)
+ (report_dir / REPORT_MD).write_text(
+ head + (f'\n{prior}' if prior else ''), encoding='utf-8')
+ except Exception as e2: # noqa: BLE001
+ _p(f'warning: fallback {REPORT_MD} write failed too: {e2}', flush=True)
+
+
+def variants_of(cfg: dict, board: str) -> list:
+ for b in cfg.get('boards', []):
+ if b['name'] == board:
+ return [v['name'] for v in (b.get('variant') or [])] or [board]
+ return [board]
+
+
+def summarize(cfg: dict, boards: list, report: dict) -> dict:
+ # .get, not a subscript: this is the one reader an agent's verdict depends on, and a
+ # row without 'board' used to kill the CLI with a traceback and no results at all --
+ # hil-validate.js then reports every board as "hil-operator returned no entry".
+ rows = {r['board']: r.get('cells') or {}
+ for r in (report.get('rows') or [])
+ if isinstance(r, dict) and 'board' in r}
+ owner = {v['name']: b['name'] for b in cfg.get('boards', [])
+ for v in (b.get('variant') or [])}
+ results = []
+ for board in boards:
+ names = variants_of(cfg, board)
+ mine = {n: rows[n] for n in names if n in rows}
+ # a variant name that is neither declared nor prefixed cannot be attributed; the
+ # `<board>-` fallback only helps ad-hoc builds, it is not the primary path. It must
+ # also never steal a row DECLARED by another board: a declared variant need not start
+ # with its own board's name, so it may happen to start with this board's name plus '-'.
+ mine.update({n: c for n, c in rows.items()
+ if n.startswith(f'{board}-') and n not in mine
+ and owner.get(n, board) == board})
+ # the BOARD-name row too: hil_test writes lock contention and pool timeouts keyed
+ # by board name, but variants_of returns only DECLARED variant names -- and
+ # nanoch32v203 / ch32v307v_r1_1v0 declare none equal to their board name. Without
+ # this those rows are invisible, so a lock held by concurrent CI is published as a
+ # hardware FAIL and hil-validate.js never retries it.
+ if board in rows and board not in mine:
+ mine[board] = rows[board]
+ if not mine:
+ results.append({'board': board, 'ran': False, 'pass': False, 'locked': False,
+ 'detail': 'no report row for this board'})
+ continue
+ # 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. 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()):
+ for test, val in sorted(cells.items()):
+ if test == LOCKED_CELL:
+ continue
+ if cell_state(val) == 'fail':
+ bad.append(f'{vname} {test}: {val}')
+ ok = not bad and not locked
+ if locked:
+ detail = 'held by another holder; not flashed'
+ elif bad:
+ detail = '; '.join(bad)
+ else:
+ detail = f'{len(mine)} variant(s), {sum(len(c) for c in mine.values())} cell(s) ok'
+ results.append({'board': board, 'ran': True, 'pass': ok, 'locked': locked,
+ 'detail': detail})
+ # `caveat` too: an abandoned or no-boards run says so THERE, and this JSON is all
+ # an agent gets -- leaving it in the sidecar puts it back where only a human looks.
+ return {'results': results, 'banner': report.get('banner', ''),
+ 'caveat': report.get('caveat', '')}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ ap.add_argument('config_file')
+ ap.add_argument('-b', '--board', action='append', default=[],
+ help='boards to report on; default: every board in the config')
+ ap.add_argument('--report-dir', default='.', help=f'where {REPORT_JSON} lives (default: cwd)')
+ a = ap.parse_args()
+
+ cfg = json.loads(Path(a.config_file).read_text())
+ boards = a.board or [b['name'] for b in cfg.get('boards', [])]
+ jpath = Path(a.report_dir) / REPORT_JSON
+ if not jpath.is_file():
+ print(f'error: {jpath} not found -- did hil_test.py run in this directory?',
+ file=sys.stderr)
+ return 1
+ # through _load, like every writer: feeding raw JSON to summarize left the one reader an
+ # agent's verdict depends on crashing on the malformed sidecars the writers tolerate.
+ doc, _ = _load(Path(a.report_dir))
+ json.dump(summarize(cfg, boards, doc), sys.stdout, indent=2)
+ print()
+ return 0
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/test/hil/helper/hil_select.py b/test/hil/helper/hil_select.py
deleted file mode 100755
index f0d4f0b9f..000000000
--- a/test/hil/helper/hil_select.py
+++ /dev/null
@@ -1,524 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MIT
-"""PR-diff -> HIL selection: which rig boards and which tests a change can affect.
-
-Stdlib-only (runs on bare CI runners; imports hil_util for the example rosters,
-never hil_test/pyserial — test_hil_util.BottomLayer enforces the stdlib closure).
-Fail-open: any file no rule classifies forces the full matrix. See
-docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md.
-
-JSON: full, boards (name -> 'all' | [tests]), families (bsp families the diff
-touches, including ones with no rig board - build-only consumers such as /pre-pr
-sample from these), args (hil_test.py args per config) and args_flasher (the same
-args split by each board's flasher, for CI legs that split one rig by flasher).
-"""
-import argparse
-import functools
-import glob
-import json
-import os
-import re
-import subprocess
-import sys
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # helper/ scripts import via the test/hil root
-from helper.hil_util import device_tests, dual_tests, host_test
-
-ALL_TESTS = {'device': device_tests, 'dual': dual_tests, 'host': host_test}
-
-# class dir -> config macro suffix exceptions (rule 3); dfu is per-file, handled inline
-NET_MACROS = ('ECM_RNDIS', 'NCM')
-
-_NONCODE_RE = re.compile(
- r'^(docs/|\.claude/|.*\.(md|rst)$|LICENSE)')
-_FULL_RE = re.compile(
- r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
- r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|'
- r'tools/build\.py$|tools/get_deps\.py$|tools/cmake/|hw/mcu/|lib/|'
- r'hw/bsp/(family_support\.cmake|board_api\.h|board\.c|ansi_escape\.h)$|'
- r'examples/build_system/|examples/CMakeLists\.txt$|'
- # board_test is HIL infrastructure, not a test: hil_test.py flashes it to park
- # every board (variant boundary + end-of-board teardown), so every board depends on it
- r'examples/device/board_test/)')
-
-# --no-renames: with rename detection git reports only a rename's destination, so code
-# moved out of an HIL-relevant path would be classified by its new path alone
-GIT_DIFF_ARGV = ['git', 'diff', '--no-renames', '--name-only']
-
-
-def test_role(test: str) -> str:
- return test.split('/', 1)[0] # 'device' | 'dual' | 'host'
-
-
-def board_roles(board: dict) -> set:
- t = board.get('tests', {})
- roles = set()
- if t.get('device'):
- roles.add('device')
- if t.get('host'):
- roles.add('host')
- if t.get('dual'):
- roles.update(('device', 'host'))
- for only in t.get('only', []):
- r = test_role(only)
- roles.update(('device', 'host') if r == 'dual' else (r,))
- return roles
-
-
-def board_tests(board: dict) -> list:
- """Every test this board would run today (mirrors hil_test.test_board's default)."""
- t = board.get('tests', {})
- if 'only' in t:
- run = list(t['only'])
- else:
- run = []
- if t.get('device'):
- run += device_tests
- if t.get('dual'):
- run += dual_tests
- if t.get('host'):
- run += host_test
- return [x for x in run if x not in t.get('skip', [])]
-
-
-# cached: called per changed file x roster board, and the tree doesn't change mid-run
[email protected]_cache(maxsize=None)
-def board_family(board_name: str, repo_root: str):
- hits = glob.glob(os.path.join(repo_root, 'hw/bsp/*/boards', board_name))
- return os.path.basename(os.path.dirname(os.path.dirname(hits[0]))) if hits else None
-
-
-# `if (OPTION STREQUAL "1")` guards in family_support.cmake, and the option tokens
-# a roster entry passes to the build (NAME=VALUE / -DNAME=VALUE)
-_CM_IF_RE = re.compile(r'if\s*\(')
-_CM_ELSE_RE = re.compile(r'else(if)?\s*\(')
-_CM_ENDIF_RE = re.compile(r'endif\s*\(')
-_CM_OPT_RE = re.compile(r'if\s*\(\s*\$?\{?([A-Za-z_]\w*)\}?\s+STREQUAL\s+"?1"?\s*\)')
-_CM_PORT_RE = re.compile(r'src/portable/((?:[^/\s]+/)?[^/\s]+)/')
-_FALSY = ('', '0', 'off', 'false', 'no')
-
-
[email protected]_cache(maxsize=None)
-def port_option_gates(repo_root: str) -> dict:
- """port dir -> build options that compile it regardless of the board's family
- file, e.g. {'analog/max3421': {'MAX3421_HOST'}} from family_support.cmake."""
- gates = {}
- try:
- text = open(os.path.join(repo_root, 'hw/bsp/family_support.cmake')).read()
- except OSError:
- return gates
- stack = [] # one entry per open if(): its option, or None
- for line in text.splitlines():
- line = line.strip()
- if _CM_IF_RE.match(line):
- m = _CM_OPT_RE.match(line)
- stack.append(m.group(1) if m else None)
- elif _CM_ELSE_RE.match(line):
- if stack:
- stack[-1] = None # the guard doesn't hold in this branch
- elif _CM_ENDIF_RE.match(line):
- if stack:
- stack.pop()
- opts = {o for o in stack if o}
- m = _CM_PORT_RE.search(line)
- if opts and m:
- gates.setdefault(m.group(1), set()).update(opts)
- return gates
-
-
-_CM_SET_RE = re.compile(r'set\s*\(\s*([A-Za-z_]\w*)\s+([^)\s]+)\s*\)')
-
-
-# cached: called per changed portable file x roster board
[email protected]_cache(maxsize=None)
-def bsp_board_options(board_name: str, repo_root: str) -> frozenset:
- """Build options a board turns on in its own BSP: `set(<OPT> <value>)` in
- hw/bsp/<family>/boards/<board>/board.cmake, e.g. MAX3421_HOST on the espressif
- and rp2040 max3421 boards. CMake only - HIL CI builds nothing with Make, so a
- board.mk-only option (e.g. nrf5340dk's MAX3421_HOST) compiles no port here."""
- fam = board_family(board_name, repo_root)
- if not fam:
- return frozenset()
- path = os.path.join(repo_root, 'hw/bsp', fam, 'boards', board_name, 'board.cmake')
- try:
- text = open(path).read()
- except OSError:
- return frozenset()
- out = set()
- for line in text.splitlines():
- line = line.strip()
- if line.startswith('#'):
- continue
- m = _CM_SET_RE.match(line)
- if m and m.group(2).strip('"').lower() not in _FALSY:
- out.add(m.group(1))
- return frozenset(out)
-
-
-def board_options(board: dict, repo_root: str) -> set:
- """Build options a board has truthy: the roster entry's build.args plus each
- variant's defines (NAME=VALUE) and raw CFLAGS (-DNAME=VALUE), plus whatever its
- own board.cmake sets (a board can enable a gated port without the roster saying so)."""
- toks = list(board.get('build', {}).get('args', []))
- for v in board.get('variant', []):
- toks += list(v.get('defines', []))
- toks += v.get('flags', '').split()
- out = set(bsp_board_options(board['name'], repo_root))
- for t in toks:
- name, _, val = (t[2:] if t.startswith('-D') else t).partition('=')
- if name and val.strip().strip('"').lower() not in _FALSY:
- out.add(name.strip())
- return out
-
-
[email protected]_cache(maxsize=None)
-def port_families(port_dir: str, repo_root: str) -> set:
- """Board families that compile this src/portable dir. CMake only: HIL CI builds
- every board with CMake, so a port wired up in family.mk alone is compiled for no
- HIL board and must not select one. family.cmake lists portable sources directly
- for most families; espressif instead references them from a nested component
- CMakeLists.txt (hw/bsp/espressif/components/tinyusb_src/CMakeLists.txt)."""
- fams = set()
- bsp_root = os.path.join(repo_root, 'hw/bsp')
- # trailing '/' so a port dir is not a prefix of a sibling: bare 'microchip/pic'
- # would otherwise match '.../microchip/pic32mz/...' and inherit its families
- needle = port_dir + '/'
- for f in glob.glob(os.path.join(bsp_root, '*/family.cmake')) + \
- glob.glob(os.path.join(bsp_root, '*/components/*/CMakeLists.txt')):
- try:
- if needle in open(f).read():
- fam = os.path.relpath(f, bsp_root).split(os.sep, 1)[0]
- fams.add(fam)
- except OSError:
- pass
- return fams
-
-
-_CLS_INC_RE = re.compile(r'#\s*include\s*[<"]class/([^/"<>]+)/([^"<>]+)[">]')
-
-
[email protected]_cache(maxsize=None)
-def class_include_edges(repo_root: str) -> dict:
- """'<class>/<header>' -> the other class dirs that include it. A class header
- pulled in by a second class ships in every firmware enabling that second class:
- src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, and
- net_device.h includes class/cdc/cdc.h. The class rule derives macros from the
- directory name alone, so without this edge a change to the included header
- selects only its own class's examples - and on a board that skips those (e.g.
- metro_m4_express skips audio_test_freertos), nothing at all.
-
- Derived from the actual #include lines rather than a hand-written table so it
- cannot rot when a class picks up or drops a cross-class include."""
- edges = {}
- for f in sorted(glob.glob(os.path.join(repo_root, 'src/class/*/*.[ch]'))):
- cls = os.path.basename(os.path.dirname(f))
- try:
- text = open(f).read()
- except OSError:
- continue
- for inc_cls, inc_hdr in _CLS_INC_RE.findall(text):
- if inc_cls != cls:
- edges.setdefault(f'{inc_cls}/{inc_hdr}', set()).add(cls)
- return edges
-
-
-def class_macros(cls: str, base: str, prefix: str) -> list:
- """Config macros that compile a class dir's code, for role prefix TUD/TUH.
- `base` refines dfu only (it splits DFU from DFU_RUNTIME per file); pass '' for
- a class reached through an include edge, where the widest set is correct."""
- if cls == 'net':
- return [f'CFG_{prefix}_{m}' for m in NET_MACROS]
- if cls == 'dfu':
- if base.startswith('dfu_rt'):
- return [f'CFG_{prefix}_DFU_RUNTIME']
- if base.startswith('dfu_device') or base.startswith('dfu_host'):
- return [f'CFG_{prefix}_DFU']
- return [f'CFG_{prefix}_DFU', f'CFG_{prefix}_DFU_RUNTIME']
- return [f'CFG_{prefix}_{cls.upper()}']
-
-
-def _config_enables(cfg_path: str, macros) -> bool:
- try:
- text = open(cfg_path).read()
- except OSError:
- return False
- return any(re.search(rf'#define\s+{m}\s+\(?\s*0*[1-9]', text) for m in macros)
-
-
-def roster_only_tests(all_boards) -> set:
- """Test paths that only appear in a roster board's tests.only list (e.g.
- espressif boards), not in the shared device/dual/host_test lists."""
- out = set()
- for b in all_boards:
- out.update(b.get('tests', {}).get('only', []))
- return out
-
-
-def class_examples(macros, role: str, repo_root: str, extra_tests: set) -> set:
- """Tests (from role's + dual lists, plus roster-only-list tests of that role)
- whose example config enables any macro."""
- pool = role_tests({role}, extra_tests)
- out = set()
- for test in pool:
- cfg = os.path.join(repo_root, 'examples', test, 'src', 'tusb_config.h')
- if _config_enables(cfg, macros):
- out.add(test)
- return out
-
-
-def role_tests(roles: set, extras: set) -> set:
- """Every test for the given role(s): each role's own list + dual tests,
- plus roster-only-list tests (extras) matching those roles or 'dual'."""
- pool = set(dual_tests)
- for r in roles:
- pool |= set(ALL_TESTS[r])
- pool |= {t for t in extras if test_role(t) in roles or test_role(t) == 'dual'}
- return pool
-
-
-class _Sel:
- """Accumulates contributions. board->set(tests) plus 'all-board' markers."""
- def __init__(self):
- self.full = False
- self.by_board = {} # name -> set of tests, or 'all'
- self.roles = set() # roles touched by any contribution
- self.families = set() # bsp families touched (incl. off-rig ones: build-only consumers)
- self.reasons = []
-
- def add(self, boards, tests, reason):
- """tests: 'all' or iterable of test paths."""
- self.reasons.append(reason)
- for b in boards:
- cur = self.by_board.get(b)
- if tests == 'all' or cur == 'all':
- self.by_board[b] = 'all'
- else:
- self.by_board[b] = (cur or set()) | set(tests)
-
- def force_full(self, reason):
- self.full = True
- self.reasons.append(reason)
-
-
-def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel):
- base = os.path.basename(path)
- if _NONCODE_RE.match(path):
- s.reasons.append(f'{path}: non-code, no contribution')
- return
- if _FULL_RE.match(path):
- s.force_full(f'{path}: core/infra -> full matrix')
- return
-
- m = re.match(r'src/portable/((?:[^/]+/)?[^/]+)/', path)
- if m:
- port = m.group(1)
- if re.match(r'(dcd_|.*_device)', base):
- roles = {'device'}
- elif re.match(r'(hcd_|.*_host)', base):
- roles = {'host'}
- else:
- roles = {'device', 'host'}
- fams = port_families(port, repo_root)
- if not fams:
- # no family references this port: either a new/renamed port dir or a
- # family.cmake layout the scan misses - widen instead of contributing nothing
- s.force_full(f'{path}: port {port} maps to no board family -> full matrix')
- return
- s.families.update(fams)
- # a board can also pull the port in through a build option (e.g. MAX3421_HOST=1
- # from the roster on metro_m4_express, or from its own board.cmake), which its
- # family file never names
- gates = port_option_gates(repo_root).get(port, set())
- boards = [b['name'] for b in roster_boards
- if (board_family(b['name'], repo_root) in fams or
- (gates and board_options(b, repo_root) & gates)) and (board_roles(b) & roles)]
- tests = role_tests(roles, extras)
- s.roles.update(roles)
- why = f'{path}: port {port} -> families {sorted(fams)}'
- if gates:
- why += f' + option {sorted(gates)}'
- s.add(boards, tests, f'{why} -> boards {boards} ({"/".join(sorted(roles))})')
- return
-
- m = re.match(r'src/class/([^/]+)/', path)
- if m:
- cls = m.group(1)
- if re.search(r'_device\.[ch]$', base):
- roles = {'device'}
- elif re.search(r'_host\.[ch]$', base):
- roles = {'host'}
- else:
- roles = {'device', 'host'}
- # this file's own class, plus any class whose headers include it
- via = sorted(class_include_edges(repo_root).get(f'{cls}/{base}', ()))
-
- def macros(prefix):
- return (class_macros(cls, base, prefix) +
- [m2 for c in via for m2 in class_macros(c, '', prefix)])
- tests = set()
- if 'device' in roles:
- tests |= class_examples(macros('TUD'), 'device', repo_root, extras)
- if 'host' in roles:
- tests |= class_examples(macros('TUH'), 'host', repo_root, extras)
- boards = [b['name'] for b in roster_boards if board_roles(b) & roles]
- s.roles.update(roles)
- why = f'{path}: class {cls}' + (f' (+ included by {via})' if via else '')
- s.add(boards, tests, f'{why} -> {sorted(tests)} ({"/".join(sorted(roles))})')
- return
-
- m = re.match(r'src/(device|host)/', path)
- if m:
- role = m.group(1)
- boards = [b['name'] for b in roster_boards if role in board_roles(b)]
- s.roles.add(role)
- s.add(boards, role_tests({role}, extras), f'{path}: core {role} stack -> all {role} tests')
- return
-
- m = re.match(r'hw/bsp/([^/]+)/(?:boards/([^/]+)/)?', path)
- if m:
- fam, brd = m.group(1), m.group(2)
- s.families.add(fam)
- if brd:
- boards = [b['name'] for b in roster_boards if b['name'] == brd]
- why = f'{path}: bsp board {brd}'
- else:
- boards = [b['name'] for b in roster_boards
- if board_family(b['name'], repo_root) == fam]
- why = f'{path}: bsp family {fam}'
- s.roles.update(('device', 'host'))
- s.add(boards, 'all', f'{why} -> boards {boards}')
- return
-
- m = re.match(r'examples/(device|host|dual)/([^/]+)/', path)
- if m:
- test = f'{m.group(1)}/{m.group(2)}'
- known = any(test in pool for pool in ALL_TESTS.values()) or test in extras
- if known:
- boards = [b['name'] for b in roster_boards]
- role = test_role(test)
- s.roles.update(('device', 'host') if role == 'dual' else (role,))
- s.add(boards, [test], f'{path}: example -> {test} on all boards')
- else:
- s.reasons.append(f'{path}: example not in HIL lists, no contribution')
- return
-
- s.force_full(f'{path}: unclassified -> full matrix')
-
-
-def classify(changed_files, repo_root, rosters):
- all_boards = []
- seen = set()
- for _, boards in rosters:
- for b in boards:
- if b['name'] not in seen:
- seen.add(b['name'])
- all_boards.append(b)
-
- extras = roster_only_tests(all_boards)
- s = _Sel()
- # no early exit once full: keep classifying so `families` still reports every
- # family the diff touches (build-only consumers need it). Nothing after the first
- # force_full can change full/boards/args - the full branch below ignores by_board.
- for path in changed_files:
- _classify_one(path, repo_root, all_boards, extras, s)
-
- if s.full:
- return {'full': True, 'boards': {b['name']: 'all' for b in all_boards},
- 'families': sorted(s.families), 'reasons': s.reasons}
-
- # role pruning: single-role selections drop the other role's tests and boards
- by_name = {b['name']: b for b in all_boards}
- out = {}
- for name, tests in s.by_board.items():
- allowed = board_tests(by_name[name])
- if tests == 'all':
- kept = list(allowed)
- else:
- kept = [t for t in allowed if t in tests]
- if s.roles and s.roles != {'device', 'host'}:
- role = next(iter(s.roles))
- kept = [t for t in kept if test_role(t) in (role, 'dual')]
- if kept:
- out[name] = 'all' if set(kept) == set(allowed) else sorted(kept)
- return {'full': False, 'boards': out, 'families': sorted(s.families),
- 'reasons': s.reasons}
-
-
-def _board_args(name, chosen) -> list:
- parts = [f'-b {name}']
- if chosen != 'all':
- parts.append(f'-bt {name}:{",".join(chosen)}')
- return parts
-
-
-def selection_args(sel, rosters):
- """hil_test.py args per config. Empty means either 'full matrix' or 'nothing
- selected' - callers must read sel['full'] to tell them apart."""
- args = {}
- for cfg_path, boards in rosters:
- parts = []
- if not sel['full']:
- for b in boards:
- chosen = sel['boards'].get(b['name'])
- if chosen is not None:
- parts += _board_args(b['name'], chosen)
- args[os.path.basename(cfg_path)] = ' '.join(parts)
- return args
-
-
-def selection_args_by_flasher(sel, rosters):
- """{config: {flasher name: args}}. CI runs one rig as several jobs split by
- flasher (esptool vs the rest); each must gate on its own subset, otherwise the
- other leg runs a filter matching zero boards and reports a vacuous green."""
- out = {}
- for cfg_path, boards in rosters:
- per = {}
- if not sel['full']:
- for b in boards:
- chosen = sel['boards'].get(b['name'])
- if chosen is None:
- continue
- per.setdefault(b.get('flasher', {}).get('name', ''), []).extend(
- _board_args(b['name'], chosen))
- out[os.path.basename(cfg_path)] = {f: ' '.join(p) for f, p in per.items()}
- return out
-
-
-def changed_files_from_git(base, repo_root):
- mb = subprocess.run(['git', 'merge-base', 'HEAD', base], cwd=repo_root,
- capture_output=True, text=True, check=True).stdout.strip()
- diff = subprocess.run(GIT_DIFF_ARGV + [f'{mb}..HEAD'], cwd=repo_root,
- capture_output=True, text=True, check=True).stdout
- return [l for l in diff.splitlines() if l.strip()]
-
-
-def main():
- ap = argparse.ArgumentParser(description=__doc__)
- g = ap.add_mutually_exclusive_group(required=True)
- g.add_argument('--base', help='git ref to diff against (merge-base..HEAD)')
- g.add_argument('--diff-file', help='newline-separated changed-file list')
- ap.add_argument('configs', nargs='+', help='rig roster JSON file(s)')
- a = ap.parse_args()
-
- # test/hil/helper/ -> repo root is FOUR levels up; three left this at <repo>/test
- # after the helper/ move and every repo-relative glob silently matched nothing
- repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
- rosters = []
- for c in a.configs:
- with open(c) as f:
- rosters.append((c, json.load(f)['boards']))
-
- files = (open(a.diff_file).read().splitlines() if a.diff_file
- else changed_files_from_git(a.base, repo_root))
- files = [f for f in files if f.strip()]
-
- s = classify(files, repo_root, rosters)
- s['args'] = selection_args(s, rosters)
- s['args_flasher'] = selection_args_by_flasher(s, rosters)
- for r in s['reasons']:
- print(f'hil_select: {r}', file=sys.stderr)
- print(json.dumps(s))
-
-
-if __name__ == '__main__':
- main()
diff --git a/test/hil/helper/hil_util.py b/test/hil/helper/hil_util.py
index 54984d20f..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
@@ -18,7 +20,7 @@ from typing import Any
# -------------------------------------------------------------
-# HIL example test lists, shared by hil_test.py (runner) and hil_select.py (PR-diff
+# HIL example test lists, shared by hil_test.py (runner) and ci_select.py (PR-diff
# selector). Run order is shuffled per board (see test_board); every example carries a
# unique hardcoded idProduct (see its usb_descriptors.c).
# -------------------------------------------------------------
@@ -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 c7dfa95df..43ede5795 100644
--- a/test/hil/hil_ci.sh
+++ b/test/hil/hil_ci.sh
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Run HIL test remotely on ci.lan
-# Usage: test/hil/hil_ci.sh [-b BOARD] [-t TEST] [extra hil_test.py args...]
+# Usage: test/hil/hil_ci.sh [-b BOARD]... [-t TEST] [extra hil_test.py args...]
# Example:
# test/hil/hil_ci.sh -b stm32f723disco
+# test/hil/hil_ci.sh -b stm32f723disco -b raspberry_pi_pico
# test/hil/hil_ci.sh -b stm32f723disco -t host/cdc_msc_hid -r 1
#
# Env overrides: REMOTE, REMOTE_DIR, CONFIG (path to HIL config json),
@@ -44,17 +45,46 @@ for a in "$@"; do
exit 1
done
-# Parse -b BOARD from arguments to know which build to copy
-BOARD=""
+# Parse -b BOARD from arguments to know which builds to copy. Repeatable: hil_test.py
+# takes the whole board set in ONE run (it schedules them across host controllers and
+# budgets the flashes itself), so every -b needs its binaries staged, not just the last.
+BOARDS=()
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
- -b)
- [[ $# -ge 2 ]] || { echo "error: -b requires a BOARD argument" >&2; exit 1; }
- BOARD="$2"
+ # hil_test.py declares `-b, --board` with action='append', so argparse also accepts
+ # --board=X and -bX. Recognising only the bare `-b X` forwarded the others to the rig
+ # while never staging them: the board ran with no firmware and reported a green row.
+ -b|--board)
+ [[ $# -ge 2 ]] || { echo "error: $1 requires a BOARD argument" >&2; exit 1; }
+ BOARDS+=("$2")
ARGS+=("$1" "$2")
shift 2
;;
+ --board=*)
+ BOARDS+=("${1#--board=}")
+ ARGS+=("$1")
+ shift
+ ;;
+ # -bt (--board-test) BEFORE the glued -b?* arm, mirroring argparse's longest-match: it is
+ # the form <config>.failed uses, and a bare -b?* would register a board named "t..." that
+ # the roster check below rejects -- killing every documented retry.
+ -bt|--board-test)
+ [[ $# -ge 2 ]] || { echo "error: $1 requires NAME:tests" >&2; exit 1; }
+ ARGS+=("$1" "$2")
+ shift 2
+ ;;
+ -bt?*|--board-test=*)
+ ARGS+=("$1")
+ shift
+ ;;
+ # glued short form: argparse resolves -bNAME to --board NAME, so staging must too --
+ # unparsed it fell through to the all-boards branch and silently staged everything built
+ -b?*)
+ BOARDS+=("${1#-b}")
+ ARGS+=("$1")
+ shift
+ ;;
*)
ARGS+=("$1")
shift
@@ -62,6 +92,110 @@ while [[ $# -gt 0 ]]; do
esac
done
+# Resolve a board to its build dirs: its own dir, the cmake-build-<board>-* glob (ad-hoc
+# local builds), and the variant dirs named in $CONFIG -- variant names are NOT required to
+# be prefixed with the board name, so the glob alone is not enough. Prints one dir per line.
+variant_names() {
+ python3 -c '
+import json, sys
+cfg = json.load(open(sys.argv[1]))
+for b in cfg.get("boards", []):
+ if b["name"] == sys.argv[2]:
+ for v in b.get("variant") or []:
+ print(v["name"])
+' "$CONFIG" "$1"
+}
+
+resolve_build_dirs() {
+ local board="$1" d v
+ declare -A seen=()
+ shopt -s nullglob
+ for d in "$ROOT_DIR"/examples/cmake-build-"$board" "$ROOT_DIR"/examples/cmake-build-"$board"-*; do
+ [[ -d $d && -z ${seen[$d]:-} ]] && { seen[$d]=1; printf '%s\n' "$d"; }
+ done
+ shopt -u nullglob
+ # to a file, not a process substitution: `set -e`/pipefail cannot see the exit status of
+ # the latter, so a malformed roster silently yielded zero variant dirs
+ local vf; vf=$(mktemp)
+ variant_names "$board" > "$vf" || { rm -f "$vf"; echo "Error: could not read variants for $board from $CONFIG" >&2; exit 1; }
+ while IFS= read -r v; do
+ d="$ROOT_DIR/examples/cmake-build-$v"
+ [[ -d $d && -z ${seen[$d]:-} ]] && { seen[$d]=1; printf '%s\n' "$d"; }
+ done < "$vf"
+ rm -f "$vf"
+}
+
+# Pre-flight: EVERY board must resolve to at least one build dir before anything is wiped or
+# copied. This check used to live in the copy loop, so an unbuilt board late in the list
+# aborted the run after the remote tree had been rm -rf'd and earlier boards fully rsynced --
+# zero coverage, a half-staged rig, and a stale local hil_report.md left in place. Report all
+# missing boards at once so one build round fixes them.
+MANIFEST=$(mktemp)
+trap 'rm -f "$MANIFEST"' EXIT
+# Roster membership first: hil_test.py rejects an unknown -b with sys.exit(1) for the WHOLE
+# run (hil_test.py:2297), and it does so AFTER this script has wiped REMOTE_DIR and staged
+# every board -- one typo then costs the entire batch. We already parse $CONFIG here, so
+# catch it before anything is touched. Note -b matches board names only, never variant names.
+if [ ${#BOARDS[@]} -gt 0 ]; then
+ROSTER=$(python3 -c '
+import json, sys
+print("\n".join(b["name"] for b in json.load(open(sys.argv[1])).get("boards", [])))
+' "$CONFIG") || { echo "error: could not read the board roster from $CONFIG" >&2; exit 1; }
+notinroster=()
+for b in ${BOARDS[@]+"${BOARDS[@]}"}; do
+ grep -qxF -- "$b" <<< "$ROSTER" || notinroster+=("$b")
+done
+if [ ${#notinroster[@]} -gt 0 ]; then
+ echo "error: not in $(basename "$CONFIG"): ${notinroster[*]}" >&2
+ echo " (-b takes board names, not variant names)" >&2
+ exit 1
+fi
+fi # BOARDS non-empty: nothing to validate for an all-boards run
+
+missing=()
+for b in ${BOARDS[@]+"${BOARDS[@]}"}; do
+ dirs=$(resolve_build_dirs "$b")
+ if [ -z "$dirs" ]; then
+ missing+=("$b")
+ else
+ while IFS= read -r d; do printf '%s\t%s\n' "$b" "$d" >> "$MANIFEST"; done <<< "$dirs"
+ # A declared variant with no build dir is NOT an error -- no cmake preset is
+ # variant-suffixed, so this is the normal state for e.g. the -DMA variants. It is worth
+ # saying out loud: hil_test.py logs `Skip (no binary)` and counts zero errors for it, so
+ # the run exits 0 and the operator reads a green table for cells that never ran.
+ # plain assignment, not process substitution: set -e sees a variant_names failure here,
+ # the same trap the comment in resolve_build_dirs warns about
+ vnames=$(variant_names "$b")
+ while IFS= read -r v; do
+ [ -z "$v" ] && continue
+ # whole lines: a substring match lets cmake-build-<v>-DMA silence the warning for <v>
+ grep -qxF -- "$ROOT_DIR/examples/cmake-build-$v" <<< "$dirs" \
+ || echo "warning: $b variant '$v' has no build dir -- its cells will be skipped, not tested" >&2
+ done <<< "$vnames"
+ fi
+done
+if [ ${#missing[@]} -gt 0 ]; then
+ echo "Error: no build directory under $ROOT_DIR/examples/ for: ${missing[*]}" >&2
+ for b in "${missing[@]}"; do
+ echo " cd examples && cmake --preset $b && cmake --build --preset $b" >&2
+ done
+ exit 1
+fi
+
+# The all-boards form needs its emptiness check HERE too: below the setup ssh it fired after
+# the remote tree was already rm -rf'd, destroying the previous run's report and re-run spec
+# on the rig before deciding there was nothing to do.
+if [ ${#BOARDS[@]} -eq 0 ]; then
+ shopt -s nullglob
+ allbuilds=("$ROOT_DIR"/examples/cmake-build-*/)
+ shopt -u nullglob
+ if [ ${#allbuilds[@]} -eq 0 ]; then
+ echo "error: no examples/cmake-build-* directories under $ROOT_DIR -- nothing to test" >&2
+ echo " build first, e.g.: cd examples && cmake --preset <board> && cmake --build --preset <board>" >&2
+ exit 1
+ fi
+fi
+
# Setup remote directory. `bash -s` + heredoc so REMOTE_DIR arrives as a positional
# parameter, keeping the `rm -rf` target out of the command string the heredoc runs.
echo "==> Setting up remote $REMOTE:$REMOTE_DIR"
@@ -76,6 +210,69 @@ rm -rf -- "$1"
mkdir -p -- "$1/test/hil/helper" "$1/examples"
REMOTE
+# The --accumulate merge base. The wipe above just cleared REMOTE_DIR, and
+# accumulate_report merges onto the sidecar in the RUN's cwd (hil_test.py:2193 sets
+# `fresh = not args.accumulate`, and only a non-fresh run reads it) -- so without this a
+# remote retry starts from nothing and its one-row table REPLACES the full-fleet one it was
+# meant to extend. The copy-back at the end of this script has always existed; this is the
+# other half of it.
+#
+# Gated, not unconditional: a fresh run unlinks the sidecar anyway (hil_test.py:2244), so
+# uploading there is wasted work that also obscures what the wipe means.
+#
+# <config>.failed is deliberately NOT uploaded: hil_test.py only ever writes it, never
+# reads it -- the retry spec reaches the rig as the -b/-bt arguments the caller expanded
+# from it (`hil_ci.sh $(cat <config>.failed)`).
+# argparse decides, not a case arm: hil_test.py declares `-a, --accumulate`, so argparse
+# also accepts `-av`, `-va`, `--accum` and `--acc` -- and hil-validate.js tells the
+# operator to retry "adding -v", which makes `-av` the natural spelling. A hand-rolled
+# match missed all four: no upload, and the else-branch warning never fired either, so the
+# one-row table replaced the full-fleet one in silence.
+ACCUMULATE=$(python3 - ${ARGS[@]+"${ARGS[@]}"} <<'PY'
+import argparse, sys
+p = argparse.ArgumentParser(add_help=False)
+p.add_argument('-a', '--accumulate', action='store_true')
+p.add_argument('-v', '--verbose', action='store_true') # so -av/-va bundle as they do there
+print(1 if p.parse_known_args(sys.argv[1:])[0].accumulate else 0)
+PY
+) || ACCUMULATE=0
+if [ "$ACCUMULATE" = 1 ]; then
+ if [ -f "$ROOT_DIR/hil_report.json" ]; then
+ # Provenance: hil_report.json is not namespaced by CONFIG or REMOTE (build.yml and
+ # pr_comment.yml read that exact name), so a `REMOTE=hifiphile CONFIG=.../hfp.json`
+ # run leaves an hfp sidecar behind that a later ci.lan retry would merge, publishing
+ # boards that never ran here. Require at least one row to belong to THIS roster.
+ if python3 - "$ROOT_DIR/hil_report.json" "$CONFIG" <<'PY'
+import json, sys
+try:
+ rows = json.load(open(sys.argv[1])).get('rows') or []
+ cfg = json.load(open(sys.argv[2])).get('boards') or []
+except Exception:
+ sys.exit(1)
+known = set()
+for b in cfg:
+ known.add(b.get('name'))
+ known.update(v.get('name') for v in (b.get('variant') or []))
+sys.exit(0 if not rows or any(r.get('board') in known for r in rows if isinstance(r, dict))
+ else 1)
+PY
+ then
+ echo "==> Uploading hil_report.json as the --accumulate merge base"
+ scp -q "$ROOT_DIR/hil_report.json" "$REMOTE:$REMOTE_DIR/"
+ else
+ echo "==> warning: $ROOT_DIR/hil_report.json holds no board from $(basename "$CONFIG")" \
+ "-- it is from another rig or config, so it is NOT being uploaded; this run's" \
+ "table will REPLACE rather than extend" >&2
+ fi
+ else
+ # Loud, because this is the failure mode: the run still succeeds, and quietly
+ # publishes a small table where a full one used to be.
+ echo "==> warning: --accumulate was requested but $ROOT_DIR/hil_report.json does not" \
+ "exist, so there is nothing to merge onto -- this run's table will REPLACE the" \
+ "previous one rather than extend it" >&2
+ fi
+fi
+
# Copy HIL test script and config
echo "==> Copying test scripts"
scp -q "$ROOT_DIR/test/hil/hil_test.py" \
@@ -89,8 +286,11 @@ scp -q "$ROOT_DIR/test/hil/helper/__init__.py" \
"$ROOT_DIR/test/hil/helper/hil_util.py" \
"$ROOT_DIR/test/hil/helper/hil_health.py" \
"$ROOT_DIR/test/hil/helper/hil_lock.py" \
- "$ROOT_DIR/test/hil/helper/hil_select.py" \
+ "$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
@@ -103,52 +303,23 @@ copy_board_binaries() {
"$src" "$REMOTE:$REMOTE_DIR/examples/"
}
-if [ -n "$BOARD" ]; then
- # Copy the board's build dir plus its variant dirs. Variant names come from
- # $CONFIG (they are not required to be prefixed with the board name); the
- # cmake-build-<BOARD>-* glob is kept as a fallback for ad-hoc local builds.
- # Collect only dirs that actually exist, deduplicated.
- declare -A SEEN_DIRS=()
- BUILD_DIRS=()
- add_build_dir() {
- [[ -d "$1" && -z "${SEEN_DIRS[$1]:-}" ]] || return 0
- SEEN_DIRS[$1]=1
- BUILD_DIRS+=("$1")
- }
- shopt -s nullglob
- for d in "$ROOT_DIR"/examples/cmake-build-"$BOARD" "$ROOT_DIR"/examples/cmake-build-"$BOARD"-*; do
- add_build_dir "$d"
- done
- shopt -u nullglob
- # to a file, not a process substitution: `set -e`/pipefail cannot see the exit
- # status of the latter, so a malformed roster silently yielded zero variant dirs
- VARIANTS_FILE=$(mktemp)
- python3 -c '
-import json, sys
-cfg = json.load(open(sys.argv[1]))
-for b in cfg.get("boards", []):
- if b["name"] == sys.argv[2]:
- for v in b.get("variant") or []:
- print(v["name"])
-' "$CONFIG" "$BOARD" > "$VARIANTS_FILE" || {
- echo "Error: could not read variants for $BOARD from $CONFIG"
- rm -f "$VARIANTS_FILE"
- exit 1
- }
- while IFS= read -r v; do
- add_build_dir "$ROOT_DIR/examples/cmake-build-$v"
- done < "$VARIANTS_FILE"
- rm -f "$VARIANTS_FILE"
- if [ ${#BUILD_DIRS[@]} -eq 0 ]; then
- echo "Error: no build directory found for $BOARD under $ROOT_DIR/examples/"
- echo "Build first with: cd examples && cmake --preset $BOARD && cmake --build --preset $BOARD"
- exit 1
- fi
- echo "==> Copying binaries for $BOARD (${#BUILD_DIRS[@]} build dir(s))"
- for d in "${BUILD_DIRS[@]}"; do
- copy_board_binaries "$d"
+if [ ${#BOARDS[@]} -gt 0 ]; then
+ # Replay the pre-flight manifest: the dirs were already resolved and proved non-empty
+ # for every board, so nothing here can abort mid-staging. Plain reads of the manifest --
+ # a process substitution would hide a reader failure from set -e (the comment in
+ # resolve_build_dirs is about exactly that trap).
+ for b in "${BOARDS[@]}"; do
+ dirs=()
+ while IFS=$'\t' read -r bb d; do
+ [ "$bb" = "$b" ] && [ -n "$d" ] && dirs+=("$d")
+ done < "$MANIFEST"
+ echo "==> Copying binaries for $b (${#dirs[@]} build dir(s))"
+ for d in ${dirs[@]+"${dirs[@]}"}; do
+ copy_board_binaries "$d"
+ done
done
else
+ # emptiness was already refused in pre-flight, before the remote wipe
echo "==> Copying all built binaries"
# Use `%/` parameter expansion to strip the trailing slash from the glob —
# rsync needs the bare dir name so the per-board cmake-build-<BOARD>/ subdir
@@ -170,14 +341,38 @@ for a in ${ARGS[@]+"${ARGS[@]}"}; do ARGS_Q+=("$(printf '%q' "$a")"); done
CONFIG_Q="$(printf '%q' "test/hil/$(basename "$CONFIG")")"
echo "==> Running HIL test on $REMOTE"
rc=0
-# --retry 1 FIRST, before the user's args: this targets the same shared rig CI uses, and
-# the pool guard is a flat constant that does not scale with max_retry -- argparse's
-# default of 3 lets a few flaky boards re-pay 510s each until the 3600s guard fires,
-# abandoning the pool and holding board flocks against concurrent CI. Placed first, not
-# appended, so argparse's last-wins means `hil_ci.sh -r 3` still gets 3.
-ssh "$REMOTE" bash -s -- "$REMOTE_DIR" --retry 1 ${ARGS_Q[@]+"${ARGS_Q[@]}"} "$CONFIG_Q" <<'REMOTE' || rc=$?
+# --retry 1 FIRST, before the user's args: this targets the same shared rig CI uses, and the
+# pool guard is a flat constant that does not scale with max_retry, so a few flaky boards can
+# re-pay ~510s each until the 3600s guard fires, abandoning the pool and holding board flocks
+# against concurrent CI. hil_test.py's own default is already 1; passing it explicitly keeps
+# that true if the default ever moves. Placed first, not appended, so argparse's last-wins
+# means `hil_ci.sh -r 3` still gets 3.
+# Forward the HIL_* knobs (HIL_NO_BOARD_LOCK for an authorized force, the parallel widths,
+# HIL_POOL_TIMEOUT). ssh passes no environment and joins its argv into one string the remote
+# shell re-splits, so a bare NAME=value element would arrive as a positional argument to
+# hil_test.py and argparse would exit 2. Build `export` lines instead and hand them over as a
+# single %q-quoted word for the remote to eval.
+# Joined with '; ', NOT newlines: %q renders a newline as bash-only $'...' quoting, which the
+# remote LOGIN shell must parse from the joined command string -- under dash the force arrives
+# as garbage and silently does nothing. Backslash escaping round-trips in both shells.
+# HIL_REPORT_DIR stays local: where the report lands on the rig is this script's contract
+# (REMOTE_DIR, where all three copy-backs below look), so forwarding it would relocate the
+# report and every copy-back would come home empty.
+HIL_EXPORTS=""
+while IFS= read -r v; do
+ [ -z "$v" ] && continue
+ HIL_EXPORTS+="export $(printf '%s=%q' "$v" "${!v}"); "
+done < <(compgen -v | grep -x 'HIL_[A-Z0-9_]*' | grep -vxE 'HIL_EXPORTS|HIL_REPORT_DIR' || true)
+[ -n "$HIL_EXPORTS" ] && echo "==> Forwarding: $HIL_EXPORTS"
+# One %q-quoted word, so ssh's argv join and the remote shell's re-split hand it back
+# byte-for-byte, and the remote evals it. Empty stays `''` -- a real, shiftable argument --
+# rather than vanishing from the joined string and shifting the run's own flags out of place.
+HIL_EXPORTS_Q=$(printf '%q' "$HIL_EXPORTS")
+
+ssh "$REMOTE" bash -s -- "$REMOTE_DIR" "$HIL_EXPORTS_Q" --retry 1 ${ARGS_Q[@]+"${ARGS_Q[@]}"} "$CONFIG_Q" <<'REMOTE' || rc=$?
cd -- "$1"
shift
+eval "$1"; shift # HIL_* exports, %q-quoted locally into one word
# Flasher CLIs live in the user bin dirs on ci.lan (esptool/idf in ~/.local/bin,
# STM32CubeProgrammer's STM32_Programmer_CLI in ~/bin); the non-interactive shell
# subprocess used for flashing doesn't source profile/rc, so add them explicitly.
@@ -187,8 +382,41 @@ REMOTE
# Copy the generated report back to the local checkout (best-effort; the run's
# exit code is preserved regardless of whether a report was produced).
-scp -q "$REMOTE:$REMOTE_DIR/hil_report.md" "$ROOT_DIR/hil_report.md" \
- && echo "==> Report copied to $ROOT_DIR/hil_report.md" \
- || echo "==> warning: no hil_report.md copied back" >&2
+# rm -f FIRST, exactly as the sidecar loop below does: the markdown and the JSON are two
+# halves of ONE document now, so leaving a stale table behind when the copy fails -- beside
+# a sidecar that was correctly removed -- publishes last run's green results under this
+# run's red job, and the operator's hil_report.py call exits 1 against the missing sidecar.
+# Fetch BOTH halves to temps and commit them as a pair. Separate fetch/rename meant a
+# markdown that arrived beside a sidecar that did not left the local pair failing the
+# rendering invariant, and the next --accumulate retry merging the wrong base. Deleting
+# first and then scp'ing was worse still: an ssh drop at the end of a 60-minute run
+# destroyed the report outright.
+md_ok=0; json_ok=0
+scp -q "$REMOTE:$REMOTE_DIR/hil_report.md" "$ROOT_DIR/hil_report.md.tmp" 2>/dev/null \
+ && [ -f "$ROOT_DIR/hil_report.md.tmp" ] && md_ok=1
+scp -q "$REMOTE:$REMOTE_DIR/hil_report.json" "$ROOT_DIR/hil_report.json.tmp" 2>/dev/null \
+ && [ -f "$ROOT_DIR/hil_report.json.tmp" ] && json_ok=1
+if [ "$md_ok" = 1 ] && [ "$json_ok" = 1 ]; then
+ mv -f "$ROOT_DIR/hil_report.md.tmp" "$ROOT_DIR/hil_report.md"
+ mv -f "$ROOT_DIR/hil_report.json.tmp" "$ROOT_DIR/hil_report.json"
+ echo "==> Report copied to $ROOT_DIR/hil_report.md (+ sidecar)"
+else
+ rm -f "$ROOT_DIR/hil_report.md.tmp" "$ROOT_DIR/hil_report.json.tmp"
+ # All or nothing: a half-copied pair is worse than none. The stale local markdown goes
+ # because that is what gets pasted into a PR as this run's results; the stale sidecar
+ # goes with it so the two cannot disagree.
+ rm -f "$ROOT_DIR/hil_report.md" "$ROOT_DIR/hil_report.json"
+ echo "==> warning: report copy-back incomplete (md=$md_ok json=$json_ok); removed the" \
+ "stale local pair -- an --accumulate retry has no merge base until a run succeeds" >&2
+fi
+
+# The re-run spec lives in the run's cwd on the rig and the next invocation rm -rf's it.
+# Delete the local copy first: a green run writes no .failed, so a silent no-op scp would
+# leave last run's spec looking current and "retry from the spec" would re-flash boards
+# that passed.
+spec="$(basename "$CONFIG").failed"
+rm -f "$ROOT_DIR/$spec"
+scp -q "$REMOTE:$REMOTE_DIR/$spec" "$ROOT_DIR/$spec" 2>/dev/null \
+ && echo "==> $spec copied to $ROOT_DIR/$spec" || true
exit $rc
diff --git a/test/hil/hil_flash.py b/test/hil/hil_flash.py
index f4bed45a6..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)
@@ -294,7 +294,7 @@ reset_lm4flash.no_op = True
# The one place a flasher's firmware extension is decided. A flasher with no entry falls
-# back to .elf-or-.bin and can be handed the wrong file — test_hil_select's
+# back to .elf-or-.bin and can be handed the wrong file — test_ci_select's
# TestRosterFlashersDispatch fails if a roster names one.
FLASHER_SUFFIX = {
'esptool': '.bin',
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index 174251343..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
-from helper import hil_health, hil_lock, hil_util
+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
@@ -106,9 +106,6 @@ STATUS_OK = "\033[32mOK\033[0m"
STATUS_FAILED = "\033[31mFailed\033[0m"
STATUS_SKIPPED = "\033[33mSkipped\033[0m"
-# Plain (non-ANSI) cell symbols for hil_report.md; a missing binary counts as skipped.
-REPORT_CELL = {'pass': '✅', 'fail': '❌', 'skip': '⚪'}
-
class TestFail(AssertionError):
"""Fail a test but still surface a metric string in its report cell (e.g. usbtest's '❌ 29/30'
@@ -194,10 +191,6 @@ class TestsCfg(TypedDict, total=False):
dev_attached: list[AttachedDevCfg]
-class BuildCfg(TypedDict, total=False):
- args: list[str]
-
-
class VariantCfg(TypedDict, total=False):
name: str # build dir (cmake-build-<name>) and HIL report row
flags: str # raw CFLAGS, e.g. "-DCFG_TUD_DWC2_DMA_ENABLE=1"
@@ -209,8 +202,11 @@ class Board(TypedDict):
uid: str
tests: TestsCfg
flasher: FlasherCfg
- build: NotRequired[BuildCfg]
+ # every build knob lives here, including a board's always-on defines: a board that
+ # 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)
@@ -225,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
@@ -239,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)
@@ -294,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
@@ -302,8 +324,19 @@ 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
# staging list does not need another entry to keep the rig working.
@@ -322,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
@@ -360,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):
@@ -368,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)}'
@@ -390,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
@@ -446,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:
@@ -770,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')
@@ -811,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')
@@ -873,7 +1008,7 @@ def test_device_cdc_msc_throughput(board):
# payload, so an HS board reads as suspiciously slow. Say so rather than publish a green
# cell whose scale is a guess.
scale = '' if speed_known else ' FS?'
- return f'{REPORT_CELL["pass"]} C {pair(cdc_r, cdc_w)} M {pair(msc_r, msc_w)}{scale}'
+ return f'{hil_report.REPORT_CELL["pass"]} C {pair(cdc_r, cdc_w)} M {pair(msc_r, msc_w)}{scale}'
def test_device_dfu(board):
@@ -993,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
@@ -1071,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)
@@ -1239,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:
@@ -1301,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):
@@ -1342,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'{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
@@ -1386,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).
@@ -1399,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)
@@ -1415,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
@@ -1456,8 +1603,22 @@ def test_device_usbtest(board):
board_wedged = (f'{board["name"]}: usbtest reported a hang and was killed '
f'before it could report a verdict')
raise TestFail(f'usbtest did not run: {detail}',
- metric=f'{REPORT_CELL["fail"]} 0/30')
+ 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
@@ -1466,30 +1627,30 @@ 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
# wedge, and flashes through the poisoned node to do it.
raise TestFail(f'usbtest {passed}/{total} but the device wedged ({board_wedged})',
- metric=f'{REPORT_CELL["fail"]} {passed}/{total}', parsed=True)
+ metric=f'{hil_report.REPORT_CELL["fail"]} {passed}/{total}', parsed=True)
if failed == 0 and notrun == 0 and total > 0:
- return f'{REPORT_CELL["pass"]} {passed}/{total}'
+ return f'{hil_report.REPORT_CELL["pass"]} {passed}/{total}'
bad = [c.get('num') for c in data.get('cases', [])
if c.get('status') not in ('PASS', 'BUDGET')]
why = f'usbtest {passed}/{total}'
@@ -1505,7 +1666,7 @@ def test_device_usbtest(board):
why += f'; {notrun} case(s) never ran ({reason}), so this says nothing about them'
# parsed ONLY when every case ran: an aborted battery (budget expiry, kernel hang, bus
# drop) leaves BUDGET entries, and those are exactly what a reflash retry can fix.
- raise TestFail(why, metric=f'{REPORT_CELL["fail"]} {passed}/{total}',
+ raise TestFail(why, metric=f'{hil_report.REPORT_CELL["fail"]} {passed}/{total}',
parsed=(notrun == 0))
@@ -1670,21 +1831,17 @@ 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 and build.args defines.
+ 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
the developer watching the build is the timeout."""
name = board['name']
- bcfg = cast(BuildCfg, board.get('build', {}))
- extra_defs = bcfg.get('args', [])
variants = board.get('variant') or [{'name': name, 'flags': ''}]
failed = 0
for v in variants:
cmd = [sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'build.py'), '-b', name]
- for d in extra_defs:
- cmd += ['-D', d]
if v['name'] != name:
cmd += ['--build-name', v['name']]
for d in v.get('defines', []):
@@ -1711,11 +1868,48 @@ def build_board(board: Board) -> tuple[str, int]:
return name, failed
-# pseudo-test column for a variant boundary the park-flash could not clear (see below)
-BOUNDARY_CELL = 'same-PID boundary'
+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]
-def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
+ 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']
@@ -1728,42 +1922,11 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
log_line(f'{name:25} {STATUS_FAILED}: {e}')
# visible report row so the ❌ matches the exit code; failed-tests stays empty so a
# re-run repeats the whole board (no bogus -bt filter)
- return name, 1, [], [(name, {'board-locked': 'fail'}, None)], 0.0
+ return name, 1, [], [(name, {hil_report.LOCKED_CELL: 'fail'}, None)], 0.0
# 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 = []
@@ -1815,7 +1978,7 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
# charging again would double-count one incident in the exit code
if not wedge_skip:
err_count += 1
- cells[BOUNDARY_CELL] = 'fail'
+ cells[hil_report.BOUNDARY_CELL] = 'fail'
# blaming run_list[0] would re-run an innocent test that then passes,
# leaving the boundary unretested; re-run the whole board instead
board_wide_fail = True
@@ -1831,7 +1994,7 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
# Do NOT flash through a poisoned node: each attempt enumerates into
# it, blocks uninterruptibly and leaves another stray behind. Report
# the skip so the cell is not mistaken for a pass.
- cells[test] = f'{REPORT_CELL["skip"]} board wedged'
+ cells[test] = f'{hil_report.REPORT_CELL["skip"]} board wedged'
# ...and re-run the WHOLE board, like the boundary-failure path above:
# these tests never executed, so naming them individually in the .failed
# spec is not enough -- an --accumulate re-run that fixes only the wedged
@@ -1872,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
@@ -1899,8 +2060,6 @@ def test_board(board: Board) -> tuple[str, int, list[str], list, float]:
_lock_fh.close()
-REPORT_MD = 'hil_report.md'
-REPORT_JSON = 'hil_report.json'
# controller hints from previous runs: uid -> {'name', 'pci', 'duration'}. Only 'pci' is
# consumed (dispatch order and first-flash budgeting, never battery serialization). PCI
# addresses are boot-stable, so the cache survives reboots and goes stale on re-cabling.
@@ -1918,66 +2077,6 @@ def schedule_boards(boards: list, pci_of_uid: dict) -> list:
return [b for grp in itertools.zip_longest(*buckets.values()) for b in grp if b is not None]
-def render_matrix(rows_all: list) -> str:
- """Render rows (list of (row_label, {example: status}, duration)) as an aligned
- markdown matrix: columns = tests (bare names) centered, boards left-aligned,
- per-row duration as the trailing column."""
- seen = set()
- for _, cells, _ in rows_all:
- seen.update(cells)
- if not seen:
- return 'No tests were run.'
-
- # metric-bearing columns pinned first, the rest alphabetical: stable regardless of the
- # shuffled execution order
- pinned = ['usbtest', 'cdc_msc_throughput', 'msc_file_explorer', 'msc_file_explorer_freertos']
-
- def col_key(t):
- name = t.rsplit('/', 1)[-1]
- return (pinned.index(name) if name in pinned else len(pinned), name, t)
-
- columns = sorted(seen, key=col_key)
- headers = [c.rsplit('/', 1)[-1] for c in columns] + ['duration'] # bare example names
-
- def cell(cells, col):
- v = cells.get(col)
- if v is None:
- return ''
- return REPORT_CELL.get(v, v) # status symbol, or a metric string (e.g. speed) verbatim
-
- 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])
- 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)]
- return '| ' + ' | '.join(padded) + ' |'
-
- header = line(board_hdr, headers)
- sep = '| ' + '-' * board_w + ' | ' + ' | '.join(':' + '-' * (w - 2) + ':' for w in col_w) + ' |'
- body = [line(lbl, vals) for lbl, vals in rows_vals]
-
- # tally run cells (not-run cells are absent from the dicts). A cell is a bare status or
- # a metric string carrying its own icon ("❌ 29/30"), so classify by the leading icon.
- def cell_kind(v):
- if v == 'fail' or (isinstance(v, str) and v.startswith(REPORT_CELL['fail'])):
- return 'fail'
- if v == 'skip' or (isinstance(v, str) and v.startswith(REPORT_CELL['skip'])):
- return 'skip'
- return 'pass'
- kinds = [cell_kind(v) for _, cells, _ in rows_all for v in cells.values()]
- failed = kinds.count('fail')
- skipped = kinds.count('skip')
- passed = kinds.count('pass')
- summary = (f'**{REPORT_CELL["pass"]} {passed} passed · {REPORT_CELL["fail"]} {failed} failed · '
- f'{REPORT_CELL["skip"]} {skipped} skipped · blank not run**')
-
- return summary + '\n\n' + '\n'.join([header, sep] + body)
-
-
def _write_failed_spec(failed_fname: Path, report_dir: Path, mret: list) -> None:
"""Re-run spec: only the failed boards (-b), each restricted to its own failed tests
(-bt); a board with failures but no test list re-runs entirely.
@@ -2058,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)
@@ -2067,109 +2166,13 @@ 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')
-
-
-def accumulate_report(mret: list, report_dir: Path, fresh: bool, scope: str = '',
- banner: str = '') -> str:
- """Merge this run's results into hil_report.json in report_dir, then (re)write
- the markdown matrix to hil_report.md. `fresh` (a first run, no --accumulate)
- starts a new report; otherwise a re-run accumulates so boards/tests that
- already passed are preserved while re-run cells are updated. `scope` names the
- board filter, if any, so a scoped table is not mistaken for a full one.
- Returns the md."""
- acc = {} # ordered {row_label: [cells dict, duration str|None]}
- prior_banner = ''
- jpath = report_dir / REPORT_JSON
- if not fresh and jpath.is_file():
- try:
- saved = json.loads(jpath.read_text())
- # CI keys the report dir by run id, so the sidecar is from an earlier attempt
- for entry in saved.get('rows', []):
- acc[entry['board']] = [dict(entry['cells']), entry.get('duration')]
- # ... and so is the caveat those cells were collected under. A rerun on a rig
- # that has since recovered contributes no banner, and the .failed spec reruns
- # only FAILURES -- so the earlier attempt's passes are never re-earned and
- # would be published as clean results of a rig that was not.
- prior_banner = saved.get('banner', '')
- except (ValueError, KeyError, TypeError):
- pass # corrupt/old sidecar: start fresh
-
- # current cells override prior for boards/tests that ran; a filtered run reports
- # duration None, keeping the previous full-run value
- for name, _, _, rows, *_ in mret:
- if rows and not any('board-locked' in cells for _, cells, _ in rows):
- # board ran for real: clear a stale lock-failure cell (its row is keyed by
- # board name; test rows may be variant names)
- stale = acc.get(name)
- if stale is not None:
- stale[0].pop('board-locked', None)
- if not stale[0]:
- # variant-keyed boards never repopulate the board-name row, so drop it
- # or it renders as a blank ghost row
- del acc[name]
- for row_label, cells, dur in rows:
- row = acc.setdefault(row_label, [{}, None])
- # 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:
- row[0].pop(BOUNDARY_CELL, None)
- row[0].update(cells)
- if dur is not None:
- row[1] = dur
-
- report_dir.mkdir(parents=True, exist_ok=True)
- # by LINE, deduped: attempts repeat the same caveat far more often than they add a new
- # one, and three copies of the D-state note reads as three incidents
- seen, merged = set(), []
- for line in (prior_banner + banner).splitlines():
- if line.strip() and line not in seen:
- seen.add(line)
- merged.append(line)
- banner = '\n'.join(merged) + '\n' if merged else ''
- jpath.write_text(json.dumps({'rows': [{'board': k, 'cells': c, 'duration': d}
- for k, (c, d) in acc.items()],
- 'banner': banner}, indent=2) + '\n')
-
- md = render_matrix([(k, c, d) for k, (c, d) in acc.items()])
- if scope:
- # a scoped run's small table is otherwise indistinguishable from a full one, and
- # it replaces the previous full table in the sticky PR comment
- md = f'_Scoped run: {scope}. Boards/tests not listed were not run._\n\n' + md
- # LAST, so it is outermost: a rig-health caveat outranks the table AND the scope note,
- # and the top of the report is where hil/SKILL.md tells the agent to look for it.
- if banner:
- md = banner + '\n' + md
- (report_dir / REPORT_MD).write_text(md + '\n', encoding='utf-8')
- return md
-
-
# containment paths print through hil_health._p: stdout may already be a dead pipe (a
# dropped ssh session), and a BrokenPipeError there would skip os._exit
_p = hil_health._p
def _abandon_exit(pool, mgr, abandoned: bool, err_count: int,
- report: Path | None = None) -> None:
+ report_dir: Path | None = None) -> None:
"""Free the runner when the pool could not be shut down. Returns only if not abandoned.
Must run even while an exception is propagating: multiprocessing's atexit handler
@@ -2205,24 +2208,11 @@ def _abandon_exit(pool, mgr, abandoned: bool, err_count: int,
'stay locked.', flush=True)
# A report already written by accumulate_report says nothing about the abandon, and a
# green table under a red job is how an agent ends up pasting it as this run's result.
- # Prepend the caveat; best-effort, never at the cost of exiting.
- if report is not None:
- try:
- if report.exists():
- # utf-8 explicitly (the cells are ✅/❌/⚪) and catch ValueError too: a torn
- # report or a LANG=C locale raises UnicodeDecodeError -- NOT an OSError --
- # straight past os._exit, stranding the runner.
- body = report.read_text(encoding='utf-8', errors='replace')
- # Only when no banner is there yet, searched anywhere in the head rather
- # than at char 0: write_timeout_report's banner must stay FIRST (its table
- # is a PREVIOUS attempt's) and it puts the rig-health quote above itself.
- if '**HIL run ab' not in body[:2000]:
- report.write_text(
- '**HIL run abandoned: the worker pool would not shut down.** The '
- 'table below was collected before the abandon; treat board '
- 'results as unverified.\n\n' + body, encoding='utf-8')
- except (OSError, ValueError):
- pass
+ # Set the caveat in the DOCUMENT -- prepending to the markdown alone left the sidecar,
+ # which is all hil_report.summarize() and therefore an agent ever sees, saying nothing.
+ # Best-effort, never at the cost of exiting.
+ if report_dir is not None:
+ hil_report.mark_report_abandoned(report_dir, 'the worker pool would not shut down.')
try:
sys.stdout.flush()
except OSError:
@@ -2232,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
@@ -2303,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
@@ -2311,13 +2480,11 @@ def main() -> None:
print(msg, flush=True)
# loud AND leaving evidence: exiting with no report at all lets the PR comment
# keep the previous push's stale table under a red job
- try:
- rd = Path(os.environ.get('HIL_REPORT_DIR', '.'))
- rd.mkdir(parents=True, exist_ok=True)
- (rd / REPORT_MD).write_text(f'**HIL run selected no boards.** {msg}\n',
- encoding='utf-8')
- except OSError:
- pass
+ rd = Path(os.environ.get('HIL_REPORT_DIR', '.'))
+ # fresh must be threaded through: this runs BEFORE the `if fresh:` wipe below, so
+ # defaulting it here wiped an --accumulate run's accumulated rows -- the exact
+ # regression the parameter exists to prevent.
+ hil_report.mark_report_no_boards(rd, msg, fresh=not args.accumulate)
sys.exit(1)
@@ -2352,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); '
@@ -2364,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))
@@ -2393,31 +2548,22 @@ def main() -> None:
# BEFORE Manager()/Pool(), not inside the try: hil_ci.sh reuses a persistent REMOTE_DIR
# and scp's the report back unconditionally, so if a fork failure (OSError/EAGAIN right
# after a convoy -- the case this whole block guards) skipped the wipe, the finally's
- # _abandon_exit would prepend "HIL run abandoned" to the PREVIOUS run's table and
+ # _abandon_exit would stamp "HIL run abandoned" onto the PREVIOUS run's report and
# publish last night's board results as this run's. Nothing is live yet here, so an
# OSError from the wipe itself just exits with its traceback -- it cannot strand the
# interpreter in multiprocessing's unbounded atexit join, which is what deferring it
# was protecting against.
if fresh:
report_dir.mkdir(parents=True, exist_ok=True)
- for f in (REPORT_JSON, REPORT_MD):
+ for f in (hil_report.REPORT_JSON, hil_report.REPORT_MD):
(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
@@ -2434,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:
- accumulate_report(mret, report_dir, fresh, '',
- health_banner + _blind_note(mret)
- + _stray_note(mret) + banner)
- except Exception as rerr: # noqa: BLE001 - the raise below must still happen
- # FALL BACK, do not just warn: accumulate_report can raise on an
- # unwritable/root-owned report dir or a torn JSON, and _abandon_exit
- # only PREPENDS to a report that exists. Without this the artifact
- # upload finds nothing (if-no-files-found: ignore) and the sticky PR
- # comment keeps the previous push's green table under a red job.
- print(f'warning: partial report failed: {type(rerr).__name__}: {rerr}; '
- f'falling back to the board list', flush=True)
- try:
- hil_health.write_timeout_report(
- report_dir, [b for b in config_boards
- if b['name'] in stuck], POOL_TIMEOUT, REPORT_MD,
- prefix=health_banner)
- except Exception as re2: # noqa: BLE001
- print(f'warning: fallback report failed too: '
- f'{type(re2).__name__}: {re2}', flush=True)
+ 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)
@@ -2478,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:
- accumulate_report(mret, report_dir, fresh, '',
- health_banner + _blind_note(mret)
- + _stray_note(mret) + banner)
- except Exception as re2: # noqa: BLE001 - the raise below must still happen
- print(f'warning: partial report failed: {type(re2).__name__}: {re2}',
- flush=True)
+ # 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
@@ -2543,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
@@ -2584,12 +2657,11 @@ def main() -> None:
# looks exactly like a full run that happened to be small
scoped = sorted(set(args.board) | set(board_test))
scope = f'{len(scoped)} board(s) — {", ".join(scoped)}' if scoped else ''
- report = accumulate_report(mret, report_dir, fresh, scope,
- health_banner + _blind_note(mret)
- + _stray_note(mret))
+ report = hil_report.accumulate_report(mret, report_dir, fresh, scope,
+ health_banner + _stray_note(mret))
print()
print(report)
- print(f'\nReport written to {(report_dir / REPORT_MD).resolve()}')
+ print(f'\nReport written to {(report_dir / hil_report.REPORT_MD).resolve()}')
duration = time.time() - duration
print()
@@ -2600,7 +2672,7 @@ def main() -> None:
# In the finally, not after: any raise above (accumulate_report sits outside the
# OSError handler) would skip the abandon path and unwind into multiprocessing's
# unbounded atexit join, hanging the runner.
- _abandon_exit(pool, mgr, pool_abandoned, err_count, report_dir / REPORT_MD)
+ _abandon_exit(pool, mgr, pool_abandoned, err_count, report_dir)
# Same clamp: exit status is a byte either way, so 256 failures would report green.
sys.exit(min(err_count, 125))
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_metrics.py b/test/hil/test/test_ci_metrics.py
new file mode 100644
index 000000000..6f1511913
--- /dev/null
+++ b/test/hil/test/test_ci_metrics.py
@@ -0,0 +1,581 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for the by-example half of tools/metrics.py and the (family, example)
+# pair-compare script. Stdlib only; synthetic map.json fixtures, no builds.
+# python3 test/hil/test/test_ci_metrics.py
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))))
+METRICS = os.path.join(REPO, 'tools', 'metrics.py')
+
+
+def fake_map(path, files):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, 'w') as f:
+ json.dump({'files': files}, f)
+
+
+def entry(name, size, path_prefix='tinyusb/src'):
+ return {'file': name, 'path': f'{path_prefix}/{name}', 'size': size,
+ 'symbols': [{'name': f'{name}_fn', 'size': size}], 'sections': {'.text': size}}
+
+
+class TestByExample(unittest.TestCase):
+ def build_tree(self, td):
+ fake_map(os.path.join(td, 'device', 'cdc_msc', 'cdc_msc.map.json'),
+ [entry('usbd.c', 100), entry('cdc_device.c', 50)])
+ fake_map(os.path.join(td, 'host', 'bare_api', 'bare_api.map.json'),
+ [entry('usbh.c', 200)])
+
+ def test_by_example_output(self):
+ with tempfile.TemporaryDirectory() as td:
+ self.build_tree(td)
+ out = os.path.join(td, 'metrics')
+ r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
+ '--by-example', '-o', out,
+ os.path.join(td, '*', '*', '*.map.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ by_ex = json.load(open(out + '_by_example.json'))
+ self.assertEqual(set(by_ex), {'device/cdc_msc', 'host/bare_api'})
+ self.assertEqual({f['file'] for f in by_ex['device/cdc_msc']['files']},
+ {'usbd.c', 'cdc_device.c'})
+ # the plain averaged output is unchanged by the extra flag
+ avg = json.load(open(out + '.json'))
+ self.assertIn('files', avg)
+
+ def test_by_example_json_roundtrips_as_combine_input(self):
+ with tempfile.TemporaryDirectory() as td:
+ self.build_tree(td)
+ out = os.path.join(td, 'metrics')
+ subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j', '--by-example',
+ '-o', out, os.path.join(td, '*', '*', '*.map.json')], check=True)
+ out2 = os.path.join(td, 'sub')
+ r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
+ '-o', out2, out + '_by_example.json'],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ sub = json.load(open(out2 + '.json'))
+ names = {f['file'] for f in sub['files']}
+ # one data entry per example, not one blob: reading it as an ordinary
+ # metrics.json would double-count every file
+ self.assertIn('usbd.c', names)
+ self.assertIn('cdc_device.c', names)
+ self.assertNotIn('TOTAL', {n.upper() for n in names})
+
+ def test_by_example_expansion_is_keyed_on_the_filename(self):
+ # the '_by_example.json' suffix IS the contract (write_by_example, the CMake
+ # rule and metrics_pair_compare all spell it). A shape-sniff would reroute
+ # any coincidentally-shaped JSON into the per-example branch instead.
+ with tempfile.TemporaryDirectory() as td:
+ look_alike = os.path.join(td, 'metrics.json')
+ with open(look_alike, 'w') as f:
+ json.dump({'device/cdc_msc': {'files': [entry('usbd.c', 100)]}}, f)
+ out = os.path.join(td, 'combined')
+ r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
+ '-o', out, look_alike],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ combined = json.load(open(out + '.json'))
+ self.assertNotIn('usbd.c', {f['file'] for f in combined.get('files', [])})
+
+
+PAIR_COMPARE = os.path.join(REPO, '.github/scripts/metrics_pair_compare.py')
+
+
+def fake_by_example(root, board, data):
+ d = os.path.join(root, f'cmake-build-{board}')
+ os.makedirs(d, exist_ok=True)
+ with open(os.path.join(d, 'metrics_by_example.json'), 'w') as f:
+ json.dump(data, f)
+
+
+class TestPairCompare(unittest.TestCase):
+ def test_intersection_compare(self):
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # real board names so board->family resolution works against hw/bsp
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]},
+ 'device/dfu': {'files': [entry('dfu_device.c', 10)]}})
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 120)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('usbd.c', md)
+ self.assertNotIn('dfu_device.c', md) # not on both sides
+ self.assertIn('raspberry_pi_pico', md) # scope footer names the board
+ self.assertIn('device/dfu', md) # named as dropped
+
+ def test_a_different_board_of_the_same_family_is_not_compared(self):
+ """--one-first returns all_boards[0], so adding a board can shift which one a
+ family builds. Keyed on the family, the base run's sizes and the PR run's sizes
+ would land under one key and the difference between two unrelated MCUs would be
+ published as this PR's code-size impact."""
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # both rp2040, both device/cdc_msc - only the board differs
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}})
+ fake_by_example(new, 'adafruit_fruit_jam',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 900)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('skipped', md)
+ self.assertNotIn('+800', md)
+
+ def test_empty_intersection_writes_note(self):
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ fake_by_example(base, 'raspberry_pi_pico', {'device/dfu': {'files': [entry('a.c', 1)]}})
+ fake_by_example(new, 'stm32f407disco', {'device/cdc_msc': {'files': [entry('b.c', 1)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('skipped', open(out + '.md').read())
+
+ def test_malformed_files_are_skipped_with_stderr_note(self):
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # good pair on both sides -- must survive the malformed siblings below
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}})
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 120)]}})
+ # well-formed JSON, wrong shape (a list, not a {example: {files: [...]}} dict)
+ wrong_shape = os.path.join(base, 'cmake-build-stm32f407disco', 'metrics_by_example.json')
+ os.makedirs(os.path.dirname(wrong_shape), exist_ok=True)
+ with open(wrong_shape, 'w') as f:
+ json.dump(['not', 'a', 'dict'], f)
+ # metrics_by_example.json not under a cmake-build-<board> dir
+ misplaced = os.path.join(base, 'not_a_board_dir', 'metrics_by_example.json')
+ os.makedirs(os.path.dirname(misplaced), exist_ok=True)
+ with open(misplaced, 'w') as f:
+ json.dump({'device/dfu': {'files': [entry('dfu_device.c', 10)]}}, f)
+
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr) # fail-open: never crash the job
+ md = open(out + '.md').read()
+ self.assertIn('usbd.c', md) # good pair still compared
+ self.assertIn(wrong_shape, r.stderr)
+ self.assertIn(misplaced, r.stderr)
+ self.assertIn('skipping', r.stderr)
+
+
+ def test_missing_base_baseline_gets_its_own_note(self):
+ # interim state right after this feature merges: master has not uploaded a
+ # per-example baseline yet, so the BASE side collects nothing. The generic
+ # "no pair on both sides" note misattributes that to the PR's own scoping.
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ os.makedirs(base)
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('No per-example baseline from the base branch yet', md)
+ self.assertIn('next push', md)
+ self.assertNotIn('comparison skipped', md)
+
+ def test_a_partially_malformed_file_contributes_nothing(self):
+ """A file that blows up half way through must drop WHOLE. Entries parsed
+ before the malformation used to stay in the comparison while stderr claimed
+ the file had been skipped - a silently truncated table published as the
+ code-size verdict. A non-list 'files' (TypeError) also has to be caught."""
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # good entry FIRST, malformed second: the leak is order-dependent
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('leaked.c', 100)]},
+ 'device/dfu': {'files': 42}})
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('leaked.c', 120)]}})
+ # a sibling file that is fine on both sides must still be compared
+ fake_by_example(base, 'stm32f407disco',
+ {'device/cdc_msc': {'files': [entry('good.c', 10)]}})
+ fake_by_example(new, 'stm32f407disco',
+ {'device/cdc_msc': {'files': [entry('good.c', 12)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('good.c', md)
+ self.assertNotIn('leaked.c', md)
+ self.assertIn('skipping', r.stderr)
+ self.assertIn(os.path.join(base, 'cmake-build-raspberry_pi_pico'), r.stderr)
+
+ def test_dropped_footer_is_summarised_not_dumped(self):
+ """The sticky PR comment is capped at 65,536 chars by GitHub; a broad scoped
+ PR drops hundreds of (family, example) pairs and the full list alone ran to
+ tens of KB, pushing the comment past the cap and reddening code-metrics."""
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ common = {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}}
+ extra = {f'device/example_{i:03d}': {'files': [entry(f'f{i}.c', i + 1)]}
+ for i in range(30)}
+ fake_by_example(base, 'raspberry_pi_pico', dict(common, **extra))
+ fake_by_example(new, 'raspberry_pi_pico', common)
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ footer = md[md.index('_Scoped compare:'):]
+ self.assertLess(len(footer), 2048, footer)
+ self.assertIn('30', footer) # the count is still reported
+ self.assertIn('more', footer) # truncation marker
+ self.assertIn('device/example_029', r.stderr) # full list on stderr
+
+
+CIRCLECI = os.path.join(REPO, '.circleci')
+SENTINELS = ('example-map-default', 'build-filtered-default')
+
+
+class TestCircleCiSentinelContract(unittest.TestCase):
+ """config.yml's set-matrix rewrites config2.yml's parameter defaults by matching
+ a sentinel comment line — the only way past /pipeline/continue's 512-char
+ parameter cap. Renaming or reformatting either side is a silent full-build
+ fallback that no CI job reports, so pin the contract here."""
+
+ def setUp(self):
+ self.config = open(os.path.join(CIRCLECI, 'config.yml')).read()
+ self.config2 = open(os.path.join(CIRCLECI, 'config2.yml')).read()
+
+ def test_each_sentinel_appears_once_on_a_default_line(self):
+ for tag in SENTINELS:
+ marker = f'# {tag}: rewritten in-place by config.yml set-matrix'
+ hits = [l for l in self.config2.splitlines() if l.strip().endswith(marker)]
+ self.assertEqual(len(hits), 1, f'{tag}: {len(hits)} sentinel lines in config2.yml')
+ self.assertIn('default:', hits[0], f'{tag}: sentinel is not on a default: line')
+
+ def test_the_selection_travels_as_a_file(self):
+ # a mass-sweep selection runs to hundreds of KB: handed to ci_set_matrix as one
+ # argv it E2BIGs the step before the `||` fallback can fire, and EXAMPLE_MAP /
+ # BUILD_FILTERED (derived with jq, no argv limit) would then label a FULL build
+ # scoped -- the build and its label disagreeing is worse than either alone
+ self.assertIn('--select-file', self.config)
+ self.assertNotIn('--select "', self.config)
+
+ def test_the_rewriter_names_the_same_sentinels(self):
+ for tag in SENTINELS:
+ self.assertIn(f"'{tag}'", self.config,
+ f'{tag}: config.yml rewrite block does not name this sentinel')
+ self.assertIn("# {tag}: rewritten in-place by config.yml set-matrix", self.config,
+ 'config.yml no longer builds the sentinel comment it matches on')
+
+ def test_the_rewrite_precedes_the_scoped_entries(self):
+ # the scoping is all-or-nothing: config2's checked-in defaults are {} / false =
+ # unfiltered, so a rewrite that fails AFTER the family entries were generated
+ # leaves a subset of families built and code-metrics told it was a full build.
+ # Rewrite first, and on failure drop the scoping (back to the full matrix).
+ rewrite = self.config.index("p = '.circleci/config2.yml'")
+ entries = self.config.index('gen_build_entry() {')
+ self.assertLess(rewrite, entries,
+ 'the sentinel rewrite must run before any build entry is generated')
+ tail = self.config[rewrite:entries]
+ self.assertIn('MATRIX_JSON="$FULL_MATRIX_JSON"', tail,
+ 'a failed rewrite must fall back to the FULL matrix, not keep the '
+ 'scoped one')
+ # and that fallback must be a plain assignment: a second `python ...` here is an
+ # unguarded command under CircleCI's `set -e`, inside the one branch whose whole
+ # job is to keep the pipeline green
+ self.assertNotIn('ci_set_matrix.py)', tail)
+
+ def test_the_selector_gate_runs_both_suites(self):
+ # test_ci_select.py owns the rules; this file owns the sentinel contract the
+ # very same job rewrites. Gating on one of the two leaves the other unguarded.
+ for suite in ('test_ci_select.py', 'test_ci_metrics.py'):
+ self.assertIn(suite, self.config, f'{suite} does not gate the CircleCI selector')
+
+
+class TestWorkflowSelectionHandOff(unittest.TestCase):
+ """build.yml's counterpart of the CircleCI contract above: same E2BIG limit, same
+ consequence (the scoping silently turns itself off on exactly the PRs where it
+ saves most), plus the GITHUB_ENV lines that carry PR-derived values."""
+
+ def setUp(self):
+ wf = os.path.join(os.path.dirname(CIRCLECI), '.github', 'workflows')
+ self.build = open(os.path.join(wf, 'build.yml')).read()
+ self.util = open(os.path.join(wf, 'build_util.yml')).read()
+
+ def test_no_step_execs_with_the_selection_in_its_environment(self):
+ # SELECT_JSON="$SELECT_JSON" python3 -c ... E2BIGs at ~128KiB: measured 261KB
+ # for a `git ls-files hw/bsp/**` sweep. Every reader takes the file instead.
+ self.assertNotIn('SELECT_JSON="$SELECT_JSON"', self.build)
+ self.assertIn('json.load(open("ci_select_out.json"))', self.build)
+
+ def test_the_file_is_written_before_its_first_reader(self):
+ self.assertLess(self.build.index("printf '%s' \"$SELECT_JSON\" > ci_select_out.json"),
+ self.build.index('json.load(open("ci_select_out.json"))'),
+ 'the selection file must exist before the step that reads it')
+
+ def test_pr_derived_env_values_are_character_guarded(self):
+ # values reach GITHUB_ENV/GITHUB_OUTPUT as bare NAME=VALUE lines; a newline in
+ # one (git allows it in a path, and both the example map and the roster are
+ # PR-editable) writes extra variables into every later step of a job that runs
+ # with secrets - and for run_*, flips which rig jobs execute
+ for name in ('EX_ARGS', 'ARTIFACT_TAG'):
+ self.assertIn(f'echo "{name}=', self.util)
+ # per guard, not a sum: `count(a) + count(b) == 2` stays green when one guard is
+ # deleted and the other duplicated
+ for guard in ('case "$EX_ARGS" in', 'case "$TAG" in'):
+ self.assertEqual(self.util.count(guard), 1,
+ f'{guard}: each GITHUB_ENV write screens its value exactly once')
+ # CircleCI builds from the same PR-derived map and uses $EX_ARGS unquoted
+ cci = open(os.path.join(CIRCLECI, 'config2.yml')).read()
+ self.assertIn('case "$EX_ARGS" in', cci,
+ 'the CircleCI copy of the example filter needs the same screen')
+ self.assertIn('case "$BUILD_ARGS" in', self.build)
+ self.assertIn('unexpected characters in the " + key', self.build,
+ 'the args_*/run_* emitter must screen each board filter')
+
+ def test_the_guards_accept_what_the_selector_actually_emits(self):
+ """A guard that rejects a NORMAL value is worse than no guard: build.yml throws
+ the whole selection away, warns, and both axes fall back to full - silently
+ turning the feature off. So run the real character classes over real selections
+ rather than only asserting that the guard text is present.
+
+ The one that got away: `[-A-Za-z0-9_/ .=+]` has no ':' or ',', and every partial
+ board filter is `-bt <board>:<test>,<test>`."""
+ import re, subprocess, sys, tempfile, json
+ repo = os.path.dirname(CIRCLECI)
+ # the character classes, lifted from the three places they are written
+ classes = {}
+ m = re.search(r're\.fullmatch\(r"\[([^"]+)\]\*"', self.build)
+ self.assertTrue(m, 'args_*/run_* guard not found in build.yml')
+ classes['args'] = m.group(1)
+ for name, text in (('BUILD_ARGS', self.build), ('EX_ARGS', self.util),
+ ('TAG', self.util)):
+ m = re.search(r'case "\$%s" in\s*\n\s*\*\[!([^\]]+)\]\*\)' % name, text)
+ self.assertTrue(m, f'{name} guard not found')
+ classes[name] = m.group(1).replace('\\', '')
+
+ def ok(cls, value):
+ return re.fullmatch('[%s]*' % cls.replace('!', ''), value) is not None
+
+ with tempfile.TemporaryDirectory() as d:
+ for path in ('src/class/cdc/cdc_device.c', 'src/device/usbd.c',
+ 'src/portable/synopsys/dwc2/dcd_dwc2.c',
+ 'examples/device/cdc_msc/src/main.c',
+ 'hw/bsp/stm32f4/family.cmake'):
+ f = os.path.join(d, 'diff.txt')
+ with open(f, 'w') as fh:
+ fh.write(path + '\n')
+ r = subprocess.run([sys.executable, os.path.join(repo, 'tools/ci_select.py'),
+ '--diff-file', f,
+ os.path.join(repo, 'test/hil/tinyusb.json'),
+ os.path.join(repo, 'test/hil/hfp.json')],
+ capture_output=True, text=True, cwd=repo)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ s = json.loads(r.stdout)
+ for flasher, a in s.get('args_flasher', {}).get('tinyusb.json', {}).items():
+ self.assertTrue(ok(classes['args'], a),
+ f'{path}/{flasher}: the args guard rejects {a!r}')
+ hfp = s.get('args', {}).get('hfp.json', '')
+ self.assertTrue(ok(classes['args'], hfp), f'{path}: hfp {hfp!r}')
+ # BUILD_ARGS is the hfp job's `-b <board> [-e ...]` list, not the -bt
+ # test filter above - screen the value that step actually builds
+ with open(os.path.join(d, 'sel.json'), 'w') as fh:
+ fh.write(r.stdout)
+ hm = subprocess.run(
+ [sys.executable, os.path.join(repo, '.github/scripts/hil_ci_set_matrix.py'),
+ '--select-file', os.path.join(d, 'sel.json'),
+ os.path.join(repo, 'test/hil/hfp.json')],
+ capture_output=True, text=True, cwd=repo)
+ self.assertEqual(hm.returncode, 0, hm.stderr)
+ build_args = ' '.join(json.loads(hm.stdout)['arm-gcc'])
+ self.assertTrue(ok(classes['BUILD_ARGS'], build_args),
+ f'{path}: the BUILD_ARGS guard rejects {build_args!r}')
+ for entry in json.loads(hm.stdout)['arm-gcc']:
+ tag = re.sub(r' -e [^ ]+', '', entry)
+ self.assertTrue(ok(classes['TAG'], tag),
+ f'{path}: the artifact-name guard rejects {tag!r}')
+ for fam, exs in (s.get('build', {}).get('family_examples') or {}).items():
+ ex_args = ' '.join('-e ' + e for e in exs)
+ self.assertTrue(ok(classes['EX_ARGS'], ex_args),
+ f'{path}/{fam}: the EX_ARGS guard rejects {ex_args!r}')
+
+ def test_an_unusable_selection_is_unusable_for_both_matrices(self):
+ # hil_ci_set_matrix reads "full false with no boards map" as unusable and falls
+ # open to the whole roster; if this emitter instead computed run_*=false, the
+ # rig jobs would skip while all 37 build legs ran - a full build and still zero
+ # hardware coverage, which is the outcome the guard exists to prevent
+ self.assertIn('isinstance(s.get("boards"), dict)', self.build)
+
+ def test_the_build_extras_drop_when_the_matrix_falls_open(self):
+ # ci_set_matrix falls open with rc 0, so the example map and family regex must
+ # follow it or a nominally full build is filtered and labelled as a scoped one
+ self.assertIn("grep -q 'ci_set_matrix: UNSCOPED'", self.build)
+ self.assertIn('BUILD_SELECT_FILE', self.build)
+ scripts = os.path.join(os.path.dirname(CIRCLECI), '.github', 'scripts')
+ matrix = open(os.path.join(scripts, 'ci_set_matrix.py')).read()
+ # count-independent: pin the INVARIANT, not the number of fall-open paths -
+ # every message that emits the full matrix must carry the marker, and a purely
+ # informational note (a partial family miss) must not claim to have done so.
+ # Adjacent string literals are joined first, since these messages wrap.
+ import re as _re
+ flat = _re.sub(r"['\"]\s*\n\s*f?['\"]", '', matrix)
+ hits = [m.start() for m in _re.finditer('emitting the full ', flat)]
+ self.assertGreaterEqual(len(hits), 2, 'fall-open messages not found')
+ for i in hits:
+ self.assertIn('UNSCOPED', flat[max(0, i - 200):i],
+ 'a fall-open path without the marker build.yml greps for')
+
+ def _run_extras_block(self, sel):
+ """Extract the build-extras shell block from build.yml and run it for real.
+ Nothing else exercises it, which is why the empty/rejected conflation shipped."""
+ import re as _re, shlex, subprocess, tempfile, json as _json
+ repo = os.path.dirname(CIRCLECI)
+ i = self.build.index("EXAMPLE_MAP='{}'\n BUILD_FILTERED='false'")
+ i = self.build.rindex('\n', 0, i) + 1
+ j = self.build.index(' echo "matrix=$MATRIX_JSON"', i)
+ block = _re.sub(r'^ {10}', '', self.build[i:j], flags=_re.M)
+ with tempfile.TemporaryDirectory() as d:
+ selp = os.path.join(d, 'sel.json')
+ with open(selp, 'w') as fh:
+ _json.dump(sel, fh)
+ matrix = subprocess.run(
+ [sys.executable, os.path.join(repo, '.github/scripts/ci_set_matrix.py'),
+ '--select-file', selp], capture_output=True, text=True, cwd=repo).stdout.strip()
+ self.assertTrue(matrix, 'ci_set_matrix produced nothing')
+ sh = os.path.join(d, 'probe.sh')
+ with open(sh, 'w') as fh:
+ # shlex.quote, not hand-rolled quoting: a TMPDIR with a space in it
+ # made this fail for a reason that had nothing to do with the block
+ fh.write('BUILD_SELECT_FILE=' + shlex.quote(selp) + '\n')
+ fh.write('MATRIX_JSON=' + shlex.quote(matrix) + '\n')
+ fh.write(block)
+ # sentinel + newline separated: the block itself writes ::warning:: to
+ # stdout, and '|' would collide with the regex's own separator
+ fh.write('\nprintf "@@R@@\\n%s\\n%s\\n%s" "$MATRIX_JSON" "$BUILD_FILTERED" "$FAMILY_REGEX"\n')
+ r = subprocess.run(['bash', sh], capture_output=True, text=True, cwd=repo)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ mj, filtered, regex = r.stdout.split('@@R@@\n', 1)[1].split('\n', 2)
+ return sum(len(v) for v in _json.loads(mj).values()), filtered, regex
+
+ def test_an_empty_family_list_is_not_treated_as_unusable(self):
+ """.build.families is read twice - as a count and as a `|`-joined regex. An EMPTY
+ list and one REJECTED by the charset guard both leave the regex empty and mean
+ opposite things, so the block has to branch on which happened.
+
+ Testing `-z "$FAMILY_REGEX"` alone sent every nothing-selected PR down the
+ fall-open path and discarded the correct all-empty matrix: #3842 (docs +
+ .gitignore) and #3840 (test/hil only) each rebuilt all 74 cmake legs after the
+ selector had correctly chosen none."""
+ legs, filtered, regex = self._run_extras_block(
+ {'build': {'full': False, 'families': [], 'family_examples': {}}})
+ self.assertEqual(legs, 0, 'an empty families list must keep the all-empty matrix')
+ self.assertEqual(filtered, 'false', 'nothing was built, so nothing to compare')
+ self.assertEqual(regex, '')
+
+ def test_a_real_family_list_stays_scoped(self):
+ legs, filtered, regex = self._run_extras_block(
+ {'build': {'full': False, 'families': ['stm32f4', 'rp2040'],
+ 'family_examples': {}}})
+ self.assertGreater(legs, 0)
+ self.assertEqual(filtered, 'true')
+ self.assertEqual(regex, 'stm32f4|rp2040')
+
+ def test_a_regex_metacharacter_in_a_family_name_falls_open(self):
+ # the name is interpolated raw into a name_is_regexp artifact pattern, so a
+ # metacharacter would match another family's baseline - reject and widen
+ legs, filtered, regex = self._run_extras_block(
+ {'build': {'full': False, 'families': ['stm32f4.*'], 'family_examples': {}}})
+ self.assertGreater(legs, 100, 'a rejected family list must fall open to full')
+ self.assertEqual(filtered, 'false')
+ self.assertEqual(regex, '')
+
+ def test_membrowse_upload_is_not_scoped_by_the_pr_filter(self):
+ # by decision, the upload runs unfiltered so the size history stays keyed on the
+ # family's preferred board whatever the PR touched. $EX_ARGS would not have
+ # scoped the targets either way - `examples-membrowse-upload` is not `all`, so
+ # resolve_example_target_groups passes it through as the aggregate - but it DID
+ # move the board, because --one-first picks one that can build the -e set.
+ #
+ # The accepted cost: on a family whose preferred board cannot build that set,
+ # the upload lands on a board the Build step never compiled and every example
+ # goes up --identical. test_the_upload_board_can_diverge_from_the_built_board
+ # keeps that consequence measured rather than assumed.
+ line = [l for l in self.util.splitlines()
+ if '--target examples-membrowse-upload' in l][0]
+ self.assertNotIn('$EX_ARGS', line)
+ self.assertNotIn('-e ', line)
+
+ def test_the_upload_board_can_diverge_from_the_built_board(self):
+ """Pins the SIZE of what the removal gave up, so it cannot grow unnoticed.
+
+ --one-first with no -e returns preferred_list[0]; with one it returns the first
+ preferred board that can build it. Where those differ, the Membrowse Upload step
+ configures a build dir the Build step never wrote.
+
+ ci=True unconditionally, as _prune_buildable does and for the same reason: the
+ answer must be the runner's, not the developer's. The CI skip lists are off by
+ default locally, which moves the pick on three families - this test asserted the
+ local set and went red on its first CI run."""
+ sys.path.insert(0, os.path.join(REPO, 'tools'))
+ import build as build_py
+ roles = ('device', 'host', 'dual')
+ exs = sorted(f'{r}/{n}' for r in roles
+ for n in os.listdir(os.path.join(REPO, 'examples', r))
+ if os.path.isdir(os.path.join(REPO, 'examples', r, n)))
+ fams = sorted(d for d in os.listdir(os.path.join(REPO, 'hw/bsp'))
+ if os.path.isdir(os.path.join(REPO, 'hw/bsp', d, 'boards')))
+ cwd = os.getcwd()
+ os.chdir(REPO)
+ try:
+ diverging = set()
+ for fam in fams:
+ try:
+ base = build_py.get_family_boards(fam, False, True, None, 'cmake',
+ (), ci=True)
+ except Exception:
+ continue
+ if not base:
+ continue
+ for e in exs:
+ try:
+ one = build_py.get_family_boards(fam, False, True, [e], 'cmake',
+ (), ci=True)
+ except Exception:
+ continue
+ if one and one[0] != base[0]:
+ diverging.add(fam)
+ break
+ finally:
+ os.chdir(cwd)
+ self.assertEqual(diverging, {'imxrt', 'lpc11', 'lpc18', 'lpc54', 'mcx', 'rx',
+ 'samd11', 'samd2x_l2x', 'samd5x_e5x', 'stm32l0',
+ 'stm32l4', 'tm4c'},
+ 'the set of families whose membrowse upload can land on an '
+ 'uncompiled board changed; re-check whether dropping $EX_ARGS '
+ 'from the upload step is still the right trade')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
new file mode 100644
index 000000000..22fbde17b
--- /dev/null
+++ b/test/hil/test/test_ci_select.py
@@ -0,0 +1,2602 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for ci_select.py — pure logic, no hardware, no git. Run directly:
+# python3 test/hil/test/test_ci_select.py
+#
+# Imports stay stdlib + ci_select/hil_util/hil_flash ONLY: the pre-commit hil-test
+# hook runs this suite, on GitHub's bare runner in the pre-commit workflow as well as
+# locally, and that runner has no pyserial/pymtp. hil_flash is admissible because it
+# is stdlib + hil_util only (test_hil_util.BottomLayer enforces the stdlib closure of
+# both) and the roster-dispatch tests need its flash_* table; never import hil_test,
+# which pulls pyserial.
+import contextlib
+import glob
+import io
+import json
+import os
+import pathlib
+import re
+import subprocess
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # test/hil, for hil_flash/helper
+sys.path.insert(0, os.path.join(REPO, 'tools'))
+import hil_flash
+import ci_select
+from helper.hil_util import device_tests, dual_tests
+
+
+def real_rosters():
+ """The actual rig rosters, for regression tests that need real-world data
+ (a specific board/family/only-list) rather than the synthetic ROSTER above."""
+ rosters = []
+ for name in ('tinyusb.json', 'hfp.json'):
+ path = os.path.join(REPO, 'test/hil', name)
+ with open(path) as f:
+ rosters.append((f'test/hil/{name}', json.load(f)['boards']))
+ return rosters
+
+
+def roster_flashers():
+ """(roster path, board) for every board in the live rosters, `boards-skip`
+ included: a parked board's flasher name must still dispatch, so that unparking it
+ is not what discovers the name went stale."""
+ for name in ('tinyusb.json', 'hfp.json'):
+ path = os.path.join(REPO, 'test/hil', name)
+ with open(path) as f:
+ cfg = json.load(f)
+ for key in ('boards', 'boards-skip'):
+ for b in cfg.get(key, []):
+ yield f'test/hil/{name}', b
+
+
+def on_roster(tc, *names):
+ """The subset of `names` currently in the live rig rosters, skipping the test
+ when none are, because parking/unparking a board is routine rig maintenance.
+
+ That skip now matters MORE than it used to, not less: this suite is a blocking
+ pre-commit hook AND build.yml's selector steps gate on it (a failing suite falls
+ open to the full matrix), so an assertion that depends on a specific board being
+ present goes red on every PR -- including src/-only ones that never touched the
+ rig -- until someone fixes the roster. Keep roster-dependent assertions behind
+ on_roster."""
+ have = {b['name'] for _, boards in real_rosters() for b in boards}
+ got = [n for n in names if n in have]
+ if not got:
+ tc.skipTest(f'not in the rig roster: {", ".join(names)}')
+ return got
+
+
+ROSTER = [
+ # device-only, rp2040 family
+ {'name': 'raspberry_pi_pico', 'uid': 'u1', 'flasher': {'name': 'openocd'},
+ 'tests': {'device': True, 'host': True, 'dual': True}},
+ # device-only, stm32f4 family
+ {'name': 'stm32f407disco', 'uid': 'u2', 'flasher': {'name': 'jlink'},
+ 'tests': {'device': True, 'host': False, 'dual': False}},
+ # host-only board
+ {'name': 'raspberry_pi_pico2', 'uid': 'u3', 'flasher': {'name': 'openocd'},
+ 'tests': {'device': False, 'host': True, 'dual': False}},
+ # only-list board (espressif-style), flashed by the CI leg that splits on esptool
+ {'name': 'espressif_s3_devkitm', 'uid': 'u4', 'flasher': {'name': 'esptool'},
+ 'tests': {'only': ['device/cdc_msc_freertos', 'host/device_info']}},
+]
+ROSTERS = [('test/hil/tinyusb.json', ROSTER)]
+
+
+def sel(files):
+ return ci_select.classify(files, REPO, ROSTERS)
+
+
+class TestPortRule(unittest.TestCase):
+ def test_dcd_rp2040_selects_pico_family_only(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico', s['boards'])
+ self.assertNotIn('stm32f407disco', s['boards'])
+ self.assertNotIn('espressif_s3_devkitm', s['boards'])
+ # device role: no host tests in pico's list
+ self.assertTrue(all(not t.startswith('host/') for t in s['boards']['raspberry_pi_pico']))
+ # host-only boards drop out entirely on a device-role change
+ self.assertNotIn('raspberry_pi_pico2', s['boards'])
+
+ def test_shared_port_file_is_both_roles(self):
+ s = sel(['src/portable/synopsys/dwc2/dwc2_common.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('raspberry_pi_pico', s['boards']) # rp2040 is not a dwc2 family
+ self.assertIn('stm32f407disco', s['boards']) # stm32f4 is
+
+
+class TestCoreRoleRule(unittest.TestCase):
+ def test_usbd_selects_all_device_tests_everywhere(self):
+ s = sel(['src/device/usbd.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('raspberry_pi_pico2', s['boards']) # host-only board dropped
+ pico = s['boards']['raspberry_pi_pico']
+ self.assertTrue(set(device_tests).issubset(set(pico)))
+ self.assertTrue(set(dual_tests).issubset(set(pico))) # dual survives device role
+ self.assertTrue(all(not t.startswith('host/') for t in pico))
+ # only-list board: selection intersects its only-list
+ esp = s['boards']['espressif_s3_devkitm']
+ self.assertEqual(esp, ['device/cdc_msc_freertos'])
+
+ def test_host_change_drops_device(self):
+ s = sel(['src/host/usbh.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico2', s['boards'])
+ self.assertNotIn('stm32f407disco', s['boards']) # device-only board dropped
+
+
+class TestClassRule(unittest.TestCase):
+ def test_cdc_device_selects_cdc_examples_only(self):
+ s = sel(['src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ pico = s['boards']['raspberry_pi_pico']
+ self.assertIn('device/cdc_msc', pico)
+ self.assertIn('device/cdc_dual_ports', pico)
+ self.assertNotIn('device/msc_dual_lun', pico) # CFG_TUD_CDC 0 there
+ self.assertNotIn('device/usbtest', pico) # CFG_TUD_CDC 0 there
+ self.assertTrue(all(not t.startswith('host/') for t in pico))
+
+ def test_msc_host_selects_host_side(self):
+ s = sel(['src/class/msc/msc_host.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('stm32f407disco', s['boards']) # device-only board
+ pico2 = s['boards']['raspberry_pi_pico2']
+ self.assertIn('host/msc_file_explorer', pico2)
+ self.assertTrue(all(not t.startswith('device/') for t in pico2))
+
+
+class TestClassIncludeEdges(unittest.TestCase):
+ """A class header another class includes reaches that class's examples too.
+ src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, so
+ midi_test's firmware contains audio.h - but the class rule derives macros from
+ the directory name alone, so an audio.h change used to select only
+ device/audio_test_freertos. On boards that skip that example the per-board
+ intersection emptied and an audio.h-only PR ran ZERO HIL on them."""
+ def test_edges_derived_from_includes(self):
+ edges = ci_select.class_include_edges(REPO)
+ self.assertEqual(edges.get('audio/audio.h'), {'midi'})
+ self.assertEqual(edges.get('cdc/cdc.h'), {'net'})
+
+ def test_audio_header_selects_midi_example(self):
+ s = ci_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ # every board that runs device/midi_test at all must run it here (boards with
+ # a tests.only list, e.g. espressif, run the freertos examples instead)
+ by_name = {b['name']: b for _, bs in real_rosters() for b in bs}
+ checked = 0
+ for name, tests in s['boards'].items():
+ if 'device/midi_test' in ci_select.board_tests(by_name[name]):
+ self.assertIn('device/midi_test', tests, name)
+ checked += 1
+ self.assertTrue(checked)
+
+ def test_audio_header_reaches_boards_that_skip_audio(self):
+ # both skip device/audio_test_freertos: without the midi edge their
+ # intersection is empty and they drop out of the selection entirely
+ boards = on_roster(self, 'metro_m4_express', 'nrf54lm20dk')
+ s = ci_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
+ for board in boards:
+ self.assertEqual(s['boards'].get(board), ['device/midi_test'], board)
+
+ def test_edge_is_per_header_not_per_class(self):
+ # midi includes audio.h, not audio_device.h: an audio_device change must
+ # not drag midi's examples in
+ s = ci_select.classify(['src/class/audio/audio_device.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for tests in s['boards'].values():
+ if tests != 'all':
+ self.assertNotIn('device/midi_test', tests)
+
+
+class TestFallbackRules(unittest.TestCase):
+ def test_unknown_tool_is_full(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertTrue(s['full'])
+
+ def test_docs_only_is_empty_not_full(self):
+ s = sel(['docs/info/contributing.rst', 'README.rst'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_bsp_family_selects_family_boards(self):
+ s = sel(['hw/bsp/rp2040/family.cmake'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico', s['boards'])
+ self.assertEqual(s['boards']['raspberry_pi_pico'], 'all')
+ self.assertNotIn('stm32f407disco', s['boards'])
+
+ def test_bsp_board_narrows_to_board(self):
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ self.assertFalse(s['full'])
+ self.assertEqual(list(s['boards'].keys()), ['raspberry_pi_pico'])
+
+ def test_example_change_selects_that_example(self):
+ s = sel(['examples/device/cdc_msc/src/main.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards']['raspberry_pi_pico'], ['device/cdc_msc'])
+
+ def test_core_common_is_full(self):
+ for f in ['src/tusb.c', 'src/common/tusb_fifo.c', 'src/osal/osal_freertos.h']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_board_test_example_is_full(self):
+ # board_test is the park/teardown firmware hil_test.py flashes on every board,
+ # not an unlisted example: a regression there must not skip the whole rig
+ for f in ['examples/device/board_test/src/main.c',
+ 'examples/device/board_test/CMakeLists.txt']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_harness_is_full(self):
+ # hw/mcu/ is no longer here: it resolves to families/boards via mcu_families()
+ # instead of forcing full - see TestMcuHilRule
+ for f in ['test/hil/hil_test.py', '.github/workflows/build.yml']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_mixed_roles_no_pruning(self):
+ s = sel(['src/device/usbd.c', 'src/host/usbh.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico2', s['boards'])
+ self.assertIn('stm32f407disco', s['boards'])
+
+ def test_cmakelists_and_requirements_are_full(self):
+ for f in ['src/CMakeLists.txt', 'examples/CMakeLists.txt',
+ 'examples/device/CMakeLists.txt', 'test/hil/requirements.txt']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_docs_txt_is_noncode(self):
+ s = sel(['docs/info/changelog.txt'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+
+class TestArgsEmission(unittest.TestCase):
+ def test_args_for_scoped_selection(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ args = ci_select.selection_args(s, ROSTERS)
+ a = args['tinyusb.json']
+ self.assertIn('-b raspberry_pi_pico', a)
+ self.assertNotIn('stm32f407disco', a)
+ self.assertIn('-bt raspberry_pi_pico:', a) # device-only subset of a device+host board
+
+ def test_args_full_is_empty(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertEqual(ci_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
+
+ def test_args_all_board_gets_bare_b(self):
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ a = ci_select.selection_args(s, ROSTERS)['tinyusb.json']
+ self.assertIn('-b raspberry_pi_pico', a)
+ self.assertNotIn('-bt', a)
+
+ def test_args_by_flasher_splits_esp_from_the_rest(self):
+ s = sel(['src/device/usbd.c'])
+ per = ci_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
+ self.assertIn('espressif_s3_devkitm', per['esptool'])
+ self.assertIn('raspberry_pi_pico', per['openocd'])
+ self.assertNotIn('espressif_s3_devkitm', per.get('openocd', '') + per.get('jlink', ''))
+
+ def test_args_by_flasher_omits_a_flasher_with_no_selected_board(self):
+ # the esp CI leg must see no args at all here, not a filter matching zero boards
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ per = ci_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
+ self.assertEqual(per, {'openocd': '-b raspberry_pi_pico'})
+
+ def test_args_by_flasher_full_is_empty(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertEqual(ci_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
+
+ def test_cli_diff_file(self):
+ import subprocess, tempfile, json as j
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('src/class/cdc/cdc_device.c\n')
+ path = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ out = j.loads(r.stdout)
+ self.assertFalse(out['full'])
+ self.assertIn('tinyusb.json', out['args'])
+ # reasons are a stderr diagnostic, deliberately NOT in the payload: they were
+ # 97% of a 9.8 MB JSON on a dep bump, and every consumer re-parses that file
+ self.assertNotIn('reasons', out, 'reasons must not ride in the machine-read JSON')
+ self.assertNotIn('reasons', out['build'])
+ self.assertIn('cdc_device', r.stderr)
+ # A core-class diff must select boards THROUGH THE CLI: the in-process tests
+ # inject their own repo root, so only this subprocess path catches a broken
+ # repo_root derivation -- which once made every repo-relative glob match
+ # nothing and turned this exact diff into a silent full-HIL skip.
+ self.assertTrue(out['boards'],
+ 'CLI selected zero boards for a src/class change: repo_root broken?')
+ os.unlink(path)
+
+
+class TestRealRosterPortFamilies(unittest.TestCase):
+ """Regression for port_families() missing espressif's dwc2 reference, which
+ lives in a component CMakeLists.txt rather than family.cmake/family.mk."""
+ def test_dwc2_change_selects_espressif_boards(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertIn(board, s['boards'])
+
+
+class TestOptionGatedPort(unittest.TestCase):
+ """Regression: family_support.cmake compiles some ports from a build option
+ (MAX3421_HOST=1 -> hcd_max3421.c), so a board's family file never names them."""
+ # host-side option board (max3421 as host controller), off any max3421 family
+ OPT_ROSTER = [('test/hil/opt.json', [
+ {'name': 'fake_dual_board', 'uid': 'o1', 'flasher': {'name': 'jlink'},
+ 'variant': [{'name': 'fake_dual_board', 'defines': ['MAX3421_HOST=1']}],
+ 'tests': {'device': True, 'host': False, 'dual': True}},
+ {'name': 'fake_host_board', 'uid': 'o2', 'flasher': {'name': 'jlink'},
+ 'variant': [{'name': 'fake_host_board', 'flags': '-DMAX3421_HOST=1'}],
+ 'tests': {'device': False, 'host': True, 'dual': False}},
+ {'name': 'fake_off_board', 'uid': 'o3', 'flasher': {'name': 'jlink'},
+ 'variant': [{'name': 'fake_off_board', 'defines': ['MAX3421_HOST=0']}],
+ 'tests': {'device': True, 'host': True, 'dual': True}},
+ ])]
+
+ def test_real_roster_max3421_selects_option_board(self):
+ boards = on_roster(self, 'metro_m4_express')
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertIn(board, s['boards'])
+
+ def test_option_selects_via_defines_and_flags(self):
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, self.OPT_ROSTER)
+ self.assertFalse(s['full'])
+ self.assertIn('fake_dual_board', s['boards']) # variant defines
+ self.assertIn('fake_host_board', s['boards']) # variant flags
+ self.assertNotIn('fake_off_board', s['boards']) # variant defines, but =0
+
+ def test_device_role_port_does_not_pull_host_only_option_board(self):
+ s = ci_select.classify(['src/portable/analog/max3421/dcd_max3421.c'], REPO, self.OPT_ROSTER)
+ self.assertFalse(s['full'])
+ self.assertNotIn('fake_host_board', s['boards']) # host-only board, device change
+ self.assertIn('fake_dual_board', s['boards']) # device-capable option board
+
+ def test_gates_parsed_from_family_support(self):
+ self.assertEqual(ci_select.port_option_gates(REPO).get('analog/max3421'),
+ {'MAX3421_HOST'})
+
+ def test_board_cmake_option_counts(self):
+ """A board can enable a gated port in its own BSP rather than via the roster
+ (hw/bsp/espressif/boards/*/board.cmake -> set(MAX3421_HOST 1)); board_options()
+ must see those too, or such a board joining the roster is silently dropped."""
+ self.assertIn('MAX3421_HOST',
+ ci_select.bsp_board_options('adafruit_feather_esp32s3', REPO))
+ self.assertIn('CFG_TUH_RPI_PIO_USB',
+ ci_select.bsp_board_options('adafruit_fruit_jam', REPO))
+ # commented-out `# set(MAX3421_HOST 1)` must not count
+ self.assertNotIn('MAX3421_HOST',
+ ci_select.bsp_board_options('feather_nrf52840_express', REPO))
+
+ def test_board_cmake_option_selects_off_family_board(self):
+ # adafruit_feather_esp32s3 is not on any rig roster; stand it in as one to
+ # prove the BSP-sourced option alone pulls a max3421 change onto the board
+ roster = [('test/hil/opt.json', [
+ {'name': 'adafruit_feather_esp32s3', 'uid': 'o1', 'flasher': {'name': 'esptool'},
+ 'tests': {'device': False, 'host': True, 'dual': False}}])]
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
+ self.assertFalse(s['full'])
+ self.assertIn('adafruit_feather_esp32s3', s['boards'])
+
+ def test_board_mk_option_is_ignored(self):
+ """Make-only options must not select: HIL CI builds with CMake exclusively, so
+ hw/bsp/nrf/boards/nrf5340dk/board.mk's MAX3421_HOST compiles nothing here."""
+ roster = [('test/hil/opt.json', [
+ {'name': 'nrf5340dk', 'uid': 'o1', 'flasher': {'name': 'jlink'},
+ 'tests': {'device': False, 'host': True, 'dual': False}}])]
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+
+class TestPortFamiliesCmakeOnly(unittest.TestCase):
+ """port_families() is CMake-only (HIL CI never builds with Make) and matches on
+ 'port_dir/' so a port dir is not a prefix of a sibling."""
+ def test_make_only_family_is_not_a_family(self):
+ # hw/bsp/pic32mz has family.mk but no family.cmake
+ self.assertEqual(ci_select.port_families('microchip/pic32mz', REPO), set())
+
+ def test_prefix_port_does_not_inherit_sibling_families(self):
+ # bare-substring matching let 'microchip/pic' match '.../microchip/pic32mz/...'
+ self.assertEqual(ci_select.port_families('microchip/pic', REPO), set())
+
+ def test_make_only_port_contributes_nothing(self):
+ s = sel(['src/portable/microchip/pic32mz/dcd_pic32mz.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+ self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
+
+ def test_cmake_families_still_found(self):
+ self.assertEqual(ci_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
+ self.assertIn('stm32f4', ci_select.port_families('synopsys/dwc2', REPO))
+
+
+class TestPortFamiliesCoverage(unittest.TestCase):
+ """Systematic guard: every real dcd_*/hcd_* port directory should map to at
+ least one board family, so a future family.cmake/CMakeLists.txt layout that
+ port_families() doesn't scan fails loudly instead of silently dropping boards
+ (as espressif's dwc2 reference did - see TestRealRosterPortFamilies)."""
+ # Ports with no board family: not a bug, just not wired into any rig board.
+ # Add here (with a reason) only if port_families() legitimately can't find one.
+ # A port listed here contributes NOTHING on either axis (empty means empty), so
+ # this list is the tripwire: a port that stops resolving must show up as a test
+ # failure, not as a PR that quietly builds and tests nothing.
+ NO_FAMILY = {
+ 'template', # reference/example port, not built by any board
+ # hw/bsp/pic32mz has family.mk only (no family.cmake), and port_families()
+ # is CMake-only because HIL CI builds every board with CMake - so this port
+ # is compiled for no HIL board.
+ 'microchip/pic32mz',
+ 'microchip/pic', # same: only ever referenced from pic32mz's family.mk
+ }
+
+ @staticmethod
+ def _dcd_hcd_ports():
+ portable_root = os.path.join(REPO, 'src/portable')
+ ports = []
+ for entry in sorted(os.listdir(portable_root)):
+ d = os.path.join(portable_root, entry)
+ if not os.path.isdir(d):
+ continue
+ if glob.glob(os.path.join(d, 'dcd_*.c')) or glob.glob(os.path.join(d, 'hcd_*.c')):
+ ports.append(entry)
+ continue
+ for sub in sorted(os.listdir(d)):
+ sd = os.path.join(d, sub)
+ if os.path.isdir(sd) and (glob.glob(os.path.join(sd, 'dcd_*.c')) or
+ glob.glob(os.path.join(sd, 'hcd_*.c'))):
+ ports.append(f'{entry}/{sub}')
+ return ports
+
+ def test_every_port_maps_to_a_family(self):
+ ports = self._dcd_hcd_ports()
+ self.assertTrue(ports) # sanity: the scan itself found something
+ for port in ports:
+ if port in self.NO_FAMILY:
+ continue
+ fams = ci_select.port_families(port, REPO)
+ self.assertTrue(fams, f'{port}: no family references this port '
+ f'(port_families() scan gap, or add to NO_FAMILY)')
+
+
+class TestRealRosterOnlyListTests(unittest.TestCase):
+ """Regression for roster-only-list tests (e.g. espressif's hid_composite_freertos)
+ being invisible to the selector because it only knew the shared hil_util lists."""
+ def test_only_list_example_change_selects_it(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['examples/device/hid_composite_freertos/src/main.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertEqual(s['boards'][board], ['device/hid_composite_freertos'])
+
+ def test_class_change_includes_only_list_boards(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/class/hid/hid_device.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertIn(board, s['boards'])
+
+
+class TestPortAndCoreRoleUseExtras(unittest.TestCase):
+ """Regression: the port rule and core-role rule must thread the roster-only
+ test universe (extras) the same way the class rule already does, so a DCD
+ or device-stack change doesn't silently drop espressif's only-list tests
+ (e.g. hid_composite_freertos) that aren't in the shared device_tests list."""
+ def test_dcd_change_includes_only_list_test(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
+ 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)
+ self.assertIn('device/usbtest', tests)
+
+ def test_core_device_change_includes_only_list_test(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/device/usbd.c'], REPO, real_rosters())
+ 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)
+ self.assertIn('device/usbtest', tests)
+
+ def test_host_change_does_not_leak_device_only_list_test(self):
+ s = ci_select.classify(['src/host/usbh.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board, tests in s['boards'].items():
+ if tests == 'all':
+ continue
+ self.assertNotIn('device/hid_composite_freertos', tests, board)
+
+
+class TestFamilies(unittest.TestCase):
+ """`families` exists for consumers that build (not just test) the diff: most
+ families have no rig board, so `boards` alone would compile nothing for them."""
+ def test_off_rig_port_still_reports_family(self):
+ s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {}) # no same7x board on the rig
+ self.assertEqual(s['families'], ['same7x'])
+
+ def test_port_families_are_reported(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertIn('rp2040', s['families'])
+
+ def test_bsp_family_and_board_report_family(self):
+ self.assertEqual(sel(['hw/bsp/rp2040/family.cmake'])['families'], ['rp2040'])
+ self.assertEqual(sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])['families'],
+ ['rp2040'])
+
+ def test_docs_only_has_no_families(self):
+ self.assertEqual(sel(['docs/info/contributing.rst'])['families'], [])
+
+ def test_full_selection_still_reports_families(self):
+ """A full-matrix file must not hide the families of the other changed files:
+ consumers that build from `families` (e.g. /pre-pr) ignore `boards` when full."""
+ s = sel(['src/common/tusb_fifo.c', 'src/portable/microchip/samx7x/dcd_samx7x.c'])
+ self.assertTrue(s['full'])
+ self.assertIn('same7x', s['families'])
+ # full stays full: every roster board, and no args to narrow the run
+ self.assertEqual(set(s['boards']), {b['name'] for b in ROSTER})
+ self.assertTrue(all(v == 'all' for v in s['boards'].values()))
+ self.assertEqual(ci_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
+ self.assertEqual(ci_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
+
+ def test_family_order_does_not_matter(self):
+ # same as above with the full-matrix file last (was the only order that worked)
+ s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c', 'src/common/tusb_fifo.c'])
+ self.assertTrue(s['full'])
+ self.assertIn('same7x', s['families'])
+
+
+class TestGitDiffArgv(unittest.TestCase):
+ def test_diff_disables_rename_detection(self):
+ """Without --no-renames git reports only a rename's destination, so moving an
+ HIL-relevant file to a non-code path would be classified as non-code only."""
+ self.assertIn('--no-renames', ci_select.GIT_DIFF_ARGV)
+
+
+class TestPortWithoutFamilyContributesNothing(unittest.TestCase):
+ """A port dir no family file references contributes nothing on BOTH axes (the
+ maintainer's empty-means-empty ruling): nothing compiles the file, so there is
+ nothing to run. Forcing the full 30-board rig here bought no coverage - the build
+ walk answered the identical condition with zero families for the same path."""
+ def test_unreferenced_port_contributes_nothing(self):
+ orig = ci_select.port_families
+ ci_select.port_families = lambda port_dir, repo_root: set()
+ try:
+ s = sel(['src/portable/vendor/newip/dcd_newip.c'])
+ b = ci_select.classify_build(['src/portable/vendor/newip/dcd_newip.c'], REPO)
+ finally:
+ ci_select.port_families = orig
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+ self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
+ self.assertFalse(b['full'])
+ self.assertEqual(b['families'], [])
+
+
+class TestOpenocdVidPid(unittest.TestCase):
+ """The roster's optional flasher `vid_pid` field (openocd-verbatim, e.g.
+ "0x1a86 0x8010", more pairs appended) pins openocd's probe discovery so it
+ never opens foreign usbfs nodes. It must be emitted BEFORE the args: the
+ rescue cfgs run `init` internally (rp2350-rescue.cfg errors on any
+ config-stage command after its init; rp2040.cfg under RESCUE scans before a
+ trailing flag is even parsed), and no rig cfg sets a competing list
+ (the 2026-08-10 convoy mechanism)."""
+
+ def test_vid_pid_flag_precedes_args(self):
+ cmd = hil_flash._openocd_cmd_base(
+ {'uid': 'S1', 'args': '-f target/wch-riscv.cfg', 'vid_pid': '0x1a86 0x8010'})
+ self.assertIn('-c "adapter usb vid_pid 0x1a86 0x8010" -f target/wch-riscv.cfg', cmd)
+ self.assertTrue(cmd.endswith('-f target/wch-riscv.cfg'), cmd)
+
+ def test_rescue_cfg_command_keeps_vid_pid_before_init(self):
+ """rescue_openocd swaps the target cfg for one that runs `init` internally;
+ a vid_pid flag after the args would error there (rp2350) or be skipped
+ (rp2040) -- in exactly the wedged-rig scenario the pin exists for."""
+ flasher = {'name': 'openocd', 'uid': 'S1', 'vid_pid': '0x2e8a 0x000c',
+ 'args': '-c "set RESCUE 1" -f target/rp2040.cfg'}
+ cmd = hil_flash._openocd_cmd_base(flasher)
+ self.assertLess(cmd.index('adapter usb vid_pid'), cmd.index('-f target/'), cmd)
+
+ def test_vid_pid_multiple_pairs(self):
+ cmd = hil_flash._openocd_cmd_base(
+ {'uid': 'S1', 'args': '-f i.cfg', 'vid_pid': '0x2e8a 0x000c 0x2e8a 0x000d'})
+ self.assertIn('-c "adapter usb vid_pid 0x2e8a 0x000c 0x2e8a 0x000d"', cmd)
+
+ def test_no_field_no_flag_but_warns(self):
+ # the roster lint only covers the committed rosters; a dev PC's local.json entry
+ # without the field must at least say what it is giving up -- on STDERR, since
+ # hil_test captures stdout per test and would swallow it on a passing run
+ import io
+ from contextlib import redirect_stderr
+ hil_flash._VID_PID_WARNED.discard('S-warn')
+ cap = io.StringIO()
+ with redirect_stderr(cap):
+ cmd = hil_flash._openocd_cmd_base({'uid': 'S-warn', 'args': '-f i.cfg'})
+ self.assertNotIn('vid_pid', cmd)
+ self.assertIn('vid_pid', cap.getvalue())
+
+ def test_roster_openocd_entries_all_pin_vid_pid(self):
+ # every openocd probe on the rig has a known VID/PID; a new entry without the
+ # pin silently reintroduces open-everything discovery
+ for path, board in roster_flashers():
+ f = board['flasher']
+ # tinyusb.json only: hfp.json is the hifiphile rig owner's file, and a
+ # blocking repo-wide lint over someone else's roster would red every PR the
+ # moment they add an openocd board (hil_flash treats the field as optional)
+ if f['name'] == 'openocd' and path.endswith('tinyusb.json'):
+ self.assertIn('vid_pid', f,
+ f"{path}: {board['name']} openocd flasher lacks vid_pid")
+ self.assertNotIn('vid_pid', f.get('args', ''),
+ f"{path}: {board['name']} packs vid_pid into args; use the field")
+
+
+class TestRosterFlashersDispatch(unittest.TestCase):
+ """hil_test and hil_pool_check resolve a board's flasher with a bare
+ getattr(hil_flash, f'flash_{name}'), and hil_test does it inside a redirect_stdout —
+ so a renamed or typo'd roster name raises an AttributeError whose output is swallowed,
+ with nothing pointing at the roster as the thing to edit. Renaming a flash_*/reset_*
+ pair without updating every roster must fail here instead."""
+
+ def test_flash_and_reset_exist_for_every_roster_flasher(self):
+ for path, board in roster_flashers():
+ name = board['flasher']['name'].lower()
+ for fn in (f'flash_{name}', f'reset_{name}'):
+ self.assertTrue(callable(getattr(hil_flash, fn, None)),
+ f'{path}: {board["name"]} uses flasher "{name}" '
+ f'but hil_flash.{fn} does not exist')
+
+ def test_firmware_suffix_known_for_every_roster_flasher(self):
+ """find_firmware falls back to accepting .elf-or-.bin when a flasher is missing
+ from FLASHER_SUFFIX, silently restoring the mismatch that map exists to catch."""
+ for path, board in roster_flashers():
+ name = board['flasher']['name'].lower()
+ self.assertIn(name, hil_flash.FLASHER_SUFFIX,
+ f'{path}: {board["name"]} uses flasher "{name}" '
+ f'with no hil_flash.FLASHER_SUFFIX entry')
+
+
+class FlasherRecoverEntry(unittest.TestCase):
+ """Optional roster key: a SECOND flasher used only to deliver recovery while a usbfs
+ node is poisoned. Boards whose primary flasher cannot get past a convoy (jlink,
+ stlink, lm4flash) name an openocd entry here instead of changing how they are
+ normally flashed."""
+
+ def test_recover_flasher_prefers_the_optional_entry(self):
+ prim = {'name': 'jlink', 'uid': 'X', 'args': '-device MIMXRT1064xxx6A'}
+ rec = {'name': 'openocd', 'uid': 'X', 'args': '-f interface/jlink.cfg -f target/foo.cfg'}
+ self.assertEqual(hil_flash.recover_flasher({'flasher': prim, 'flasher_recover': rec}), rec)
+ self.assertEqual(hil_flash.recover_flasher({'flasher': prim}), prim)
+
+ def test_openocd_over_jlink_is_convoy_safe_without_a_pin(self):
+ """libjaylink discovery returns early unless idVendor == 0x1366 (SEGGER) and the PID
+ is in its table, and only THEN calls libusb_open (discovery_usb.c) -- it never opens
+ a foreign node. `adapter usb vid_pid` is a no-op for this driver: jlink.c reads
+ adapter_serial / usb address / usb location, never the vid/pid."""
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd', 'args': '-f interface/jlink.cfg -f target/stm32f4x.cfg'}))
+
+ def test_openocd_with_neither_a_pin_nor_jlink_is_not_safe(self):
+ self.assertFalse(hil_flash.convoy_safe(
+ {'name': 'openocd', 'args': '-f interface/stlink.cfg -f target/stm32h7x.cfg'}))
+
+ def test_the_existing_rules_are_unchanged(self):
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd', 'vid_pid': '0x2e8a 0x000c', 'args': '-f interface/cmsis-dap.cfg'}))
+ self.assertFalse(hil_flash.convoy_safe({'name': 'jlink', 'uid': 'X'}))
+ self.assertTrue(hil_flash.convoy_safe({'name': 'esptool'}))
+
+
+class TestModuleMove(unittest.TestCase):
+ def test_repo_root_guard(self):
+ # __file__-derived root: moving the module without re-deriving the parent
+ # count re-points every scan at the wrong tree (it happened once already)
+ self.assertTrue(os.path.isdir(os.path.join(ci_select._REPO_ROOT, 'src')))
+ self.assertTrue(os.path.isdir(os.path.join(ci_select._REPO_ROOT, 'hw', 'bsp')))
+ self.assertEqual(os.path.realpath(ci_select._REPO_ROOT), os.path.realpath(REPO))
+
+
+class TestPathFamilies(unittest.TestCase):
+ def test_port_wrapper_unchanged(self):
+ self.assertEqual(ci_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
+ self.assertIn('stm32f4', ci_select.port_families('synopsys/dwc2', REPO))
+
+ def test_boundary_without_trailing_slash(self):
+ # hw/bsp/nrf/family.cmake writes `${TOP}/hw/mcu/nordic/nrfx` — no trailing
+ # slash; the match must accept a directory-boundary end-of-token
+ self.assertEqual(ci_select.path_families('hw/mcu/nordic/nrfx', REPO), {'nrf'})
+
+ def test_boundary_rejects_prefix_sibling(self):
+ # 'microchip/pic' must not inherit pic32mz's references (and pic32mz itself
+ # is family.mk-only, which the CMake-only scan never reads)
+ self.assertEqual(ci_select.port_families('microchip/pic', REPO), set())
+ self.assertEqual(ci_select.port_families('microchip/pic32mz', REPO), set())
+
+ def test_mcu_families_prefix_walk(self):
+ self.assertEqual(ci_select.mcu_families('hw/mcu/nordic/nrf5x/nrf_clock.h', REPO), {'nrf'})
+ self.assertEqual(ci_select.mcu_families('hw/mcu/dialog/da1469x/x.h', REPO), {'da1469x'})
+ self.assertEqual(ci_select.mcu_families('hw/mcu/no_such_vendor/x.c', REPO), set())
+
+
+class TestMcuHilRule(unittest.TestCase):
+ def test_mcu_no_longer_forces_full(self):
+ s = ci_select.classify(['hw/mcu/nordic/nrf5x/nrf_clock.h'], REPO, ROSTERS)
+ self.assertFalse(s['full'])
+ self.assertIn('nrf', s['families']) # recorded even with no nrf rig board
+
+ def test_mcu_selects_family_boards(self):
+ got = on_roster(self, 'feather_nrf52840_express', 'pca10056', 'pca10095')
+ s = ci_select.classify(['hw/mcu/nordic/nrf5x/nrf_clock.h'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for b in got:
+ self.assertIn(b, s['boards'])
+
+ def test_unresolved_mcu_path_selects_nothing(self):
+ # empty means empty (maintainer ruling): if no family's build references the
+ # path, no build consumes the change - there is nothing to compile or run.
+ # test_tracked_mcu_vendors_resolve is the drift guard for a real vendor dir
+ s = ci_select.classify(['hw/mcu/no_such_vendor/x.c'], REPO, ROSTERS)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+ self.assertEqual(s['families'], [])
+
+
+class TestOrphanInvariant(unittest.TestCase):
+ ALLOW = {'microchip/pic', 'microchip/pic32mz'} # spec: known orphans, CMake builds neither
+
+ def test_every_port_resolves_to_a_family(self):
+ for d in sorted(glob.glob(os.path.join(REPO, 'src/portable/*/*'))):
+ if not os.path.isdir(d):
+ continue
+ port = os.path.relpath(d, os.path.join(REPO, 'src/portable')).replace(os.sep, '/')
+ fams = ci_select.port_families(port, REPO)
+ if port in self.ALLOW:
+ self.assertEqual(fams, set(), f'{port}: no longer an orphan - drop it from ALLOW')
+ else:
+ self.assertTrue(fams, f'{port}: no family.cmake references it - wire it up or allowlist it')
+
+ def test_tracked_mcu_vendors_resolve(self):
+ import subprocess as sp
+ r = sp.run(['git', 'ls-files', 'hw/mcu'], cwd=REPO, capture_output=True, text=True)
+ if r.returncode != 0:
+ self.skipTest('not a git checkout')
+ vendors = sorted({'/'.join(p.split('/')[:3]) for p in r.stdout.split()})
+ for v in vendors:
+ self.assertTrue(ci_select.mcu_families(v + '/x.c', REPO), f'{v}: resolves to no family')
+
+ # hw/bsp families ci_set_matrix's family_list does not map to any toolchain. Master
+ # gave a PR touching one of these no compile coverage either - none of the other 64
+ # families compiles same7x's board.h - so this is not new. What IS new is that the
+ # gap used to be masked by a full matrix and is now the whole answer, which is why
+ # ci_set_matrix treats a selection that intersects family_list to NOTHING as
+ # unusable (UNSCOPED -> full matrix) rather than emitting an all-empty one.
+ # espressif is here because hil-build-esp builds its boards by name rather than by
+ # family - though only on hathach/tinyusb: that job is gated on repository_owner,
+ # so on a fork an espressif-only PR builds nowhere.
+ UNBUILT_FAMILIES = {'cxd56', 'efm32', 'espressif', 'f1c100s', 'pic32mz', 'py32f0',
+ 'same7x'}
+
+ def test_every_bsp_family_is_in_the_ci_matrix(self):
+ sys.path.insert(0, os.path.join(REPO, '.github/scripts'))
+ import ci_set_matrix
+ fams = set(ci_select.all_bsp_families(REPO))
+ self.assertEqual(fams - set(ci_set_matrix.family_list), self.UNBUILT_FAMILIES,
+ 'a hw/bsp family that no toolchain in ci_set_matrix.family_list '
+ 'builds: a PR touching only it now selects zero build legs. Wire '
+ 'it into family_list, or add it here with a reason.')
+
+ def test_every_get_deps_family_token_resolves_or_is_a_known_alias(self):
+ """Same drift guard, dep side. A token naming no hw/bsp dir makes the entry
+ unreachable for its family in get_deps.py itself (`f in entry[2].split()`), and
+ makes a bump of it select nothing here. The four known ones are pinned; a fifth
+ appearing is a real bug in get_deps.py, not something to swallow."""
+ sys.path.insert(0, os.path.join(REPO, 'tools'))
+ import get_deps
+ fams = set(ci_select.all_bsp_families(REPO))
+ stale = {}
+ for name, d in (('deps_mandatory', get_deps.deps_mandatory),
+ ('deps_optional', get_deps.deps_optional)):
+ for path, entry in d.items():
+ for tok in str(entry[2]).split():
+ if tok != 'all' and tok not in fams:
+ stale.setdefault(tok, []).append(f'{name}[{path}]')
+ # subset, not equality: correcting a token in get_deps.py (fc100s -> f1c100s)
+ # should be a one-file change, while a NEW unmappable token - which force-fulls
+ # every get_deps edit that touches its entry - has to be a deliberate act
+ self.assertFalse(set(stale) - set(ci_select._DEPS_ALIAS_TOKENS),
+ f'get_deps family tokens naming no hw/bsp dir: '
+ f'{ {k: v for k, v in stale.items() if k not in ci_select._DEPS_ALIAS_TOKENS} }')
+
+
+class TestRostersDoNotOverlap(unittest.TestCase):
+ """sel['boards'] is one map across every roster, so a board listed in TWO rosters
+ with different test lists would get the union - and hil_test.py on the rig that
+ only runs half of them would be handed a -t it has no fixture for. No overlap
+ exists today; this is the tripwire for the day one is added."""
+
+ def test_no_board_name_is_in_two_rosters(self):
+ seen = {}
+ for name in ('tinyusb.json', 'hfp.json'):
+ cfg = json.load(open(os.path.join(REPO, 'test/hil', name)))
+ for b in cfg['boards']:
+ if b['name'] in seen:
+ self.assertEqual(
+ seen[b['name']], b.get('tests'),
+ f"{b['name']}: on two rosters with different test lists - "
+ f"selection_args must then filter per roster, not from the union")
+ seen[b['name']] = b.get('tests')
+
+
+class TestTypecRule(unittest.TestCase):
+ """Rule 12b. src/typec/usbc.c is listed unconditionally by src/CMakeLists.txt and
+ src/tinyusb.mk, but its whole body is `#if CFG_TUC_ENABLED`, which only
+ examples/typec/power_delivery sets - so it is parsed by every build and compiled by
+ one. Same shape as the class rule, same answer. Before this rule it matched nothing
+ and force-fulled 82 families and all 30 rig boards."""
+
+ def test_build_axis_selects_only_the_typec_examples(self):
+ s = ci_select.classify_build(['src/typec/usbc.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertTrue(s['families'], 'typec must be compiled somewhere')
+ self.assertTrue(s['family_examples'], 'and the examples must be named')
+ for fam, exs in s['family_examples'].items():
+ self.assertTrue(exs, fam)
+ for e in exs:
+ self.assertTrue(e.startswith('typec/'), f'{fam}: {e} is not a typec example')
+
+ def test_every_typec_file_answers_the_same(self):
+ for f in ('src/typec/usbc.c', 'src/typec/usbc.h', 'src/typec/tcd.h',
+ 'src/typec/pd_types.h'):
+ s = ci_select.classify_build([f], REPO)
+ self.assertFalse(s['full'], f)
+ self.assertTrue(s['families'], f)
+
+ def test_no_rig_board_runs_typec(self):
+ # typec is not a HIL role, so the rig cannot exercise it whatever it selects
+ s = sel(['src/typec/usbc.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_it_tracks_the_enabling_config_rather_than_a_hardcoded_list(self):
+ # the answer must come from CFG_TUC_ENABLED in the example configs, so it
+ # follows a new typec example (or an old one switched off) on its own
+ want = ci_select.examples_enabling(
+ ci_select.role_examples(REPO, ('typec',)), ('CFG_TUC_ENABLED',), REPO)
+ self.assertTrue(want, 'no example enables CFG_TUC_ENABLED - rule 12b is dead')
+ got = set()
+ for exs in ci_select.classify_build(['src/typec/usbc.c'], REPO)['family_examples'].values():
+ got |= set(exs)
+ self.assertEqual(got, want)
+
+
+class TestCachesAreKeyedOnTheTree(unittest.TestCase):
+ """build_utils caches on repo-RELATIVE paths while ci_select._in_repo() chdirs
+ between trees, so the cwd has to be part of every cache key. Without it a second
+ tree gets the first tree's skip.txt/only.txt and FAMILY_MCUS - which is exactly the
+ base-vs-branch comparison the code-size skill does in one process."""
+
+ def test_a_second_tree_is_not_answered_from_the_first(self):
+ import build_utils, tempfile
+ old = os.getcwd()
+ try:
+ os.chdir(REPO)
+ self.assertFalse(build_utils.skip_example('host/bare_api', 'metro_m0_express'))
+ with tempfile.TemporaryDirectory() as d:
+ os.makedirs(os.path.join(d, 'hw/bsp'), exist_ok=True)
+ os.chdir(d)
+ # the board does not exist in this tree at all -> unknown board -> skip
+ self.assertTrue(build_utils.skip_example('host/bare_api', 'metro_m0_express'),
+ 'the empty tree was answered from the repo tree cache')
+ os.chdir(REPO)
+ self.assertFalse(build_utils.skip_example('host/bare_api', 'metro_m0_express'),
+ 'and the repo answer must survive the excursion')
+ finally:
+ os.chdir(old)
+
+
+class TestClassesWithNoEnablingExample(unittest.TestCase):
+ """The class rule is the one rule with no drift guard: ports, hw/mcu, get_deps
+ tokens and bsp families all have one. A class dir that no example config enables
+ selects NOTHING on both axes (the maintainer's empty-means-empty ruling), which is
+ right - but it must be a listed state, not a surprise, or a class added before its
+ first example silently stops being built."""
+
+ # class dirs no example's tusb_config.h turns on, for either role. Must only shrink:
+ # a new entry means a class nothing compiles, so a break in it reaches master.
+ NO_EXAMPLE = {'bth'}
+
+ def test_only_the_known_classes_select_nothing(self):
+ import glob as _glob
+ dead = set()
+ for d in sorted(_glob.glob(os.path.join(REPO, 'src/class/*'))):
+ if not os.path.isdir(d):
+ continue
+ cls = os.path.basename(d)
+ hit = False
+ for base in sorted(os.path.basename(f) for f in _glob.glob(os.path.join(d, '*.[ch]'))):
+ roles = ci_select._class_roles(base)
+ if ci_select._build_class_examples(cls, base, roles, REPO):
+ hit = True
+ break
+ if not hit:
+ dead.add(cls)
+ self.assertEqual(dead, self.NO_EXAMPLE,
+ 'a class dir enabled by no example config: it selects nothing on '
+ 'both axes, so nothing compiles it until the next master push')
+
+
+class TestTheHarnessTestsAreNotTheHarness(unittest.TestCase):
+ """test/hil/test/ selects nothing; test/hil/ itself still selects everything.
+
+ Rule 2 is a bare `test/hil/` prefix, so the harness's own unit tests were booking
+ the full 27-board rig - ~11 minutes of exclusive hardware for a diff that cannot
+ reach it. Nothing on the rig runs them: pre-commit does, and build.yml runs
+ test_ci_select.py as the gate before trusting a selection at all.
+
+ The carve-out is only safe while that directory holds nothing rig-affecting, which
+ is what the second test pins."""
+
+ 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/stubs/hid.py'):
+ s = ci_select.classify([p], REPO, ROSTERS)
+ self.assertFalse(s['full'], p)
+ self.assertFalse(s['boards'], p)
+ b = ci_select.classify_build([p], REPO)
+ self.assertFalse(b['full'], p)
+ self.assertFalse(b['families'], p)
+
+ def test_the_harness_itself_still_takes_the_whole_rig(self):
+ # the thing rule 2 exists for: these decide what the rig does, so they cannot be
+ # trusted to narrow their own blast radius
+ for p in ('test/hil/hil_test.py', 'test/hil/tinyusb.json',
+ 'test/hil/helper/hil_ci_set_matrix.py'):
+ s = ci_select.classify([p], REPO, ROSTERS)
+ self.assertTrue(s['full'], f'{p} must still force the full rig')
+
+ def test_nothing_rig_affecting_has_moved_into_the_carve_out(self):
+ """The carve-out is a claim about that directory's contents; pin them.
+
+ A new file there that the rig DOES read would silently stop selecting the rig.
+ Listing them costs one line per file and makes that a failing test instead."""
+ 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')
+
+
+class TestExampleMapOmitsFullFamilies(unittest.TestCase):
+ """A family whose selection is ALREADY everything it can build carries no -e list.
+
+ Sixth of the same shape as the class below, found the same way: a perf rewrite of
+ _prune_buildable dropped the `set(kept) != set(buildable)` test and all 216 tests
+ stayed green. The build outcome is identical either way -- build.py applies the same
+ skip_example the pruner just did -- so nothing compiled differently and only the
+ payload grew (22 families x 33 examples on one dcd_dwc2.c diff). That is exactly the
+ kind of drift no build failure ever reports."""
+
+ def test_a_device_only_port_diff_still_omits_families_it_cannot_narrow(self):
+ # dcd_dwc2.c selects device+dual examples only, but a family whose host examples
+ # are all unbuildable anyway ends up wanting its entire buildable set
+ b = ci_select.classify_build(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO)
+ self.assertFalse(b['full'])
+ self.assertTrue(b['families'])
+ omitted = [f for f in b['families'] if f not in b['family_examples']]
+ self.assertTrue(omitted, 'no family omitted its -e list; the "already everything '
+ 'this family builds" case stopped being detected')
+ for fam in omitted:
+ self.assertNotIn(fam, b['family_examples'])
+
+ def test_a_family_that_can_build_more_than_the_diff_wants_keeps_its_list(self):
+ # the other direction: one example selects itself and nothing else, so every
+ # family it lands on must carry an explicit -e or CI builds all 46
+ b = ci_select.classify_build(['examples/device/cdc_msc/src/main.c'], REPO)
+ self.assertFalse(b['full'])
+ for fam in b['families']:
+ self.assertEqual(b['family_examples'].get(fam), ['device/cdc_msc'], fam)
+
+
+class TestSelectionBehavioursThatHadNoTest(unittest.TestCase):
+ """Five behaviours a reviewer's mutation pass proved were unpinned: break each one
+ and the whole suite stayed green. Each test here fails against its mutant.
+
+ They are grouped because they share a shape - every one is a small expression whose
+ removal silently NARROWS the selection, which is the failure direction that merges a
+ regression rather than wasting a runner."""
+
+ def test_build_defines_reach_the_prefilter(self):
+ # mutant: `defines = ()` in build.py's build_boards_list. metro_m4_express gets
+ # MAX3421_HOST=1 from its roster variant, never from its BSP, so without the
+ # defines the -e prefilter drops the rig's only MAX3421 firmware and hil-tinyusb
+ # has nothing to flash.
+ import build as build_py, build_utils, inspect
+ src = inspect.getsource(build_py.build_boards_list)
+ self.assertIn('defines = tuple(sorted(build_defines))', src,
+ 'the -D tokens must reach cmake_board/skip_example')
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ ex, board = 'dual/host_info_to_device_cdc', 'metro_m4_express'
+ self.assertTrue(build_utils.skip_example(ex, board),
+ 'without the define this example is correctly skipped')
+ self.assertFalse(build_utils.skip_example(ex, board, ('MAX3421_HOST=1',)),
+ 'with it, it must build - that is what the roster passes')
+ finally:
+ os.chdir(old)
+
+ def test_one_first_prefers_a_board_that_can_build_the_filter(self):
+ # mutant: buildable() -> True, i.e. back to all_boards[0]. lpc54's first board
+ # skips every msc_file_explorer example, so the leg would compile nothing.
+ import build as build_py
+ old_env, old = os.environ.get('GITHUB_ACTIONS'), os.getcwd()
+ os.environ['GITHUB_ACTIONS'] = 'true'
+ os.chdir(REPO)
+ try:
+ unfiltered = build_py.get_family_boards('lpc54', False, True)
+ filtered = build_py.get_family_boards('lpc54', False, True,
+ ['host/msc_file_explorer'])
+ self.assertEqual(unfiltered, ['lpcxpresso54114'], 'unfiltered pick must not move')
+ self.assertNotEqual(filtered, unfiltered,
+ 'the -e pick must avoid a board that skips the whole filter')
+ import build_utils
+ self.assertFalse(build_utils.skip_example('host/msc_file_explorer', filtered[0]),
+ f'{filtered[0]} must actually build the filtered example')
+ finally:
+ os.chdir(old)
+ if old_env is None:
+ os.environ.pop('GITHUB_ACTIONS', None)
+ else:
+ os.environ['GITHUB_ACTIONS'] = old_env
+
+ def test_a_class_file_selects_its_own_macro_not_just_the_directory(self):
+ # mutant: delete the _CLS_STEM_RE block. src/class/midi holds MIDI 1.0 AND 2.0;
+ # examples/device/midi2_device is the only example enabling CFG_TUD_MIDI2 and the
+ # only one that compiles midi2_device.c, but the directory macro alone misses it.
+ got = ci_select._build_class_examples('midi', 'midi2_device.c', {'device'}, REPO)
+ self.assertIn('device/midi2_device', got,
+ 'a midi2 change must select the example that compiles it')
+ host = ci_select._build_class_examples('midi', 'midi2_host.c', {'host'}, REPO)
+ self.assertIn('host/midi2_host', host)
+ # and the plain midi files must NOT drag midi2 in
+ plain = ci_select._build_class_examples('midi', 'midi_device.c', {'device'}, REPO)
+ self.assertNotIn('device/midi2_device', plain)
+
+ def test_a_port_change_selects_the_dual_examples(self):
+ # mutant: drop `+ ('dual',)`. A dcd/hcd change must build the dual examples -
+ # they exercise both stacks on one board, so a dwc2 break lands there first.
+ s = ci_select.classify_build(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO)
+ duals = {e for exs in s['family_examples'].values() for e in exs
+ if e.startswith('dual/')}
+ self.assertTrue(duals, 'a dcd change selected no dual example')
+
+ def test_the_selector_answers_the_same_with_and_without_ci_env(self):
+ # mutant: drop ci=True from _prune_buildable. ci_skip_boards/ci_preferred_boards
+ # only apply when GITHUB_ACTIONS/CIRCLECI is set, so without the pin a laptop and
+ # a runner disagree - and /pre-pr would report a family list CI will not build.
+ files = ['examples/host/cdc_msc_hid_freertos/src/main.c']
+ old = os.environ.get('GITHUB_ACTIONS')
+ os.environ.pop('GITHUB_ACTIONS', None)
+ try:
+ local = ci_select.classify_build(files, REPO)['families']
+ os.environ['GITHUB_ACTIONS'] = 'true'
+ import importlib
+ importlib.reload(ci_select)
+ runner = ci_select.classify_build(files, REPO)['families']
+ finally:
+ if old is None:
+ os.environ.pop('GITHUB_ACTIONS', None)
+ else:
+ os.environ['GITHUB_ACTIONS'] = old
+ import importlib
+ importlib.reload(ci_select)
+ self.assertEqual(local, runner, 'the selector must not depend on the CI env vars')
+
+
+class TestRuleTableIsCarbonOfTheSpec(unittest.TestCase):
+ """ci_select's module docstring carries the rule table so a reader landing in the
+ code does not have to open the spec to learn what rule 6 is. Both are maintained by
+ hand, so this pins them cell-for-cell: edit one without the other and this fails.
+
+ It also pins the table against the CODE - every rule id the docstring claims must
+ appear as a `# rule N` marker on a branch of _classify_build_one, so a row cannot be
+ documented without a branch, or a branch renumbered without the table."""
+
+ @staticmethod
+ def _rows(text):
+ import re as _re
+ out = []
+ for l in text.splitlines():
+ if not l.startswith('| '):
+ continue
+ c = [x.strip() for x in l.strip().strip('|').split('|')]
+ if len(c) == 5 and _re.fullmatch(r'\d+[a-z]?', c[0]):
+ out.append(c)
+ return out
+
+ def test_docstring_table_matches_the_spec(self):
+ spec = open(os.path.join(
+ REPO, 'docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md')).read()
+ doc, spec_rows = self._rows(ci_select.__doc__), self._rows(spec)
+ self.assertTrue(spec_rows, 'no rule table found in the spec')
+ self.assertEqual([r[0] for r in doc], [r[0] for r in spec_rows],
+ 'rule ids differ between ci_select.__doc__ and the spec')
+ for d, s in zip(doc, spec_rows):
+ self.assertEqual(d, s, f'rule {d[0]} differs between the docstring and the spec')
+
+ def test_every_documented_rule_has_a_branch(self):
+ import re as _re
+ src = open(os.path.join(REPO, 'tools/ci_select.py')).read()
+ marked = set()
+ # handles `# rule 6`, `# rules 1, 1b` and `# rules 8-10`
+ for m in _re.finditer(r'#\s*rules?\s+([0-9a-z, -]+)', src):
+ for tok in _re.split(r',\s*', m.group(1).strip()):
+ rng = _re.fullmatch(r'(\d+)\s*-\s*(\d+)', tok.strip())
+ if rng:
+ marked.update(str(n) for n in range(int(rng.group(1)), int(rng.group(2)) + 1))
+ elif _re.fullmatch(r'\d+[a-z]?', tok.strip()):
+ marked.add(tok.strip())
+ documented = {r[0] for r in self._rows(ci_select.__doc__)}
+ missing = sorted(documented - marked, key=lambda s: (int(_re.match(r'\d+', s).group()), s))
+ self.assertEqual(missing, [], f'documented rules with no `# rule N` branch marker: {missing}')
+
+
+class TestNoTrackedFileIsUnclassified(unittest.TestCase):
+ """Rule 17 (unclassified -> full on both axes) is the fail-open net for paths nobody
+ anticipated. It must stay that way - a wrong `full` costs runner minutes and is
+ visible in the run, a wrong `empty` costs a merged regression and is invisible - but
+ nothing in the tree should REACH it. Every tracked file is classified by a rule, so
+ 17 fires only for genuinely new shapes, and this test is what tells the author to
+ write the row instead of letting the fall-through pick an answer for them.
+
+ Before this guard, 254 tracked files reached 17: .gitignore took a docs-only PR to
+ 74 cmake legs and the whole rig, while examples/<role>/CMakeLists.txt got the RIGHT
+ answer from the wrong rule - row 15 names it, the regex never matched it."""
+
+ def _unclassified(self, axis):
+ import subprocess as sp
+ r = sp.run(['git', 'ls-files'], cwd=REPO, capture_output=True, text=True)
+ if r.returncode != 0:
+ self.skipTest('not a git checkout')
+ files = r.stdout.split()
+ self.assertGreater(len(files), 1000, 'suspiciously few tracked files')
+ out = []
+ for f in files:
+ s = (ci_select.classify_build([f], REPO) if axis == 'build'
+ else ci_select.classify([f], REPO, real_rosters()))
+ if any('unclassified' in why for why in s['reasons']):
+ out.append(f)
+ return out
+
+ def test_build_axis(self):
+ left = self._unclassified('build')
+ self.assertEqual(left, [], f'{len(left)} tracked files fall through to rule 17 on '
+ f'the build axis, e.g. {left[:5]} - classify them, or '
+ f'add the pattern to _META_RE if no build reads them')
+
+ def test_hil_axis(self):
+ left = self._unclassified('hil')
+ self.assertEqual(left, [], f'{len(left)} tracked files fall through to rule 17 on '
+ f'the HIL axis, e.g. {left[:5]}')
+
+
+class TestLibRule(unittest.TestCase):
+ """lib/** is not a full-matrix path: only the examples that build the lib need it."""
+
+ def b(self, files):
+ return ci_select.classify_build(files, REPO)
+
+ def test_lib_examples_ground_truth(self):
+ self.assertEqual(ci_select.lib_examples('embedded-cli', REPO),
+ {'host/msc_file_explorer', 'host/msc_file_explorer_freertos'})
+ self.assertEqual(ci_select.lib_examples('networking', REPO),
+ {'device/net_lwip_webserver'})
+ # only family_support.cmake's LOGGER=rtt plumbing names it, and no CI example
+ # build turns that on - the scan is per-example on purpose
+ self.assertEqual(ci_select.lib_examples('SEGGER_RTT', REPO), set())
+ self.assertEqual(ci_select.lib_examples('rt-thread', REPO), set())
+
+ def test_lib_examples_matches_at_a_directory_boundary(self):
+ # 'lib/net' must not inherit lib/networking's example
+ self.assertEqual(ci_select.lib_examples('net', REPO), set())
+
+ def test_build_lib_selects_only_the_using_examples(self):
+ s = self.b(['lib/embedded-cli/embedded_cli.h'])
+ self.assertFalse(s['full'])
+ self.assertTrue(s['families'])
+ want = {'host/msc_file_explorer', 'host/msc_file_explorer_freertos'}
+ mapped = set()
+ for fam, exs in s['family_examples'].items():
+ self.assertTrue(set(exs) <= want, f'{fam}: {exs}')
+ mapped |= set(exs)
+ self.assertEqual(mapped, want)
+
+ def test_build_lib_nobody_builds_selects_nothing(self):
+ s = self.b(['lib/SEGGER_RTT/RTT/SEGGER_RTT.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_hil_lib_selects_the_using_tests(self):
+ s = sel(['lib/embedded-cli/embedded_cli.h'])
+ self.assertFalse(s['full'])
+ want = {'host/msc_file_explorer', 'host/msc_file_explorer_freertos'}
+ self.assertEqual(set(s['boards']['raspberry_pi_pico']), want)
+ self.assertEqual(set(s['boards']['raspberry_pi_pico2']), want)
+ # device-only board and the only-list board run neither test
+ self.assertNotIn('stm32f407disco', s['boards'])
+ self.assertNotIn('espressif_s3_devkitm', s['boards'])
+
+ def test_hil_lib_used_only_by_a_disabled_test_selects_nothing(self):
+ # device/net_lwip_webserver is commented out of hil_util.device_tests, so the
+ # intersection with the HIL universe is empty
+ s = sel(['lib/networking/dhserver.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_hil_lib_nobody_builds_selects_nothing(self):
+ s = sel(['lib/SEGGER_RTT/RTT/SEGGER_RTT.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+
+# A miniature get_deps.py: the module shape the parser must cope with (imports,
+# both dep dicts, the derived deps_all, a function) without the real 300-entry file.
+_GD_BASE = """#!/usr/bin/env python3
+import argparse
+
+deps_mandatory = {
+ 'lib/fatfs': ['https://github.com/abbrev/fatfs.git', 'aaa', 'all'],
+}
+
+deps_optional = {
+ 'hw/mcu/st/cmsis_device_f4': ['https://github.com/x/f4.git', 'bbb', 'stm32f4 stm32f7'],
+ 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],
+}
+
+deps_all = {**deps_mandatory, **deps_optional}
+
+
+def main():
+ return 1
+"""
+
+
+class TestGetDepsChangedFamilies(unittest.TestCase):
+ """Pure text-in, families-out: no git, no exec of the parsed module."""
+
+ def f(self, head, base=_GD_BASE):
+ return ci_select.get_deps_changed_families(base, head, REPO)
+
+ def test_no_change_selects_nothing(self):
+ self.assertEqual(self.f(_GD_BASE), set())
+
+ def test_comment_only_change_selects_nothing(self):
+ self.assertEqual(self.f(_GD_BASE.replace('import argparse',
+ 'import argparse # noqa')), set())
+
+ def test_optional_commit_bump_selects_its_families(self):
+ self.assertEqual(self.f(_GD_BASE.replace("'bbb'", "'bbb2'")),
+ {'stm32f4', 'stm32f7'})
+
+ def test_mandatory_all_entry_is_full(self):
+ self.assertIsNone(self.f(_GD_BASE.replace("'aaa'", "'aaa2'")))
+
+ def test_logic_change_is_full(self):
+ self.assertIsNone(self.f(_GD_BASE.replace('return 1', 'return 2')))
+
+ def test_unparseable_text_is_full(self):
+ self.assertIsNone(self.f('def broken(:\n'))
+
+ def test_unresolvable_token_is_full(self):
+ # a changed entry we cannot map to a family is NOT "nothing changed": reading it
+ # that way empties the whole build matrix for a dep bump. Fall open instead -
+ # even when a sibling token does resolve, because the unmapped one may be the
+ # family that actually needed the new revision
+ base = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'zz_gone samd5x_e5x'")
+ self.assertIsNone(self.f(base.replace("'ccc'", "'ccc2'"), base))
+ base = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'zz_gone'")
+ self.assertIsNone(self.f(base.replace("'ccc'", "'ccc2'"), base))
+
+ def test_family_token_change_unions_both_sides(self):
+ # the family list itself edited: both sides contribute
+ head = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'rp2040 samd5x_e5x'")
+ self.assertEqual(self.f(head), {'nrf', 'rp2040', 'samd5x_e5x'})
+
+ def test_known_alias_tokens_select_nothing(self):
+ # the tokens in _DEPS_ALIAS_TOKENS name no hw/bsp dir: either a pre-rename
+ # spelling sitting beside the current name in the same entry, or a family with
+ # no boards in the tree. Changing one selects nothing rather than force-fulling
+ # every get_deps edit that touches its entry.
+ base = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'stm32l5'")
+ self.assertEqual(self.f(base.replace("'ccc'", "'ccc2'"), base), set())
+
+ def test_moving_an_entry_between_the_two_dicts_is_seen(self):
+ # value untouched, dict changed: mandatory deps are fetched for every family, so
+ # demoting one stops families fetching it. Merging the dicts before diffing (or
+ # comparing the ast dump of deps_all) hides this completely.
+ head = _GD_BASE.replace(
+ " 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],\n", '')
+ head = head.replace(
+ "deps_mandatory = {\n",
+ "deps_mandatory = {\n 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],\n")
+ self.assertEqual(self.f(head), {'nrf'})
+
+ def test_added_entry_selects_its_families(self):
+ head = _GD_BASE.replace(
+ "deps_optional = {\n",
+ "deps_optional = {\n 'hw/mcu/x': ['https://github.com/x/x.git', 'ddd', 'rp2040'],\n")
+ self.assertEqual(self.f(head), {'rp2040'})
+
+ def test_removed_entry_selects_its_base_side_families(self):
+ head = _GD_BASE.replace(
+ " 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],\n", '')
+ self.assertEqual(self.f(head), {'nrf'})
+
+ def test_family_list_change_unions_both_sides(self):
+ head = _GD_BASE.replace("'stm32f4 stm32f7'", "'stm32f4 stm32h7'")
+ self.assertEqual(self.f(head), {'stm32f4', 'stm32f7', 'stm32h7'})
+
+ def test_real_get_deps_parses(self):
+ with open(os.path.join(REPO, 'tools/get_deps.py')) as f:
+ real = f.read()
+ self.assertEqual(ci_select.get_deps_changed_families(real, real, REPO), set())
+ # a real optional entry bumped resolves to that entry's real family. The commit
+ # is read out of get_deps.py rather than pinned here - a routine dep bump must
+ # not fail this suite, and pinning a hash tests the tree, not the code
+ sys.path.insert(0, os.path.join(REPO, 'tools'))
+ import get_deps
+ commit, tokens = get_deps.deps_optional['hw/mcu/nordic/nrfx'][1:3]
+ bumped = real.replace(commit, '0' * len(commit))
+ self.assertNotEqual(bumped, real)
+ self.assertEqual(ci_select.get_deps_changed_families(real, bumped, REPO),
+ set(tokens.split()))
+
+
+class TestGetDepsRule(unittest.TestCase):
+ """tools/get_deps.py: the changed dep entries' families, or full when unknowable."""
+
+ def test_build_selects_the_changed_families(self):
+ s = ci_select.classify_build(['tools/get_deps.py'], REPO,
+ get_deps_families={'stm32f4'})
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], ['stm32f4'])
+ self.assertNotIn('stm32f4', s['family_examples']) # every example it builds
+
+ def test_build_without_a_base_is_full(self):
+ # --diff-file mode has no git and so no base content: fail open
+ self.assertTrue(ci_select.classify_build(['tools/get_deps.py'], REPO)['full'])
+
+ def test_build_no_dep_entry_changed_selects_nothing(self):
+ s = ci_select.classify_build(['tools/get_deps.py'], REPO, get_deps_families=set())
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+
+ def test_hil_selects_the_changed_families_boards(self):
+ s = ci_select.classify(['tools/get_deps.py'], REPO, ROSTERS,
+ get_deps_families={'stm32f4'})
+ self.assertFalse(s['full'])
+ self.assertEqual(list(s['boards']), ['stm32f407disco'])
+ self.assertEqual(s['families'], ['stm32f4'])
+
+ def test_hil_without_a_base_is_full(self):
+ self.assertTrue(ci_select.classify(['tools/get_deps.py'], REPO, ROSTERS)['full'])
+
+ def test_hil_no_dep_entry_changed_selects_nothing(self):
+ s = ci_select.classify(['tools/get_deps.py'], REPO, ROSTERS, get_deps_families=set())
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_cli_diff_file_mode_is_full(self):
+ import tempfile, json as j
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('tools/get_deps.py\n')
+ path = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ os.unlink(path)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ out = j.loads(r.stdout)
+ self.assertTrue(out['full'])
+ self.assertTrue(out['build']['full'])
+
+
+class TestGetDepsGitPlumbing(unittest.TestCase):
+ """--base mode: merge-base, the diff, and both blobs come from git, and only
+ tools/get_deps.py in the diff triggers the blob reads."""
+
+ HEAD = _GD_BASE.replace("'bbb'", "'bbb2'")
+
+ def run_main(self, diff):
+ from unittest import mock
+ calls = []
+
+ def fake_run(argv, **kw):
+ calls.append(argv)
+ if argv[:2] == ['git', 'merge-base']:
+ out = 'MB123\n'
+ elif argv[:3] == ci_select.GIT_DIFF_ARGV[:3]:
+ out = diff
+ elif argv[:2] == ['git', 'show']:
+ out = _GD_BASE if argv[2].startswith('MB123:') else self.HEAD
+ else:
+ raise AssertionError(f'unexpected git call: {argv}')
+ return subprocess.CompletedProcess(argv, 0, stdout=out, stderr='')
+
+ buf = io.StringIO()
+ argv = [sys.executable, '--base', 'origin/master']
+ with mock.patch.object(ci_select.subprocess, 'run', fake_run), \
+ mock.patch.object(sys, 'argv', argv), \
+ contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
+ ci_select.main()
+ return json.loads(buf.getvalue()), calls
+
+ def test_base_mode_reads_the_merge_base_blob(self):
+ out, calls = self.run_main('tools/get_deps.py\n')
+ self.assertIn(['git', 'show', 'MB123:tools/get_deps.py'], calls)
+ self.assertIn(['git', 'show', 'HEAD:tools/get_deps.py'], calls)
+ self.assertFalse(out['build']['full'])
+ self.assertEqual(out['build']['families'], ['stm32f4', 'stm32f7'])
+
+ def test_no_get_deps_in_the_diff_reads_no_blob(self):
+ out, calls = self.run_main('src/class/cdc/cdc_device.c\n')
+ self.assertFalse(any(c[:2] == ['git', 'show'] for c in calls))
+ self.assertFalse(out['build']['full'])
+
+ def test_git_failure_falls_open(self):
+ from unittest import mock
+
+ def fake_run(argv, **kw):
+ if argv[:2] == ['git', 'show']:
+ raise subprocess.CalledProcessError(128, argv)
+ out = 'MB123\n' if argv[:2] == ['git', 'merge-base'] else 'tools/get_deps.py\n'
+ return subprocess.CompletedProcess(argv, 0, stdout=out, stderr='')
+
+ buf = io.StringIO()
+ with mock.patch.object(ci_select.subprocess, 'run', fake_run), \
+ mock.patch.object(sys, 'argv', [sys.executable, '--base', 'origin/master']), \
+ contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
+ ci_select.main()
+ self.assertTrue(json.loads(buf.getvalue())['build']['full'])
+
+
+class TestBuildClassifier(unittest.TestCase):
+ def b(self, files):
+ return ci_select.classify_build(files, REPO)
+
+ def test_noncode_and_test_hil_contribute_nothing(self): # rules 1, 2
+ s = self.b(['docs/info/index.rst', 'README.rst', 'test/hil/hil_test.py', '.claude/skills/hil/SKILL.md'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_port_device_rule(self): # rule 3
+ s = self.b(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], ['rp2040'])
+ exs = s['family_examples']['rp2040']
+ self.assertIn('device/cdc_msc', exs)
+ self.assertFalse(any(e.startswith(('host/', 'typec/')) for e in exs))
+ # dual inclusion asserted on the pure role helper: whether a dual example
+ # survives Task 4's buildability pruning depends on the environment-gated
+ # CI board pick, so the classifier-output assertion must not rely on it
+ self.assertIn('dual/host_info_to_device_cdc',
+ ci_select.role_examples(REPO, ('device', 'dual')))
+ self.assertNotIn('host/bare_api', ci_select.role_examples(REPO, ('device', 'dual')))
+
+ def test_port_host_rule(self): # rule 4
+ s = self.b(['src/portable/analog/max3421/hcd_max3421.c'])
+ self.assertFalse(s['full'])
+ # rp2040's family.cmake unconditionally lists hcd_max3421.c as a source of its
+ # tinyusb_host_max3421 INTERFACE lib (linked only when MAX3421_HOST=1, e.g. the
+ # real feather_rp2040_max3421 board) and espressif's component CMakeLists also
+ # references it — so the raw (unpruned) scan legitimately finds both; Task 4's
+ # buildability post-filter is what may later prune either away
+ # non-empty FIRST: a subset assertion is satisfied by set(), and since ports are
+ # now empty-means-empty (fail-closed) an unnoticed regression to zero families
+ # would select no build leg at all and merge an uncompiled HCD
+ self.assertTrue(s['families'], 'a host-port change must select some family')
+ self.assertLessEqual(set(s['families']), {'espressif', 'rp2040'})
+ self.assertTrue(s['family_examples'], 'and must name the examples for them')
+ for exs in s['family_examples'].values():
+ self.assertTrue(exs)
+ self.assertFalse(any(e.startswith(('device/', 'typec/')) for e in exs))
+
+ def test_port_shared_file_selects_all_examples(self): # rule 5
+ s = self.b(['src/portable/synopsys/dwc2/dwc2_common.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('stm32f4', s['families'])
+ self.assertNotIn('rp2040', s['families'])
+ self.assertNotIn('stm32f4', s['family_examples']) # 'all' => no map key
+
+ def test_bsp_family_rule(self): # rule 6
+ s = self.b(['hw/bsp/stm32f4/boards/stm32f407disco/board.h'])
+ self.assertEqual(s['families'], ['stm32f4'])
+ self.assertNotIn('stm32f4', s['family_examples'])
+
+ def test_bsp_top_level_file_is_full(self): # rule 16
+ self.assertTrue(self.b(['hw/bsp/board.c'])['full'])
+ self.assertTrue(self.b(['hw/bsp/family_support.cmake'])['full'])
+
+ def test_mcu_rule(self): # rule 7
+ s = self.b(['hw/mcu/nordic/nrf5x/nrf_clock.h'])
+ self.assertEqual(s['families'], ['nrf'])
+ # empty means empty: no family's build references the path, so no build
+ # compiles it - nothing to select
+ s = self.b(['hw/mcu/no_such_vendor/x.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_class_device_rule(self): # rule 8
+ s = self.b(['src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ # near-all families (Task 4's pruning may drop a few); never equality
+ # against all_bsp_families — that's a tuple, and pruning shrinks the list
+ self.assertIn('stm32f4', s['families'])
+ self.assertGreater(len(s['families']), 50)
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('device/cdc_msc', exs)
+ self.assertNotIn('device/hid_composite', exs)
+ self.assertNotIn('host/cdc_msc_hid', exs) # TUH_CDC examples are rule 9's
+
+ def test_class_host_rule(self): # rule 9
+ s = self.b(['src/class/msc/msc_host.c'])
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('host/msc_file_explorer', exs)
+ self.assertNotIn('device/cdc_msc', exs)
+
+ def test_class_shared_header_and_include_edge(self): # rule 10
+ s = self.b(['src/class/audio/audio.h'])
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('device/audio_test', exs)
+ self.assertIn('device/midi_test', exs) # midi headers include audio.h
+
+ def test_core_device_rule(self): # rule 11
+ s = self.b(['src/device/usbd.c'])
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('device/cdc_msc', exs)
+ # no dual In-assertion: dual examples are only.txt-gated to max3421/pio-usb
+ # boards, so pruning legitimately drops them on a plain stm32f4 board
+ self.assertFalse(any(e.startswith(('host/', 'typec/')) for e in exs))
+
+ def test_core_host_rule(self): # rule 12
+ s = self.b(['src/host/usbh.c'])
+ exs = s['family_examples']['stm32f4']
+ self.assertFalse(any(e.startswith(('device/', 'typec/')) for e in exs))
+
+ def test_example_rule(self): # rules 13, 14
+ s = self.b(['examples/device/cdc_msc/src/main.c'])
+ self.assertEqual(s['family_examples']['stm32f4'], ['device/cdc_msc'])
+ s = self.b(['examples/device/board_test/src/main.c'])
+ self.assertEqual(s['family_examples']['stm32f4'], ['device/board_test'])
+ s = self.b(['examples/device/no_such_example/src/main.c']) # deleted example: nothing
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+
+ def test_full_paths(self): # rules 15-17
+ for p in ('src/common/tusb_fifo.c', 'src/osal/osal.h', 'src/tusb.c',
+ 'src/tusb_option.h',
+ 'tools/build.py', 'tools/cmake/cpu/cortex-m4.cmake',
+ 'examples/CMakeLists.txt', 'examples/device/CMakeLists.txt',
+ 'examples/build_system/cmake/cpu.cmake', '.github/workflows/build.yml',
+ '.circleci/config.yml', 'src/CMakeLists.txt', 'src/tinyusb.mk',
+ 'hw/bsp/family_support.mk', 'tools/build_utils.py',
+ 'some/unknown/path.c'):
+ self.assertTrue(self.b([p])['full'], p)
+
+ def test_repo_metadata_is_not_a_build_input(self):
+ # these used to reach `full` through rule 17: a PR touching only .gitignore and a
+ # README created 74 cmake legs and booked the whole rig. No Build step reads them.
+ for p in ('sonar-project.properties', '.gitignore', '.gitattributes',
+ '.clang-format', '.idea/misc.xml', 'version.yml', 'library.json',
+ 'examples/CMakePresets.json', 'test/fuzz/fuzz.cc',
+ 'test/unit-test/project.yml', '.github/workflows/pr_comment.yml',
+ 'tools/gen_doc.py'):
+ s = self.b([p])
+ self.assertFalse(s['full'], p)
+ self.assertEqual(s['families'], [], p)
+
+ def test_the_build_machinery_is_still_full(self):
+ # the other side of the same line: these DECIDE what gets built
+ for p in ('.circleci/config.yml', '.github/workflows/build.yml',
+ '.github/scripts/ci_set_matrix.py', 'tools/ci_select.py',
+ 'tools/build_utils.py', 'tools/metrics.py'):
+ self.assertTrue(self.b([p])['full'], p)
+
+ def test_mixed_diff_unions_per_family(self):
+ s = self.b(['src/portable/raspberrypi/rp2040/dcd_rp2040.c', 'src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('stm32f4', s['families'])
+ self.assertGreater(len(s['families']), 50)
+ self.assertIn('device/hid_composite', s['family_examples']['rp2040']) # from the dcd rule
+ self.assertNotIn('device/hid_composite', s['family_examples']['stm32f4']) # cdc-only there
+
+ def test_example_names_are_real_dirs(self):
+ for ex in ci_select.all_examples(REPO):
+ role, name = ex.split('/')
+ self.assertTrue(os.path.isdir(os.path.join(REPO, 'examples', role, name)), ex)
+ self.assertRegex(ex, r'^(device|dual|host|typec)/[A-Za-z0-9_]+$')
+
+
+class TestBuildPostFilter(unittest.TestCase):
+ def test_kept_examples_are_buildable(self):
+ import build_utils, build as build_py
+ s = ci_select.classify_build(['src/class/msc/msc_host.c'], REPO)
+ self.assertFalse(s['full'])
+ # families that cannot build a single TUH_MSC example drop out entirely
+ self.assertNotIn('msp430', s['families'])
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ # buildable on SOME board of the family - CircleCI builds them all
+ for fam, exs in s['family_examples'].items():
+ boards = build_py.get_family_boards(fam, False, False)
+ for e in exs:
+ self.assertTrue(any(not build_utils.skip_example(e, b) for b in boards),
+ f'{fam}: {e}')
+ finally:
+ os.chdir(old)
+
+ def test_unfiltered_family_has_no_map_key(self):
+ s = ci_select.classify_build(['hw/bsp/stm32f4/family.c'], REPO)
+ self.assertEqual(s['families'], ['stm32f4'])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_espressif_prunes_to_what_its_build_path_can_build(self):
+ # build.py's espressif branch builds get_examples('espressif') only (the
+ # *_freertos examples plus a short extra list), so keeping espressif for a
+ # device/mtp diff spins CircleCI's most expensive leg up to skip everything
+ s = ci_select.classify_build(['examples/device/mtp/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertNotIn('espressif', s['families'])
+
+ def test_espressif_survives_an_example_it_does_build(self):
+ s = ci_select.classify_build(['examples/device/cdc_msc_freertos/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertIn('espressif', s['families'])
+
+ def test_ra_survives_the_dual_example_prune(self):
+ # ra's only buildable dual example is gated on only.txt's mcu:ra6m5, which
+ # exists only if the ${MCU_VARIANT} token in FAMILY_MCUS resolves
+ s = ci_select.classify_build(
+ ['examples/dual/host_info_to_device_cdc/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertIn('ra', s['families'], s['families'])
+
+ def test_deleted_family_dir_does_not_crash(self):
+ # rule 6 extracts a family from the path; a PR that deletes or renames
+ # hw/bsp/<fam> used to traceback in get_family_boards' scandir
+ s = ci_select.classify_build(['hw/bsp/no_such_family_xyz/family.cmake'], REPO)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertTrue(any('gone from tree' in r for r in s['reasons']), s['reasons'])
+
+ def test_class_source_selecting_nothing_selects_nothing(self):
+ # a class-with-no-enabling-config case: no config enables CFG_TUH_VENDOR, so
+ # nothing exercises it and nothing builds - empty means empty (maintainer
+ # decision; the file is still parsed by every full master-push build, which is
+ # the accepted net for a break outside its #if guard). src/class/bth is the
+ # live instance of this state today; TestClassesWithNoEnablingExample pins the
+ # whole set, so a new one cannot appear unnoticed.
+ # src/class/bth/bth_device.c, a file that EXISTS: the old assertion named
+ # src/class/vendor/vendor_host.c, deleted by the same branch, so any made-up
+ # path reached the same branch and the test passed vacuously.
+ real = os.path.join(REPO, 'src/class/bth/bth_device.c')
+ self.assertTrue(os.path.isfile(real), 'the case needs a file that exists')
+ s = ci_select.classify_build(['src/class/bth/bth_device.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertTrue(any('no contribution' in r for r in s['reasons']), s['reasons'])
+ # and the reason must name the class, not just any empty answer
+ self.assertTrue(any('bth' in r for r in s['reasons']), s['reasons'])
+
+ def test_class_source_with_examples_still_scopes(self):
+ s = ci_select.classify_build(['src/class/cdc/cdc_device.c'], REPO)
+ self.assertFalse(s['full'])
+
+ def test_no_stdout_pollution(self):
+ # get_family_boards prints on odd families; the selector's stdout is JSON
+ import io, contextlib
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ ci_select.classify_build(['src/class/msc/msc_host.c'], REPO)
+ self.assertEqual(buf.getvalue(), '')
+
+
+class TestNoContributionPaths(unittest.TestCase):
+ """Paths that are inside build.yml's code filter but cannot change a compiled byte.
+ Unclassified means FULL on both axes, so a metrics-only PR would otherwise cost the
+ whole build matrix plus an exclusive full-rig sweep - where master ran nothing."""
+
+ def test_metrics_scripts_run_on_no_board_but_still_build(self):
+ # HIL axis only. tools/metrics.py IS executed by a build - examples/CMakeLists.txt
+ # makes it the `tinyusb_metrics` target and build_util.yml adds
+ # `--target tinyusb_metrics` - so the build axis must keep exercising it, or a
+ # break merges green and reds the next master push. Nothing on the rig runs it.
+ for p in ('tools/metrics.py', '.github/scripts/metrics_pair_compare.py'):
+ h = sel([p])
+ self.assertFalse(h['full'], p)
+ self.assertEqual(h['boards'], {}, p)
+ self.assertTrue(ci_select.classify_build([p], REPO)['full'], p)
+
+ def test_typec_example_builds_but_runs_nothing(self):
+ # examples/typec is compiled by the build matrix and run by no rig board; the
+ # HIL walk used to not recognise the role at all -> unclassified -> full rig
+ p = 'examples/typec/power_delivery/src/main.c'
+ h = sel([p])
+ self.assertFalse(h['full'])
+ self.assertEqual(h['boards'], {})
+ b = ci_select.classify_build([p], REPO)
+ self.assertFalse(b['full'])
+ self.assertTrue(b['families'], 'typec still has to be compiled somewhere')
+
+
+class TestHilExamples(unittest.TestCase):
+ def test_board_test_always_present_and_full_emits(self):
+ s = ci_select.classify(['src/common/tusb_fifo.c'], REPO, ROSTERS) # full
+ he = ci_select.hil_examples(s, ROSTERS)
+ self.assertEqual(set(he), {b['name'] for b in ROSTER})
+ for name, exs in he.items():
+ self.assertIn('device/board_test', exs)
+
+ def test_narrowed_board_gets_chosen_tests_only(self):
+ s = ci_select.classify(['examples/device/cdc_msc/src/main.c'], REPO, ROSTERS)
+ he = ci_select.hil_examples(s, ROSTERS)
+ self.assertEqual(he['stm32f407disco'], ['device/board_test', 'device/cdc_msc'])
+
+ def test_full_board_gets_its_whole_test_list(self):
+ s = ci_select.classify(['hw/bsp/stm32f4/boards/stm32f407disco/board.h'], REPO, ROSTERS)
+ he = ci_select.hil_examples(s, ROSTERS)
+ want = set(ci_select.board_tests(ROSTER[1])) | {'device/board_test'}
+ self.assertEqual(set(he['stm32f407disco']), want)
+ self.assertNotIn('raspberry_pi_pico', he) # deselected board: no firmware needed
+
+
+class TestHilExamplesDuplicateRosters(unittest.TestCase):
+ """Rosters are disjoint today, but a board moved between rigs (or listed on both
+ during a migration) must get the UNION of its test lists: superset firmware is
+ harmless, a missing image fails the run on whichever rig lost the coin toss."""
+
+ ROSTERS = [
+ ('test/hil/a.json', [{'name': 'dup_board', 'uid': 'd1', 'flasher': {'name': 'jlink'},
+ 'tests': {'only': ['device/cdc_msc']}}]),
+ ('test/hil/b.json', [{'name': 'dup_board', 'uid': 'd1', 'flasher': {'name': 'jlink'},
+ 'tests': {'only': ['device/hid_boot_interface']}}]),
+ ]
+
+ def test_duplicate_board_unions_the_test_lists(self):
+ he = ci_select.hil_examples({'full': True, 'boards': {}}, self.ROSTERS)
+ self.assertEqual(he['dup_board'],
+ ['device/board_test', 'device/cdc_msc',
+ 'device/hid_boot_interface'])
+
+
+class TestCliJson(unittest.TestCase):
+ def test_build_key_without_rosters(self):
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', '/dev/null'], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ j = json.loads(r.stdout)
+ self.assertIn('build', j)
+ self.assertNotIn('hil_examples', j) # rosters not given
+
+ def test_build_and_hil_keys_with_rosters(self):
+ import tempfile
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('src/portable/raspberrypi/rp2040/dcd_rp2040.c\n')
+ df = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', df, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ os.unlink(df)
+ j = json.loads(r.stdout)
+ self.assertEqual(j['build']['families'], ['rp2040'])
+ self.assertIn('hil_examples', j)
+ for exs in j['hil_examples'].values():
+ self.assertIn('device/board_test', exs)
+
+
+SET_MATRIX = os.path.join(REPO, '.github/scripts/ci_set_matrix.py')
+
+
+class TestCiSetMatrix(unittest.TestCase):
+ def run_matrix(self, *args):
+ return subprocess.run([sys.executable, SET_MATRIX, *args],
+ capture_output=True, text=True)
+
+ def test_no_flags_is_todays_output(self):
+ r = self.run_matrix()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.baseline = json.loads(r.stdout)
+ self.assertIn('stm32f4', self.baseline['arm-gcc'])
+
+ def test_select_full_is_identical(self):
+ base = json.loads(self.run_matrix().stdout)
+ sel = json.dumps({'build': {'full': True, 'families': [], 'family_examples': {}}})
+ self.assertEqual(json.loads(self.run_matrix('--select', sel).stdout), base)
+
+ def test_select_narrow_is_a_subset(self):
+ sel = json.dumps({'build': {'full': False, 'families': ['rp2040', 'stm32f4'],
+ 'family_examples': {}}})
+ m = json.loads(self.run_matrix('--select', sel).stdout)
+ self.assertEqual(m['arm-gcc'], ['rp2040', 'stm32f4'])
+ self.assertEqual(m['riscv-gcc'], [])
+ self.assertEqual(set(m), set(json.loads(self.run_matrix().stdout))) # all keys kept
+
+ def test_malformed_select_falls_open(self):
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select', 'not json {')
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+ def test_wrong_shaped_select_falls_open_too(self):
+ # valid JSON, wrong types: the matrix is built AFTER main()'s try/except, so an
+ # AttributeError here reds the step - the very outcome that handler exists to
+ # prevent (GHA and CircleCI only survive it through their own shell `||`)
+ base = json.loads(self.run_matrix().stdout)
+ for bad in ('{"build": ["stm32f4"]}', '{"build": {"full": false}}',
+ '{"build": {"full": false, "families": "stm32f4"}}', '["stm32f4"]'):
+ r = self.run_matrix('--select', bad)
+ self.assertEqual(r.returncode, 0, f'{bad}: {r.stderr}')
+ self.assertEqual(json.loads(r.stdout), base, bad)
+
+ def test_base_flag_with_empty_diff_selects_nothing(self):
+ # --base HEAD => empty diff => build.families [] => every toolchain scopes to []
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--base', 'HEAD')
+ self.assertEqual(r.returncode, 0, r.stderr)
+ m = json.loads(r.stdout)
+ self.assertEqual(set(m), set(base))
+ self.assertTrue(all(v == [] for v in m.values()), m)
+
+ def test_select_file_matches_select(self):
+ # build.yml hands the selection over as a FILE: a ~128KiB step env var makes
+ # the step's own exec fail with E2BIG before any fallback can run
+ import tempfile
+ sel = json.dumps({'build': {'full': False, 'families': ['rp2040'],
+ 'family_examples': {}}})
+ with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as f:
+ f.write(sel)
+ path = f.name
+ try:
+ self.assertEqual(self.run_matrix('--select-file', path).stdout,
+ self.run_matrix('--select', sel).stdout)
+ finally:
+ os.unlink(path)
+
+ def test_absent_families_key_falls_open(self):
+ # `{"build": {"full": false}}` with no families key is an unusable selection,
+ # not "nothing selected": scoping every toolchain to [] would report a
+ # vacuous green with zero families built
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select', json.dumps({'build': {'full': False}}))
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+ def test_families_no_toolchain_builds_falls_open(self):
+ # hw/bsp/same7x is real but in no toolchain's list, so scoping to it emits an
+ # all-empty matrix: every leg skips and the PR goes green from a build job that
+ # ran no compiler. Unusable, not "nothing selected" - and the marker matters,
+ # because that is what build.yml and CircleCI grep to drop the build extras too.
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select',
+ json.dumps({'build': {'full': False, 'families': ['same7x']}}))
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr)
+
+ def test_a_partial_toolchain_miss_still_scopes(self):
+ # one buildable family is real coverage: scope to it and just note the other
+ r = self.run_matrix('--select', json.dumps(
+ {'build': {'full': False, 'families': ['stm32f4', 'same7x']}}))
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(json.loads(r.stdout)['arm-gcc'], ['stm32f4'])
+ self.assertNotIn('UNSCOPED', r.stderr)
+ self.assertIn('same7x', r.stderr)
+
+ def test_explicit_empty_families_selects_nothing(self):
+ # an explicit [] IS a legitimate answer (a diff that builds nothing)
+ r = self.run_matrix('--select',
+ json.dumps({'build': {'full': False, 'families': []}}))
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(set().union(*json.loads(r.stdout).values()), set())
+
+ def test_missing_select_file_falls_open(self):
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select-file', '/no/such/selection.json')
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+ def test_base_flag_bad_ref_falls_open(self):
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--base', 'no-such-ref-xyz')
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+
+HIL_SET_MATRIX = os.path.join(REPO, '.github/scripts/hil_ci_set_matrix.py')
+
+
+class TestHilCiSetMatrixExamples(unittest.TestCase):
+ def run_matrix(self, *args):
+ r = subprocess.run([sys.executable, HIL_SET_MATRIX, *args,
+ os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ return r.stdout
+
+ def test_no_hil_examples_is_byte_identical(self):
+ plain = self.run_matrix()
+ sel = json.dumps({'full': True, 'boards': {}})
+ self.assertEqual(self.run_matrix('--select', sel), plain)
+
+ def test_absent_boards_key_falls_open_to_the_full_roster(self):
+ # the mirror of ci_set_matrix's families guard: reading an ABSENT boards key as
+ # "nothing selected" filters every board out, so every hil-build leg skips and
+ # both rig jobs skip through needs: - an all-green PR with zero hardware
+ # coverage. An explicit boards: {} stays a legitimate nothing-selected.
+ plain = self.run_matrix()
+ for bad in ('{"full": false, "hil_examples": {}}', '{"full": false, "boards": []}',
+ 'not json {', '["a board"]',
+ # the whole selection is unusable, hil_examples included: keeping the
+ # -e lists builds a few examples per board while the rig, unfiltered,
+ # runs that board's whole test list
+ '{"full": false, "hil_examples": {"frdm_k64f": ["device/cdc_msc"]}}'):
+ self.assertEqual(self.run_matrix('--select', bad), plain, bad)
+ self.assertNotEqual(self.run_matrix('--select', '{"full": false, "boards": {}}'),
+ plain, 'an explicit empty boards map still means nothing')
+
+ def test_select_file_matches_select(self):
+ # hil-hfp-iar passes the whole selection; as one argv it can exceed
+ # MAX_ARG_STRLEN on a big diff, so the file form must be equivalent
+ import tempfile
+ board = on_roster(self, 'stm32f407disco')[0]
+ sel = json.dumps({'full': False, 'boards': {board: 'all'},
+ 'hil_examples': {board: ['device/board_test']}})
+ with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as f:
+ f.write(sel)
+ path = f.name
+ try:
+ self.assertEqual(self.run_matrix('--select-file', path),
+ self.run_matrix('--select', sel))
+ finally:
+ os.unlink(path)
+
+ def test_examples_appended_per_board(self):
+ board = on_roster(self, 'stm32f407disco')[0]
+ sel = json.dumps({'full': False, 'boards': {board: 'all'},
+ 'hil_examples': {board: ['device/board_test', 'device/cdc_msc']}})
+ m = json.loads(self.run_matrix('--select', sel))
+ entries = [e for entries in m.values() for e in entries]
+ self.assertTrue(entries)
+ for e in entries:
+ self.assertIn(f'-b {board}', e)
+ self.assertIn('-e device/board_test', e)
+ self.assertIn('-e device/cdc_msc', e)
+
+
+class TestBuildPyExampleFilter(unittest.TestCase):
+ def setUp(self):
+ import build as build_py
+ self.build = build_py
+ self.old = os.getcwd()
+ os.chdir(REPO) # skip_example uses repo-relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_all_maps_to_example_names(self):
+ # ONE group: the examples of a '--target all' build go into a single
+ # `cmake --build --target a b c`, so they build in parallel
+ t = self.build.resolve_example_target_groups(['all'], ['device/cdc_msc', 'device/dfu'],
+ 'stm32f407disco')
+ self.assertEqual(t, [['cdc_msc', 'dfu']])
+
+ def test_other_targets_pass_through_in_their_own_group(self):
+ # a target that is not 'all' keeps its own invocation, so ordering against the
+ # examples is preserved (tinyusb_metrics runs after them, as it did unfiltered)
+ t = self.build.resolve_example_target_groups(['all', 'tinyusb_metrics'],
+ ['device/cdc_msc'], 'stm32f407disco')
+ self.assertEqual(t, [['cdc_msc'], ['tinyusb_metrics']])
+
+ def test_unbuildable_examples_drop_and_empty_is_none(self):
+ # typec/power_delivery only builds on stm32g4-class parts, never on f4
+ t = self.build.resolve_example_target_groups(['all'],
+ ['typec/power_delivery', 'device/cdc_msc'],
+ 'stm32f407disco')
+ self.assertEqual(t, [['cdc_msc']])
+ self.assertIsNone(self.build.resolve_example_target_groups(['all'],
+ ['typec/power_delivery'],
+ 'stm32f407disco'))
+
+ def test_espressif_empty_intersection_skips_without_building(self):
+ # cmake_board's espressif branch must short-circuit on an empty -e
+ # intersection the same way the generic cmake/make branches do, and
+ # must do so before touching idf.py (no real esp-idf build here).
+ calls = []
+ real_run_cmd = self.build.run_cmd # `del` here would drop the real one
+ self.build.run_cmd = lambda cmd: calls.append(cmd) # would only run for a real build
+ try:
+ r = self.build.cmake_board('espressif_s3_devkitc', [], None, [], ['all'],
+ examples=['nonexistent/example'])
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.assertEqual(r, [0, 0, 1])
+ self.assertEqual(calls, [])
+
+ def test_make_one_example_uses_make_semantics(self):
+ # F1 end to end: the make path must ask skip_example with build_system='make',
+ # or lpc54's cmake-only FAMILY_MCUS un-skips a host example whose make build
+ # compiles no HCD source and fails to link
+ calls = []
+ real_run_cmd = self.build.run_cmd
+ self.build.run_cmd = lambda cmd: calls.append(cmd)
+ try:
+ r = self.build.make_one_example('host/msc_file_explorer_freertos',
+ 'lpcxpresso54628', '', ['all'])
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.assertEqual(r, [0, 0, 1]) # skipped, nothing handed to make
+ self.assertEqual(calls, [])
+
+ def test_example_flag_rejects_a_bare_name(self):
+ # `-e cdc_msc` (no role) used to IndexError inside the target resolver;
+ # argparse rejects the shape now, with a message that names it
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools', 'build.py'),
+ '-b', 'stm32f407disco', '-e', 'cdc_msc'],
+ capture_output=True, text=True, cwd=REPO)
+ self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
+ self.assertIn('role/name', r.stderr)
+
+ def test_no_example_basename_is_reused_across_roles(self):
+ # -e maps role/name onto the BARE cmake target name, so device/foo and host/foo
+ # would collapse into one `--target foo`: one of them would never build while
+ # the post-configure check still reports both as covered. No collision today,
+ # and the -e lists are machine-generated, so nothing else would notice one.
+ seen = {}
+ for ex in ci_select.all_examples(REPO):
+ role, name = ex.split('/', 1)
+ self.assertNotIn(name, seen,
+ f'{ex} and {seen.get(name)}/{name} share a cmake target name; '
+ f'build.py -e cannot tell them apart')
+ seen[name] = role
+
+ def test_example_flag_rejects_a_name_no_example_dir_answers_to(self):
+ # right shape, no such dir: every board would report Skipped and the run would
+ # still exit 0 (main returns the FAILED count), so an entirely stale -e list -
+ # from the example map or from a roster test name - reads as a green build
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools', 'build.py'),
+ '-b', 'stm32f407disco', '-e', 'device/no_such_example'],
+ capture_output=True, text=True, cwd=REPO)
+ self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
+ self.assertIn('no such example directory', r.stderr)
+
+ def test_pr_filter_answers_before_configuring(self):
+ # nothing the -e list names is buildable here: the skip.txt mirror needs no
+ # configure output, so the whole cmake run must be skipped, not just its build
+ calls = []
+ real_run_cmd = self.build.run_cmd
+ self.build.run_cmd = lambda cmd: calls.append(cmd)
+ try:
+ r = self.build.cmake_board('stm32f407disco', [], None, [], ['all'],
+ examples=['typec/power_delivery'])
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.assertEqual(r, [0, 0, 1])
+ self.assertEqual(calls, [])
+
+ def _cmake_board_with_targets(self, registered, examples):
+ """cmake_board with the configure/build stubbed and CMake's registered-target
+ list forced. Returns (result, target names handed to `cmake --build`)."""
+ class Ok:
+ returncode = 0
+ calls = []
+
+ def fake_run(cmd):
+ calls.append(cmd)
+ return Ok()
+ real_run_cmd = self.build.run_cmd
+ real_targets = self.build.cmake_registered_targets
+ self.build.run_cmd = fake_run
+ self.build.cmake_registered_targets = lambda d: registered
+ try:
+ r = self.build.cmake_board('stm32f407disco', [], None, [], ['all'],
+ examples=examples)
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.build.cmake_registered_targets = real_targets
+ # everything after --target: one invocation carries the whole group
+ built = [c[c.index('--target') + 1:] for c in calls if '--target' in c]
+ return r, built
+
+ def test_example_without_a_cmake_target_is_dropped(self):
+ # an example dir CMake never registered (absent from the role CMakeLists, or
+ # a stale roster name) must not reach `cmake --build --target <it>`: that is a
+ # hard red, and skip.txt cannot see it
+ r, built = self._cmake_board_with_targets({'cdc_msc'},
+ ['device/cdc_msc', 'device/dfu'])
+ self.assertEqual(built, [['cdc_msc']])
+ self.assertEqual(r, [1, 0, 0])
+
+ def test_the_selected_examples_build_in_one_invocation(self):
+ # one `cmake --build --target a b c`, not one invocation per example: the
+ # per-example loop serialised every scoped leg, and hil-build gets an -e list
+ # on EVERY PR (~14 examples per board), so it is on the critical path to the rig
+ r, built = self._cmake_board_with_targets({'cdc_msc', 'dfu', 'hid_generic_inout'},
+ ['device/cdc_msc', 'device/dfu',
+ 'device/hid_generic_inout'])
+ self.assertEqual(built, [['cdc_msc', 'dfu', 'hid_generic_inout']])
+
+ def test_no_registered_target_at_all_skips_the_build(self):
+ r, built = self._cmake_board_with_targets({'cdc_msc'}, ['device/dfu'])
+ self.assertEqual(built, [])
+ self.assertEqual(r, [0, 0, 1])
+
+ def test_unparseable_target_help_keeps_the_skip_txt_answer(self):
+ # ground truth unavailable (a non-Ninja generator, an old cmake): fall back
+ # to the mirror rather than dropping every example
+ r, built = self._cmake_board_with_targets(None, ['device/cdc_msc'])
+ self.assertEqual(built, [['cdc_msc']])
+
+ def test_target_help_parse(self):
+ text = ('[1/1] All primary targets available:\n'
+ 'tinyusb_metrics: phony\n'
+ 'cdc_msc: phony\n'
+ 'cdc_msc-membrowse-upload: phony\n'
+ 'device/edit_cache: phony\n'
+ '/abs/build/device/cdc_msc/CMakeFiles/cdc_msc-jlink: CUSTOM_COMMAND\n')
+ self.assertEqual(self.build.parse_target_help(text),
+ {'tinyusb_metrics', 'cdc_msc', 'cdc_msc-membrowse-upload'})
+
+ def test_build_defines_reach_the_example_filter(self):
+ # metro_m4_express gets MAX3421_HOST=1 from its roster variant, never
+ # from its BSP: without threading them through, -e drops the rig's only
+ # MAX3421 dual firmware that --target all used to build
+ self.assertIsNone(self.build.resolve_example_target_groups(
+ ['all'], ['dual/host_info_to_device_cdc'], 'metro_m4_express'))
+ self.assertEqual(self.build.resolve_example_target_groups(
+ ['all'], ['dual/host_info_to_device_cdc'], 'metro_m4_express',
+ extra_defines=('MAX3421_HOST=1',)), [['host_info_to_device_cdc']])
+
+
+
+class TestFamilyMcusFallback(unittest.TestCase):
+ """A family whose family.cmake sets FAMILY_MCUS only inside if() blocks gets its
+ whole MCU answer from _board_mcu's CFG_TUSB_MCU scrape (build_utils._family_mcus
+ does not evaluate cmake conditionals). For mcx that answer is load-bearing - six
+ examples' skip.txt name mcu:MCXA15 - and it comes out right only because every
+ mcx board still carries the token in a make-only board.mk the scrape falls
+ through to. A board.cmake-only board (MCU_VARIANT, no CFG_TUSB_MCU) would scrape
+ 'NONE' and silently skip EVERY example on it, in CI as well as in -e."""
+
+ @staticmethod
+ def conditional_only_families():
+ """hw/bsp/<family> dirs whose family.cmake has no unconditional
+ set(FAMILY_MCUS ...) - computed, not listed, so a family that grows or loses
+ one moves in and out of this guard on its own."""
+ import build_utils
+ out = []
+ for fc in sorted(glob.glob(os.path.join(REPO, 'hw/bsp/*/family.cmake'))):
+ depth, uncond = 0, False
+ for line in open(fc).read().splitlines():
+ line = line.strip()
+ if build_utils._FAMILY_MCUS_RE.match(line) and depth == 0:
+ uncond = True
+ if re.match(r'if\s*\(', line):
+ depth += 1
+ elif re.match(r'endif\s*\(', line):
+ depth = max(0, depth - 1)
+ if not uncond:
+ out.append(os.path.dirname(fc))
+ return out
+
+ def test_every_board_of_such_a_family_scrapes_an_mcu(self):
+ import build_utils
+ fams = self.conditional_only_families()
+ self.assertTrue(fams, 'no family sets FAMILY_MCUS conditionally any more')
+ for fam_dir in fams:
+ fam = os.path.basename(fam_dir)
+ for bd in sorted(glob.glob(os.path.join(fam_dir, 'boards', '*'))):
+ if not os.path.isdir(bd):
+ continue
+ mcu, _ = build_utils._board_mcu(bd, fam_dir, fam)
+ self.assertNotEqual(
+ mcu, 'NONE',
+ f'{fam}/{os.path.basename(bd)}: nothing to scrape a CFG_TUSB_MCU '
+ f'token from, and {fam}/family.cmake sets FAMILY_MCUS only inside '
+ f'if() - skip_example would skip every example on this board. Fix '
+ f'by evaluating the if(MCU_VARIANT STREQUAL ...) branches.')
+
+
+class TestMcuTokensResolve(unittest.TestCase):
+ """The cmake-side MCU mirror must never answer with an unexpanded ${VAR} or with
+ nothing at all: both make every `mcu:` token miss, which reads as 'skip' for any
+ example carrying an only.txt and silently drops compile coverage."""
+
+ @staticmethod
+ def _every_board():
+ import build as build_py
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ for fam in sorted(os.path.basename(os.path.dirname(f))
+ for f in glob.glob(os.path.join(REPO, 'hw/bsp/*/boards'))):
+ for b in build_py.get_family_boards(fam, False, False):
+ yield fam, b
+ finally:
+ os.chdir(old)
+
+ def test_no_board_answers_with_an_unexpanded_variable(self):
+ import build_utils
+ for fam, board in self._every_board():
+ fam_dir, board_dir = f'{REPO}/hw/bsp/{fam}', f'{REPO}/hw/bsp/{fam}/boards/{board}'
+ mcus = set(build_utils._family_mcus(fam_dir, board_dir))
+ mcus.add(build_utils._board_mcu(board_dir, fam_dir, fam)[0])
+ self.assertFalse([m for m in mcus if '${' in m],
+ f'{fam}/{board}: unexpanded cmake variable in {sorted(mcus)} - '
+ f'teach build_utils._cmake_expand the construct that produces it')
+ self.assertTrue(mcus - {'NONE'},
+ f'{fam}/{board}: no MCU name resolved at all')
+
+ # skip.txt/only.txt tokens no board in the tree answers to: stale spellings left
+ # behind by a family rename. Each one silently changes what CI builds, so this list
+ # must only ever SHRINK - a new entry means either a live token the mirror cannot
+ # produce, or a rename nobody followed through. `family:samd21` was one of these
+ # until the nine examples/host/*/only.txt files were corrected to samd2x_l2x.
+ #
+ # The remaining `mcu:` entries sit beside a live token in the same file, so they gate
+ # nothing either way. MKL25ZXX (7 files) and SAME5X (1) were dead too, but unlike
+ # these they were the ONLY token for their board - the examples were already being
+ # built on the very boards those lines meant to exclude. Dropping them is a no-op for
+ # the build (verified per example) and was chosen over re-pointing, which would have
+ # removed working coverage.
+ UNREACHABLE_TOKENS = {
+ 'mcu': {'LPC177X_8X', 'MIMXRT10XX', 'MIMXRT11XX', 'STM32U3'},
+ 'family': set(),
+ 'board': set(),
+ }
+
+ def test_every_skip_only_token_is_reachable(self):
+ import build_utils
+ wanted = {ns: set() for ns in self.UNREACHABLE_TOKENS}
+ for f in glob.glob(os.path.join(REPO, 'examples/*/*/*.txt')):
+ if os.path.basename(f) in ('skip.txt', 'only.txt'):
+ for tok in open(f).read().split():
+ ns, _, name = tok.partition(':')
+ if ns in wanted and name:
+ wanted[ns].add(name)
+ have = {ns: set() for ns in wanted}
+ have['mcu'].add('MAX3421') # synthetic, from family_support.cmake:940
+ for fam, board in self._every_board():
+ fam_dir, board_dir = f'{REPO}/hw/bsp/{fam}', f'{REPO}/hw/bsp/{fam}/boards/{board}'
+ have['family'].add(fam)
+ have['board'].add(board)
+ have['mcu'] |= set(build_utils._family_mcus(fam_dir, board_dir))
+ have['mcu'].add(build_utils._board_mcu(board_dir, fam_dir, fam)[0])
+ have['mcu'].add(build_utils._scrape_mcu(pathlib.Path(fam_dir),
+ pathlib.Path(board_dir), fam)[0]) # make
+ for ns in wanted:
+ self.assertEqual(
+ wanted[ns] - have[ns], self.UNREACHABLE_TOKENS[ns] & wanted[ns],
+ f'a skip.txt/only.txt {ns}: token nothing in hw/bsp answers to. Either '
+ f'the token is stale (a rename just changed what CI builds), or the '
+ f'mirror cannot produce it - both silently skip that example everywhere.')
+
+ def test_the_mcx_skip_tokens_are_still_live(self):
+ # the reason the mcx scrape is load-bearing rather than academic
+ named = [os.path.dirname(f) for f in glob.glob(os.path.join(REPO, 'examples/*/*/skip.txt'))
+ if 'mcu:MCXA15' in open(f).read().split()]
+ self.assertTrue(named, 'no skip.txt names mcu:MCXA15 any more')
+
+
+class TestSkipExampleMirrorsFamilyFilter(unittest.TestCase):
+ """build_utils.skip_example is the python mirror of CMake's family_filter
+ (hw/bsp/family_support.cmake:171-207). family_filter loops over the whole
+ FAMILY_MCUS list; a per-board CFG_TUSB_MCU scrape alone lets -e ask for a
+ target CMake never created, and `cmake --build --target <it>` hard-fails."""
+
+ def setUp(self):
+ import build_utils
+ self.build_utils = build_utils
+ self.old = os.getcwd()
+ os.chdir(REPO) # skip_example uses repo-relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_any_family_mcu_can_skip(self):
+ # broadcom_64bit: set(FAMILY_MCUS BCM2711 BCM2835); raspberrypi_cm4 is
+ # BCM2711, and examples/device/dfu/skip.txt lists mcu:BCM2835
+ self.assertTrue(self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4'))
+
+ def test_any_family_mcu_can_satisfy_only(self):
+ # lpc55: family.mk says LPC55XX, family.cmake sets FAMILY_MCUS LPC55, and
+ # host/cdc_msc_hid/only.txt lists mcu:LPC55 - CMake builds it
+ self.assertFalse(self.build_utils.skip_example('host/cdc_msc_hid', 'lpcxpresso55s69'))
+
+ def test_existing_decisions_are_unchanged(self):
+ self.assertFalse(self.build_utils.skip_example('device/cdc_msc', 'stm32f407disco'))
+ self.assertTrue(self.build_utils.skip_example('typec/power_delivery', 'stm32f407disco'))
+
+ def test_build_define_enables_max3421_only_list(self):
+ # family_support.cmake:940 appends MAX3421 to FAMILY_MCUS when
+ # MAX3421_HOST=1; on metro_m4_express that define comes from the roster
+ # variant defines, so skip_example has to be told about it
+ ex = 'dual/host_info_to_device_cdc'
+ self.assertTrue(self.build_utils.skip_example(ex, 'metro_m4_express'))
+ self.assertFalse(self.build_utils.skip_example(ex, 'metro_m4_express',
+ extra_defines=('MAX3421_HOST=1',)))
+
+ def test_family_mcus_variable_token_resolves(self):
+ """hw/bsp/ra/family.cmake: `set(FAMILY_MCUS RAXXX ${MCU_VARIANT})`, and
+ ra6m5_ek/board.cmake sets MCU_VARIANT ra6m5 — which is exactly the token
+ dual/host_info_to_device_cdc/only.txt spells (mcu:ra6m5). Dropping the
+ ${...} token silently removed ra from every scoped dual-example build."""
+ self.assertFalse(self.build_utils.skip_example(
+ 'dual/host_info_to_device_cdc', 'ra6m5_ek'))
+
+ def test_board_cmake_max3421_counts(self):
+ """feather_rp2040_max3421/board.cmake sets MAX3421_HOST 1 while the MCU
+ token comes from rp2040's family.cmake; scanning only the file the token
+ came from misses it, and only.txt's mcu:MAX3421 never matches."""
+ self.assertFalse(self.build_utils.skip_example(
+ 'host/cdc_msc_hid_freertos', 'feather_rp2040_max3421'))
+
+
+class TestSkipExampleMakeSemantics(unittest.TestCase):
+ """FAMILY_MCUS is a CMAKE fact. hw/bsp/lpc54/family.cmake sets it to LPC54 and
+ wires the ohci host sources; family.mk builds OPT_MCU_LPC54XXX and compiles no
+ HCD source at all — so applying the cmake MCU union to a Make build un-skips
+ the 9 host examples only.txt gates on mcu:LPC54 and they fail to link
+ (undefined reference to hcd_init). Make keeps master's exact algorithm."""
+
+ def setUp(self):
+ import build_utils
+ self.build_utils = build_utils
+ self.old = os.getcwd()
+ os.chdir(REPO) # skip_example uses repo-relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_make_keeps_cmake_only_family_mcus_out(self):
+ self.assertTrue(self.build_utils.skip_example(
+ 'host/msc_file_explorer_freertos', 'lpcxpresso54628', build_system='make'))
+
+ def test_make_does_not_skip_on_a_sibling_family_mcu(self):
+ # broadcom_64bit sets FAMILY_MCUS "BCM2711 BCM2835"; raspberrypi_cm4 is the
+ # BCM2711 one and device/dfu/skip.txt names mcu:BCM2835. The aarch64 make leg
+ # built device/dfu before the union and must keep building it.
+ for ex in ('device/dfu', 'device/usbtmc'):
+ self.assertFalse(self.build_utils.skip_example(
+ ex, 'raspberrypi_cm4', build_system='make'), ex)
+
+ def test_cmake_is_the_default_and_still_unions(self):
+ self.assertTrue(self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4'))
+ self.assertEqual(
+ self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4'),
+ self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4',
+ build_system='cmake'))
+
+ def test_build_system_is_part_of_the_cache_key(self):
+ # one lru_cache shared by both semantics would answer the second caller
+ # with the first caller's verdict
+ ex, board = 'host/msc_file_explorer_freertos', 'lpcxpresso54628'
+ self.assertFalse(self.build_utils.skip_example(ex, board, build_system='cmake'))
+ self.assertTrue(self.build_utils.skip_example(ex, board, build_system='make'))
+ self.assertFalse(self.build_utils.skip_example(ex, board, build_system='cmake'))
+
+
+class TestConfigEnables(unittest.TestCase):
+ """_config_enables decides which examples a class change selects, on BOTH the
+ build and the HIL axis. A define it cannot evaluate must read as ON: reading
+ it as OFF is fail-closed, and lets a compile break merge green."""
+
+ def test_identifier_value_is_enabled(self):
+ # examples/host/midi_rx: `#define CFG_TUH_MIDI CFG_TUH_DEVICE_MAX`
+ cfg = os.path.join(REPO, 'examples/host/midi_rx/src/tusb_config.h')
+ self.assertTrue(ci_select._config_enables(cfg, ['CFG_TUH_MIDI']))
+
+ def test_literal_zero_is_disabled(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ cfg = os.path.join(td, 'tusb_config.h')
+ with open(cfg, 'w') as f:
+ f.write('#define CFG_TUD_CDC 0\n'
+ '#define CFG_TUD_MSC (0)\n'
+ '#define CFG_TUD_HID 00\n'
+ '#define CFG_TUH_HID 0 // typical keyboard + mouse\n'
+ '#define CFG_TUD_MIDI 01\n'
+ '#define CFG_TUD_DFU (1)\n')
+ for m in ('CFG_TUD_CDC', 'CFG_TUD_MSC', 'CFG_TUD_HID', 'CFG_TUH_HID'):
+ self.assertFalse(ci_select._config_enables(cfg, [m]), m)
+ for m in ('CFG_TUD_MIDI', 'CFG_TUD_DFU'):
+ self.assertTrue(ci_select._config_enables(cfg, [m]), m)
+ self.assertFalse(ci_select._config_enables(cfg, ['CFG_TUD_VIDEO']))
+
+ def test_two_branch_define_reads_on(self):
+ # examples/device/uac2_speaker_fb defines CFG_TUD_HID 1 under
+ # `#if CFG_AUDIO_DEBUG` and 0 in the #else. The default build (CFG_AUDIO_DEBUG
+ # defaults to 1) compiles the HID class in, so a CFG_TUD_HID change must keep
+ # this example on both axes - the #else's zero must not decide it.
+ cfg = os.path.join(REPO, 'examples/device/uac2_speaker_fb/src/tusb_config.h')
+ self.assertTrue(ci_select._config_enables(cfg, ['CFG_TUD_HID']))
+
+ def test_any_nonzero_define_wins_over_a_zero_one(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ cfg = os.path.join(td, 'tusb_config.h')
+ with open(cfg, 'w') as f:
+ f.write('#if FOO\n#define CFG_TUD_MSC 1\n#else\n'
+ '#define CFG_TUD_MSC 0\n#endif\n'
+ '#if BAR\n#define CFG_TUD_CDC 0\n#else\n'
+ '#define CFG_TUD_CDC (0)\n#endif\n')
+ self.assertTrue(ci_select._config_enables(cfg, ['CFG_TUD_MSC']))
+ self.assertFalse(ci_select._config_enables(cfg, ['CFG_TUD_CDC']))
+
+ def test_midi_host_change_selects_midi_rx(self):
+ s = ci_select.classify_build(['src/class/midi/midi_host.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertTrue(s['families'], 'a TUH_MIDI change must select some family')
+ self.assertTrue(any('host/midi_rx' in exs
+ for exs in s['family_examples'].values()),
+ s['family_examples'])
+
+
+class TestPruneUsesEveryFamilyBoard(unittest.TestCase):
+ """CircleCI's cmake legs build EVERY board of a family, so an example gated to
+ one board (only.txt board:mimxrt1060_evk) must keep its family even though the
+ family's one-first board cannot build it."""
+
+ def test_board_gated_example_keeps_its_family(self):
+ s = ci_select.classify_build(
+ ['examples/dual/host_hid_to_device_cdc/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertIn('imxrt', s['families'], s['families'])
+ self.assertEqual(s['family_examples'].get('imxrt'),
+ ['dual/host_hid_to_device_cdc'])
+
+ def test_either_build_system_keeps_the_family(self):
+ """This one family list gates CircleCI's MAKE legs too, and the two build
+ systems answer skip.txt differently. device/dfu carries mcu:BCM2835, which the
+ cmake FAMILY_MCUS union (BCM2711 BCM2835) applies to every broadcom_64bit board
+ and the make scrape applies to none - asking cmake alone drops the only
+ aarch64-gcc family in the matrix, so build-make-aarch64-gcc silently stops
+ compiling dfu at all."""
+ import build_utils
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ self.assertTrue(build_utils.skip_example('device/dfu', 'raspberrypi_cm4'))
+ self.assertFalse(build_utils.skip_example('device/dfu', 'raspberrypi_cm4',
+ (), 'make'))
+ finally:
+ os.chdir(old)
+ s = ci_select.classify_build(['examples/device/dfu/src/main.c'], REPO)
+ self.assertIn('broadcom_64bit', s['families'], s['families'])
+
+
+class TestPrunePoolIsBuildPys(unittest.TestCase):
+ """_prune_buildable asks build.py what each family's build path can see, the same
+ way for every family - the espressif carve-out lives in build.py.get_examples and
+ needs no second copy here. Measured identical on all 82 families."""
+
+ def setUp(self):
+ import build as build_py
+ self.build_py = build_py
+ self.old = os.getcwd()
+ os.chdir(REPO) # get_examples scans relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_only_espressif_narrows_the_pool(self):
+ allex = list(ci_select.all_examples(REPO))
+ for fam in ci_select.all_bsp_families(REPO):
+ pool = [e for e in allex if e in set(self.build_py.get_examples(fam))]
+ if fam == 'espressif':
+ self.assertNotEqual(pool, allex) # the carve-out is real
+ else:
+ self.assertEqual(pool, allex, f'{fam}: build.py narrows this family')
+
+ def test_selections_are_what_the_espressif_only_rule_gave(self):
+ # espressif's own list is the one value that ever differed from the unfiltered
+ # example set. Recomputed from build.py rather than pinned as literals: a new
+ # board, family or example moves the counts, and a suite that fails for that
+ # teaches people to edit the numbers instead of reading the diff. What is pinned
+ # is the RELATION - espressif gets exactly the rule's answer narrowed to its own
+ # pool, every other family gets the answer unnarrowed.
+ pool = set(self.build_py.get_examples('espressif'))
+ # the third diff names an example espressif DOES build, so there is nothing for
+ # the carve-out to remove - it pins that the narrowing does not over-reach
+ for files, carve in ((['src/portable/synopsys/dwc2/dcd_dwc2.c'], True),
+ (['src/class/msc/msc_host.c'], True),
+ (['examples/device/cdc_msc_freertos/src/main.c'], False)):
+ s = ci_select.classify_build(files, REPO)
+ self.assertFalse(s['full'], files)
+ self.assertIn('espressif', s['families'], files)
+ esp = set(s['family_examples'].get('espressif') or [])
+ self.assertTrue(esp, f'{files}: espressif selected nothing')
+ # the pool narrowing is what _prune_buildable adds here, so it must hold...
+ self.assertTrue(esp <= pool, f'{files}: {sorted(esp - pool)} is outside the pool')
+ # ...and it must actually bite: some other family was given an example that
+ # espressif's build path cannot see, and espressif did not get it
+ other = set().union(*(set(v) for f, v in s['family_examples'].items()
+ if f != 'espressif'), set())
+ self.assertEqual(bool(other - pool), carve,
+ f'{files}: carve-out expected={carve}, other-side extras '
+ f'{sorted(other - pool)}')
+ self.assertFalse(esp & (other - pool), files)
+
+
+class TestGetDepsExampleShim(unittest.TestCase):
+ """hil_ci_set_matrix emits `-b <board> -e role/name` entries that .github/actions/
+ get_deps and build.yml's hfp job hand verbatim to get_deps.py. argparse must not
+ reject -e there (exit 2 = every PR's Get Dependencies step red)."""
+
+ # get_deps.main() with its process pool stubbed out: argparse runs for real,
+ # nothing is cloned (this suite also runs on GitHub's bare pre-commit runner)
+ CODE = ('import sys\n'
+ 'import get_deps\n'
+ 'class P:\n'
+ ' def __enter__(self): return self\n'
+ ' def __exit__(self, *a): return False\n'
+ ' def map(self, fn, items): return [0] * len(items)\n'
+ 'get_deps.Pool = P\n'
+ "sys.argv = ['get_deps.py'] + sys.argv[1:]\n"
+ 'sys.exit(get_deps.main())\n')
+
+ def run_get_deps(self, *args):
+ env = dict(os.environ, PYTHONPATH=os.path.join(REPO, 'tools'))
+ return subprocess.run([sys.executable, '-c', self.CODE, *args],
+ capture_output=True, text=True, cwd=REPO, env=env)
+
+ def test_example_flag_is_accepted(self):
+ r = self.run_get_deps('-b', 'stm32f407disco', '-e', 'device/cdc_msc')
+ self.assertNotIn('unrecognized arguments', r.stderr)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_plain_board_still_works(self):
+ r = self.run_get_deps('-b', 'stm32f407disco')
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=1)
diff --git a/test/hil/test/test_hil_bounded.py b/test/hil/test/test_hil_bounded.py
index 908a142d5..c30c58cbd 100644
--- a/test/hil/test/test_hil_bounded.py
+++ b/test/hil/test/test_hil_bounded.py
@@ -46,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."""
@@ -63,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()
@@ -77,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'])
@@ -112,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):
@@ -136,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):
@@ -313,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)
@@ -326,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,
@@ -400,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
@@ -641,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))
@@ -699,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
@@ -806,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.
@@ -899,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)
@@ -936,11 +698,17 @@ class RunWhileContract(unittest.TestCase):
def boom():
raise AssertionError('x')
+ # a duration no other process would plausibly pick: `pgrep -f` searches the WHOLE
+ # machine, so a bare `sleep 20` matched an unrelated background job -- another
+ # agent session's retry loop, in the case that exposed this -- and failed a test
+ # about our own child. Observed failing 3/3 in isolation while that loop ran.
+ sentinel = '20.0451'
with self.assertRaises(AssertionError):
- self.hil_util.run_alongside(['sleep', '20'], boom, 1)
+ self.hil_util.run_alongside(['sleep', sentinel], boom, 1)
# nothing of ours is left running: the reap ran on the error path too
import subprocess
- out = subprocess.run(['pgrep', '-f', '^sleep 20'], capture_output=True, text=True)
+ out = subprocess.run(['pgrep', '-f', f'^sleep {sentinel}'],
+ capture_output=True, text=True)
seen['strays'] = [p for p in out.stdout.split() if p]
self.assertEqual(seen['strays'], [], 'work() raising leaked the child')
@@ -966,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
@@ -1035,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):
@@ -1058,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 --
@@ -1146,10 +790,16 @@ class AbandonExitSurvivesAFailedFork(unittest.TestCase):
def test_none_pool_and_manager_still_write_the_banner(self):
# a subprocess, because _abandon_exit ends in os._exit: in-process it would take
# the test runner with it, before any assertion could run
+ import json
import subprocess
with TemporaryDirectory() as td:
- report = Path(td) / 'hil_report.md'
- report.write_text('| board | test |\n|---|---|\n', encoding='utf-8')
+ rd = Path(td)
+ # it takes the report DIRECTORY now and re-renders both artifacts from the
+ # sidecar, so seed the sidecar -- the markdown is output, not input
+ (rd / 'hil_report.json').write_text(json.dumps(
+ {'rows': [{'board': 'boardA', 'cells': {'cdc_msc': 'pass'},
+ 'duration': '1s'}],
+ 'banner': '', 'scope': '', 'caveat': ''}))
src = (
'import sys, types\n'
f'sys.path.insert(0, {str(Path(TEST_DIR).parents[0])!r})\n'
@@ -1160,12 +810,14 @@ class AbandonExitSurvivesAFailedFork(unittest.TestCase):
'sys.modules.setdefault("serial", st)\n'
'import hil_test\n'
f'hil_test._abandon_exit(None, None, True, 1, __import__("pathlib")'
- f'.Path({str(report)!r}))\n')
+ f'.Path({str(rd)!r}))\n')
r = subprocess.run([sys.executable, '-c', src], capture_output=True,
text=True, timeout=120)
self.assertEqual(r.returncode, 1, r.stderr)
- self.assertTrue(report.read_text().startswith('**HIL run abandoned'),
- 'the abandon banner never reached the report')
+ self.assertTrue((rd / 'hil_report.md').read_text().startswith(
+ '**HIL run abandoned'), 'the abandon banner never reached the report')
+ self.assertIn('abandoned',
+ json.loads((rd / 'hil_report.json').read_text())['caveat'])
def test_kill_pool_children_tolerates_a_pool_that_never_existed(self):
from helper import hil_health
@@ -1174,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
@@ -1207,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')
@@ -1219,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
@@ -1244,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):
@@ -1318,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
@@ -1364,7 +1021,14 @@ class RemoteDirIsScreened(unittest.TestCase):
rc = 0 if keep_going else 77
for tool in ('ssh', 'scp', 'rsync'):
write_script(Path(td) / tool, f'echo "stub-{tool} $*" >&2; exit {rc}')
+ # hil_ci.sh now refuses an all-boards run with nothing built, so this arg-quoting
+ # test needs a checkout stub with one build dir to reach the run invocation
+ root = Path(td) / 'root'
+ (root / 'test' / 'hil').mkdir(parents=True)
+ (root / 'test' / 'hil' / 'hil_test.py').touch()
+ (root / 'examples' / 'cmake-build-alpha').mkdir(parents=True)
env = {**os.environ, 'REMOTE_DIR': remote_dir, 'REMOTE': 'stub',
+ 'ROOT_DIR': str(root),
'PATH': td + os.pathsep + os.environ['PATH']}
return subprocess.run(
['bash', str(Path(TEST_DIR).parents[0] / 'hil_ci.sh'), *args],
@@ -1414,92 +1078,230 @@ class RemoteDirIsScreened(unittest.TestCase):
self.assertIn(r'host/cdc\ msc', run_line)
-class CaveatSurvivesAccumulate(unittest.TestCase):
- """CI reruns with --accumulate: the sidecar keeps every earlier attempt's cells, but the
- banner was recomputed per attempt. A first attempt on a degraded rig and a clean rerun
- therefore published the degraded attempt's PASSES with no caveat on them -- and the
- generated .failed spec reruns only failures, so those cells are never re-earned."""
+class EveryBoardIsStaged(unittest.TestCase):
+ """One hil_test.py run takes several `-b` flags, and hil-operator hands it the whole board
+ set that way. The `-b` parse loop kept a single BOARD, so only the LAST board's binaries
+ were rsynced and every other board died on the rig with a missing firmware path -- after
+ its flash slot and lock were already spent.
- def _rows(self, board, cell):
- return [(board, 0, 0, [(board, {cell: 'OK'}, '1s')], 0)]
+ Three of these five fail against the pre-fix script (the discriminating unbuilt case
+ puts the board FIRST, because the old single-BOARD parse happened to handle a trailing
+ one correctly); the run-line and variant-dir tests are characterization -- the old script
+ already forwarded ARGS whole and read variants from the config for its one board."""
- def test_an_earlier_attempts_caveat_is_still_on_the_report(self):
+ def _run(self, boards, cfg_boards=None, variants=None):
+ import json
+ import subprocess
td = TemporaryDirectory()
self.addCleanup(td.cleanup)
- rd = Path(td.name)
- banner = '> **Rig note.** 2 process(es) in D state at start.\n'
+ root = Path(td.name) / 'root'
+ (root / 'test' / 'hil').mkdir(parents=True)
+ (root / 'test' / 'hil' / 'hil_test.py').touch()
+ built = cfg_boards if cfg_boards is not None else boards
+ (root / 'examples').mkdir(parents=True, exist_ok=True)
+ for b in built:
+ (root / 'examples' / f'cmake-build-{b}').mkdir(parents=True)
+ roster = [{'name': b} for b in boards]
+ for entry in roster:
+ for v in (variants or {}).get(entry['name'], []):
+ entry.setdefault('variant', []).append({'name': v})
+ cfg = root / 'test' / 'hil' / 'cfg.json'
+ cfg.write_text(json.dumps({'boards': roster}))
+ stubs = Path(td.name) / 'bin'
+ stubs.mkdir()
+ # real ssh/scp/rsync would reach the rig; these just record the argv
+ for tool in ('ssh', 'scp', 'rsync'):
+ write_script(stubs / tool, f'echo "stub-{tool} $*" >&2; exit 0')
+ env = {**os.environ, 'REMOTE': 'stub', 'ROOT_DIR': str(root), 'CONFIG': str(cfg),
+ 'PATH': str(stubs) + os.pathsep + os.environ['PATH']}
+ args = [a for b in boards for a in ('-b', b)]
+ r = subprocess.run(['bash', str(Path(TEST_DIR).parents[0] / 'hil_ci.sh'), *args],
+ capture_output=True, text=True, timeout=60, env=env)
+ r.rsyncs = [l for l in r.stderr.splitlines() if l.startswith('stub-rsync')]
+ # the RUN ssh is the one carrying hil_test.py's args; the setup ssh is not
+ r.run_lines = [l for l in r.stderr.splitlines() if '--retry 1' in l]
+ return r
- hil_test.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', banner)
- self.assertIn('Rig note', (rd / hil_test.REPORT_MD).read_text())
+ def test_binaries_for_every_requested_board_are_copied(self):
+ r = self._run(['alpha', 'beta', 'gamma'])
+ self.assertEqual(r.returncode, 0, r.stderr)
+ for b in ('alpha', 'beta', 'gamma'):
+ self.assertTrue(any(f'cmake-build-{b} ' in l for l in r.rsyncs),
+ f'{b} binaries never staged: {r.rsyncs}')
- # the rerun: clean rig, so this attempt contributes no banner of its own
- md = hil_test.accumulate_report(self._rows('boardB', 'cdc_msc'), rd, False, '', '')
- self.assertIn('boardA', md) # the earlier cells are kept ...
- self.assertIn('Rig note', md,
- 'the caveat the earlier cells were collected under was dropped')
+ def test_every_board_reaches_hil_test(self):
+ r = self._run(['alpha', 'beta'])
+ self.assertEqual(len(r.run_lines), 1, r.stderr)
+ self.assertIn('-b alpha', r.run_lines[0])
+ self.assertIn('-b beta', r.run_lines[0])
- def test_the_same_caveat_twice_is_not_stacked(self):
- td = TemporaryDirectory()
- self.addCleanup(td.cleanup)
- rd = Path(td.name)
- banner = '> **Rig note.** 2 process(es) in D state at start.\n'
- hil_test.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', banner)
- md = hil_test.accumulate_report(self._rows('boardB', 'cdc_msc'), rd, False, '', banner)
- self.assertEqual(md.count('Rig note'), 1)
+ def test_an_unbuilt_board_aborts_before_anything_is_staged(self):
+ """The discriminating case: the unbuilt board is FIRST. The pre-fix script kept only
+ the last -b, found it built, and ran happily while silently testing one board. It also
+ has to fail BEFORE staging -- the old in-loop check fired after the remote tree was
+ wiped and earlier boards were rsynced, costing a run and leaving a half-staged rig."""
+ r = self._run(['alpha', 'beta'], cfg_boards=['beta'])
+ self.assertNotEqual(r.returncode, 0, 'unbuilt first board was accepted')
+ self.assertIn('alpha', r.stdout + r.stderr)
+ self.assertEqual(r.rsyncs, [], f'staged despite an unbuilt board: {r.rsyncs}')
+ self.assertEqual(r.run_lines, [], 'reached the run despite an unbuilt board')
+ def test_a_board_whose_firmware_is_only_a_variant_dir_is_accepted(self):
+ """Variant names are not required to be prefixed with the board name, so a board can
+ own no `cmake-build-<board>` dir at all. A pre-flight that only globs the board name
+ rejects it and tells the user to build firmware that is already there."""
+ r = self._run(['alpha', 'beta'], cfg_boards=['alpha', 'odd-name-v'],
+ variants={'beta': ['odd-name-v']})
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertTrue(any('cmake-build-odd-name-v ' in l for l in r.rsyncs),
+ f"beta's variant dir never staged: {r.rsyncs}")
-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_all_unbuilt_boards_are_named_at_once(self):
+ """One build round should fix every complaint, so the guard reports the whole set."""
+ r = self._run(['alpha', 'beta', 'gamma'], cfg_boards=['beta'])
+ self.assertNotEqual(r.returncode, 0)
+ out = r.stdout + r.stderr
+ self.assertIn('alpha', out)
+ self.assertIn('gamma', out)
- 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')
+class StagingCoversEveryBoardForm(unittest.TestCase):
+ """hil_test.py declares `-b, --board` with action='append', so argparse accepts --board X,
+ --board=X and -bX too. Staging only the bare form sent boards to the rig with no firmware,
+ where every test logs `Skip (no binary)` and counts zero errors -- a green row for a board
+ that was never flashed. Also covers the roster check, which has to fire BEFORE the remote
+ tree is wiped, since hil_test.py rejects an unknown -b for the whole run."""
- 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."""
+ def _run(self, argv, built, roster=None, variants=None, env_extra=None, stale=None):
+ import json
+ import subprocess
td = TemporaryDirectory()
self.addCleanup(td.cleanup)
- rd = Path(td.name)
- wide = ('boardA', 1, ['device/cdc_msc'], [('boardA', {'cdc_msc': '❌'}, '2s')], 2.0, True)
- narrow = ('stuck', 1, [], None, 0) # what the timeout path builds
- hil_test._write_failed_spec(rd / 'x.failed', rd, [wide, narrow])
- md = hil_test.accumulate_report([wide], rd, True, '', hil_test._blind_note([wide]))
- self.assertIn('boardA', md)
- self.assertIn('not all verdicts are evidence', md.lower())
+ root = Path(td.name) / 'root'
+ (root / 'test' / 'hil').mkdir(parents=True)
+ (root / 'test' / 'hil' / 'hil_test.py').touch()
+ (root / 'examples').mkdir(parents=True, exist_ok=True)
+ for b in built:
+ (root / 'examples' / f'cmake-build-{b}').mkdir(parents=True)
+ entries = [{'name': b} for b in (roster if roster is not None else built)]
+ for e in entries:
+ for v in (variants or {}).get(e['name'], []):
+ e.setdefault('variant', []).append({'name': v})
+ cfg = root / 'test' / 'hil' / 'cfg.json'
+ cfg.write_text(json.dumps({'boards': entries}))
+ stubs = Path(td.name) / 'bin'
+ stubs.mkdir()
+ # ssh joins its argv into ONE string that the REMOTE shell re-splits, and feeds the
+ # heredoc on stdin. A stub that echoes "$*" hides exactly that, which is how a
+ # completely broken env-forwarding change once passed its own test -- so this stub
+ # re-splits like the real thing and reports the script body separately.
+ write_script(stubs / 'ssh', 'shift; printf "REMOTE-ARGV: %s\\n" "$*" >&2; '
+ 'body=$(cat); printf "REMOTE-BODY: %s\\n" "$body" >&2; exit 0')
+ for tool in ('scp', 'rsync'):
+ write_script(stubs / tool, f'echo "stub-{tool} $*" >&2; exit 0')
+ for name, content in (stale or {}).items():
+ (root / name).write_text(content)
+ env = {**os.environ, 'REMOTE': 'stub', 'ROOT_DIR': str(root), 'CONFIG': str(cfg),
+ 'PATH': str(stubs) + os.pathsep + os.environ['PATH'], **(env_extra or {})}
+ r = subprocess.run(['bash', str(Path(TEST_DIR).parents[0] / 'hil_ci.sh'), *argv],
+ capture_output=True, text=True, timeout=60, env=env)
+ r.rsyncs = [l for l in r.stderr.splitlines() if l.startswith('stub-rsync')]
+ r.run_lines = [l for l in r.stderr.splitlines() if '--retry 1' in l]
+ r.body = '\n'.join(l for l in r.stderr.splitlines() if l.startswith('REMOTE-BODY'))
+ r.stale_left = {name: (root / name).exists() for name in (stale or {})}
+ return r
- 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_long_board_forms_are_staged_and_only_that_board(self):
+ """Two boards are built so the pre-fix 'copy all built binaries' else-branch cannot
+ stage the right one by accident -- that is what made the first version of this test
+ pass against master while the feature was broken. -balpha is the glued short form
+ argparse resolves to --board alpha; unparsed it fell through to the all-boards branch
+ and silently staged everything built with no roster check."""
+ for argv in (['--board', 'alpha'], ['--board=alpha'], ['-balpha']):
+ with self.subTest(argv=argv):
+ r = self._run(argv, built=['alpha', 'beta'], roster=['alpha', 'beta'])
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertTrue(any('cmake-build-alpha ' in l for l in r.rsyncs),
+ f'{argv} never staged: {r.rsyncs}')
+ self.assertFalse(any('cmake-build-beta ' in l for l in r.rsyncs),
+ f'{argv} staged an unrequested board: {r.rsyncs}')
+
+ def test_board_test_flag_is_not_mistaken_for_a_board(self):
+ """-bt is hil_test.py's --board-test and is exactly what <config>.failed contains, so
+ a glued -b?* pattern turns the documented retry into 'not in the roster: t'."""
+ for argv in (['-b', 'alpha', '-bt', 'alpha:device/cdc_msc'],
+ ['-b', 'alpha', '-btalpha:device/cdc_msc']):
+ with self.subTest(argv=argv):
+ r = self._run(argv, built=['alpha'])
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertNotIn('not in', r.stderr)
+ self.assertTrue(any('alpha:device/cdc_msc' in l for l in r.run_lines),
+ f'-bt never reached the rig: {r.run_lines}')
+
+ def test_a_board_outside_the_roster_is_refused_before_staging(self):
+ r = self._run(['-b', 'alpha', '-b', 'ghost'], built=['alpha', 'ghost'], roster=['alpha'])
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('ghost', r.stdout + r.stderr)
+ self.assertEqual(r.rsyncs, [], 'staged despite an unknown board')
+ self.assertEqual(r.run_lines, [], 'reached the run despite an unknown board')
+
+ def test_a_variant_with_no_build_dir_warns_instead_of_passing_silently(self):
+ r = self._run(['-b', 'alpha'], built=['alpha'], variants={'alpha': ['alpha-DMA']})
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('alpha-DMA', r.stderr)
+ self.assertIn('skipped, not tested', r.stderr)
- 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)]), '')
+ def test_no_build_dirs_at_all_aborts_the_all_boards_form(self):
+ """`hil_ci.sh` with no -b stages everything built. With nothing built it used to wipe
+ the rig, stage nothing, and return a green all-skip table."""
+ r = self._run([], built=[], roster=['alpha'])
+ self.assertNotEqual(r.returncode, 0)
+ self.assertIn('nothing to test', r.stdout + r.stderr)
+ self.assertEqual(r.run_lines, [], 'reached the run with nothing staged')
+ self.assertNotIn('Setting up remote', r.stdout + r.stderr,
+ 'the guard fired only after the remote tree was already wiped')
+
+ def test_hil_env_reaches_the_rig_as_environment_not_argv(self):
+ """An authorized force is HIL_NO_BOARD_LOCK=1. Passed through ssh's argv it arrives as a
+ positional argument and argparse exits 2, so it has to travel in the script body."""
+ r = self._run(['-b', 'alpha'], built=['alpha'], env_extra={'HIL_NO_BOARD_LOCK': '1'})
+ self.assertEqual(r.returncode, 0, r.stderr)
+ # one %q-quoted word of `export NAME=value; ` fragments, evaluated by the remote —
+ # NOT a bare NAME=value element, which hil_test.py's argparse takes as a positional.
+ # %q backslash-escapes the spaces, so match the pieces rather than the plain phrase.
+ run = '\n'.join(r.run_lines)
+ self.assertIn('HIL_NO_BOARD_LOCK=1', run)
+ self.assertIn('export', run)
+ self.assertFalse(any(' HIL_NO_BOARD_LOCK=1 ' in l for l in r.run_lines),
+ 'env reached argv unquoted, where hil_test.py sees a positional')
+
+ def test_a_value_with_spaces_survives_forwarding(self):
+ r = self._run(['-b', 'alpha'], built=['alpha'],
+ env_extra={'HIL_SCRATCH': '/tmp/my scratch'})
+ self.assertEqual(r.returncode, 0, r.stderr)
+ run = '\n'.join(r.run_lines)
+ self.assertIn('HIL_SCRATCH', run)
+ self.assertIn('scratch', run)
+
+ def test_hil_report_dir_is_never_forwarded(self):
+ """Where the report lands on the rig is this script's contract (REMOTE_DIR, where all
+ three copy-backs look); forwarding a local HIL_REPORT_DIR relocates it there and every
+ copy-back comes home empty -- two of the three silently."""
+ r = self._run(['-b', 'alpha'], built=['alpha'],
+ env_extra={'HIL_REPORT_DIR': '/tmp/elsewhere'})
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertFalse(any('HIL_REPORT_DIR' in l for l in r.run_lines),
+ f'HIL_REPORT_DIR reached the rig: {r.run_lines}')
+
+ def test_a_stale_local_failed_spec_does_not_survive_a_green_run(self):
+ """A green run writes no .failed on the rig, so the copy-back scp no-ops; the local
+ spec from a previous FAILED run must not survive it looking current -- a later
+ "retry from the spec" would re-flash boards that already passed."""
+ r = self._run(['-b', 'alpha'], built=['alpha'],
+ stale={'cfg.json.failed': '--accumulate -b alpha'})
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertFalse(r.stale_left['cfg.json.failed'],
+ "last run's re-run spec survived a green run")
class PoolGuardKeepsWhatFinished(unittest.TestCase):
@@ -1606,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
@@ -1655,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)."""
@@ -1697,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 5695cad6d..ceecc8d37 100644
--- a/test/hil/test/test_hil_health.py
+++ b/test/hil/test/test_hil_health.py
@@ -467,49 +467,6 @@ class KillPoolChildren(PatchCase):
self.assertEqual(hil_health.kill_pool_children(NoPool()), 0)
-class WriteTimeoutReport(unittest.TestCase):
- def test_prefix_carries_the_preflight_diagnosis(self):
- """The timeout aborts before accumulate_report, so without the prefix the artifact
- and the PR comment lose the one line saying WHY the pool never finished."""
- with TemporaryDirectory() as td:
- d = Path(td)
- hil_health.write_timeout_report(d, [{'name': 'b1'}], 4200, 'r.md',
- prefix='> **wedged usb_hub_wq worker.**\n')
- out = (d / 'r.md').read_text()
- self.assertTrue(out.startswith('> **wedged usb_hub_wq worker.**'))
- self.assertIn('timed out after 4200s', out)
- self.assertIn('- b1', out)
-
- def test_writes_a_report_where_there_would_be_none(self):
- with TemporaryDirectory() as td:
- hil_health.write_timeout_report(Path(td), [{'name': 'ra6m5_ek'}], 4200,
- 'hil_report.md')
- md = (Path(td) / 'hil_report.md').read_text()
- self.assertIn('4200s', md)
- self.assertIn('ra6m5_ek', md)
-
- def test_keeps_a_previous_attempts_table(self):
- with TemporaryDirectory() as td:
- path = Path(td) / 'hil_report.md'
- path.write_text('| board | cdc_msc |\n')
- hil_health.write_timeout_report(Path(td), [{'name': 'b1'}], 4200, 'hil_report.md')
- md = path.read_text()
- self.assertIn('abandoned', md)
- self.assertIn('| board | cdc_msc |', md)
- self.assertLess(md.index('abandoned'), md.index('| board |'))
-
- def test_custom_banner_is_used(self):
- with TemporaryDirectory() as td:
- hil_health.write_timeout_report(Path(td), [], 0, 'hil_report.md',
- banner='**refused to start.**\n')
- self.assertIn('refused to start', (Path(td) / 'hil_report.md').read_text())
-
- def test_unwritable_dir_does_not_raise(self):
- """The caller may be about to os._exit; losing the report must not also lose the
- exit path."""
- hil_health.write_timeout_report(Path('/proc/nonexistent/nope'), [], 0, 'x.md')
-
-
class WorkerSweepsItsOwnChildren(unittest.TestCase):
"""maxtasksperchild=1 makes a worker exit the moment its task returns, so by the time
main()'s finally sweeps, the strays have been reparented to init and are off the pool's
@@ -558,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
new file mode 100644
index 000000000..7c7a097ef
--- /dev/null
+++ b/test/hil/test/test_hil_report.py
@@ -0,0 +1,1173 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for the report document: the vocabulary, the one cell classifier, rendering,
+# the four writers, and the fold to per-board verdicts. Split out of test_hil_bounded.py
+# and test_hil_health.py when the report code moved into helper/hil_report.py.
+# Run directly:
+# python3 test/hil/test/test_hil_report.py
+import json
+import os
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+TEST_DIR = os.path.dirname(os.path.abspath(__file__))
+HIL_DIR = os.path.dirname(TEST_DIR)
+sys.path.insert(0, HIL_DIR)
+
+from helper import hil_report
+
+
+class OneClassifierForBothArtifacts(unittest.TestCase):
+ """The markdown tally and the agent's verdict used to classify cells with two separate
+ copies of one rule -- hil_test's cell_kind against REPORT_CELL, and hil_summary's
+ cell_state against its own re-typed '❌'/'⚪' literals. Change the icons and the table
+ and the verdict silently disagree."""
+
+ def test_bare_states(self):
+ self.assertEqual(hil_report.cell_state('fail'), 'fail')
+ self.assertEqual(hil_report.cell_state('skip'), 'skip')
+ self.assertEqual(hil_report.cell_state('pass'), 'pass')
+
+ def test_icon_prefixed_metrics_carry_their_verdict(self):
+ self.assertEqual(hil_report.cell_state(f'{hil_report.REPORT_CELL["fail"]} 29/30'), 'fail')
+ self.assertEqual(hil_report.cell_state(f'{hil_report.REPORT_CELL["skip"]} board wedged'),
+ 'skip')
+
+ def test_an_unprefixed_metric_is_a_pass(self):
+ """Load-bearing: a passing test may return a plain metric string. Classifying
+ unknown shapes as fail would publish a green table as a red verdict."""
+ self.assertEqual(hil_report.cell_state('480.0 MBps'), 'pass')
+ self.assertEqual(hil_report.cell_state('1103 KB/s'), 'pass')
+
+ def test_a_non_string_cell_does_not_raise(self):
+ """render_matrix's copy guarded with isinstance; hil_summary's did not, because its
+ caller str()'d first. The merged one keeps the guard -- it is the safer superset."""
+ self.assertEqual(hil_report.cell_state(None), 'pass')
+
+ def test_the_icons_come_from_REPORT_CELL(self):
+ """No second copy of the emoji anywhere in the module."""
+ src = (Path(HIL_DIR) / 'helper' / 'hil_report.py').read_text(encoding='utf-8')
+ # CODE only: prose may quote an icon to explain a rule. The old assertion counted
+ # the single-quoted spelling `'❌'`, which a second copy written as "❌" would have
+ # sailed past.
+ code = '\n'.join(line.split('#', 1)[0] for line in src.splitlines())
+ for icon in ('❌', '⚪', '✅'):
+ self.assertEqual(code.count(icon), 1,
+ f'{icon} is spelled in code more than once; REPORT_CELL is'
+ f' meant to be the one source')
+
+
+class ModuleWorksImportedAndAsAScript(unittest.TestCase):
+ """It is imported as helper.hil_report by hil_test, and run as a script by the operator
+ (.claude/agents/hil-operator.md). A script run puts helper/ on sys.path, NOT test/hil,
+ so a plain `from helper import hil_health` breaks the CLI and only the CLI."""
+
+ def test_importable_as_a_package_module(self):
+ r = subprocess.run(
+ [sys.executable, '-c',
+ f'import sys; sys.path.insert(0, {HIL_DIR!r}); '
+ f'from helper import hil_report; print(hil_report.REPORT_JSON)'],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('hil_report.json', r.stdout)
+
+ def test_runnable_as_a_script(self):
+ r = subprocess.run(
+ [sys.executable, str(Path(HIL_DIR) / 'helper' / 'hil_report.py'), '--help'],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+
+class RenderReportIsPureFunctionOfTheDocument(unittest.TestCase):
+ """Four writers used to compose the markdown independently, so a table could carry
+ something the sidecar did not. One renderer, and the ordering it guarantees, is what
+ stops that -- pinned here rather than left to the order of three concatenations."""
+
+ def _doc(self, **kw):
+ d = {'rows': [{'board': 'boardA', 'cells': {'cdc_msc': 'pass'}, 'duration': '1s'}],
+ 'banner': '', 'scope': '', 'caveat': ''}
+ d.update(kw)
+ return d
+
+ def test_table_comes_from_rows(self):
+ md = hil_report.render_report(self._doc())
+ self.assertIn('boardA', md)
+ self.assertIn('cdc_msc', md)
+
+ def test_scope_note_appears_above_the_table(self):
+ md = hil_report.render_report(self._doc(scope='-b boardA'))
+ self.assertLess(md.index('Scoped run'), md.index('boardA'))
+
+ def test_banner_outranks_the_scope_note(self):
+ md = hil_report.render_report(self._doc(scope='-b boardA',
+ banner='> **Rig dirty.** x\n'))
+ self.assertLess(md.index('Rig dirty'), md.index('Scoped run'))
+
+ def test_caveat_is_outermost(self):
+ md = hil_report.render_report(self._doc(banner='> **Rig dirty.** x\n',
+ caveat='**HIL run abandoned.**\n'))
+ self.assertLess(md.index('abandoned'), md.index('Rig dirty'))
+
+ def test_a_document_with_no_rows_still_renders(self):
+ md = hil_report.render_report(self._doc(rows=[]))
+ self.assertIn('No tests were run.', md)
+
+ def test_a_malformed_row_does_not_raise(self):
+ """mark_report_abandoned renders a sidecar it did not write -- hil_ci.sh reuses a
+ persistent REMOTE_DIR, so it can be an older version's or a torn one -- and it runs
+ on the way to os._exit, where a KeyError hangs the runner in multiprocessing's
+ unbounded join() instead of freeing it."""
+ md = hil_report.render_report(self._doc(
+ rows=[{'board': 'boardA', 'cells': {'cdc_msc': 'pass'}}, {'board': 'half'},
+ {}]))
+ self.assertIn('boardA', md) # the intact row still renders ...
+ self.assertIn('half', md) # ... and a cell-less one becomes a blank row
+
+
+class ScopeSurvivesInTheJson(unittest.TestCase):
+ """A scoped run's small table is indistinguishable from a full run that lost boards.
+ The markdown says so; the JSON did not, so any JSON consumer could not tell."""
+
+ def _rows(self, board, cell):
+ return [(board, 0, 0, [(board, {cell: 'OK'}, '1s')], 0)]
+
+ def test_scope_is_recorded_in_the_sidecar(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True,
+ '-b boardA', '')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual(doc['scope'], '-b boardA')
+
+ def test_an_unscoped_run_records_an_empty_scope(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', '')
+ self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text())['scope'], '')
+
+
+class EveryExitPathLeavesBothArtifacts(unittest.TestCase):
+ """summarize() builds an agent's verdicts from the JSON. A path that writes only
+ markdown reports the whole fleet as 'no report row' while a human sees the real story."""
+
+ def test_the_no_boards_exit_writes_json_too(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_report(rd, {'rows': [], 'banner': '', 'scope': '',
+ 'caveat': '**HIL run selected no boards.** why\n'})
+ self.assertIn('selected no boards', (rd / hil_report.REPORT_MD).read_text())
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual(doc['rows'], [])
+ self.assertIn('selected no boards', doc['caveat'])
+
+ def test_write_report_raises_so_its_callers_can_report_it(self):
+ """write_report is NOT best-effort. Swallowing the OSError made
+ write_timeout_report's _p warning and hil_test's fallback-of-the-fallback dead
+ code -- an unwritable report dir produced no artifact and no message."""
+ with self.assertRaises(OSError):
+ hil_report.write_report(Path('/proc/nonexistent/nope'),
+ {'rows': [], 'banner': '', 'scope': '', 'caveat': 'x\n'})
+
+ def test_the_guarded_callers_still_do_not_raise(self):
+ """They are the ones on the way to os._exit, where a raise hangs the interpreter
+ in multiprocessing's unbounded join()."""
+ bad = Path('/proc/nonexistent/nope')
+ hil_report.mark_report_abandoned(bad, 'the worker pool would not shut down.')
+ hil_report.mark_report_no_boards(bad, 'filters intersected to nothing')
+ import io
+ from contextlib import redirect_stdout
+ with redirect_stdout(io.StringIO()):
+ hil_report.write_timeout_report(bad, [{'name': 'b1'}], 3600)
+
+
+class AbandonNoticeLandsInBothArtifacts(unittest.TestCase):
+ """_abandon_exit did a text prepend on a file it had not written, so the caveat never
+ reached the JSON and an agent reading the sidecar saw a clean partial report under a
+ red job."""
+
+ def test_abandon_sets_the_caveat_not_just_the_markdown(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('boardA', 0, 0, [('boardA', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertIn('abandoned', doc['caveat'])
+ self.assertEqual(len(doc['rows']), 1, 'the finished board must survive')
+ md = (rd / hil_report.REPORT_MD).read_text()
+ self.assertLess(md.index('abandoned'), md.index('boardA'))
+
+ def test_marking_a_missing_report_is_a_no_op(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ hil_report.mark_report_abandoned(Path(td.name), 'x') # must not raise
+
+ def test_a_sidecar_with_a_malformed_row_still_gets_stamped(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(
+ {'rows': [{'board': 'boardA'}], 'banner': '', 'scope': '', 'caveat': ''}))
+ hil_report.mark_report_abandoned(rd, 'x')
+ self.assertIn('abandoned',
+ json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat'])
+ self.assertIn('abandoned', (rd / hil_report.REPORT_MD).read_text())
+
+ def test_a_torn_sidecar_is_a_no_op(self):
+ """This runs while the interpreter is being torn down: a raise here hangs the
+ process in multiprocessing's unbounded join()."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text('{ truncated mid-')
+ hil_report.mark_report_abandoned(rd, 'x') # must not raise
+
+ def test_an_existing_abandon_caveat_is_not_overwritten(self):
+ """The pool-timeout path names the stuck boards and the rig-health verdict; this
+ one only knows the pool would not shut down. Whoever got there first wins --
+ the guard _abandon_exit used to spell as "'**HIL run ab' not in body[:2000]"."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600,
+ prefix='> **wedged usb_hub_wq worker.**\n')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertIn('timed out after 3600s', doc['caveat'])
+ self.assertIn('wedged usb_hub_wq worker', doc['banner']) # rig health, not outcome
+ self.assertNotIn('would not shut down', doc['caveat'])
+
+
+class CaveatSurvivesAccumulate(unittest.TestCase):
+ """CI reruns with --accumulate: the sidecar keeps every earlier attempt's cells, but the
+ banner was recomputed per attempt. A first attempt on a degraded rig and a clean rerun
+ therefore published the degraded attempt's PASSES with no caveat on them -- and the
+ generated .failed spec reruns only failures, so those cells are never re-earned."""
+
+ def _rows(self, board, cell):
+ return [(board, 0, 0, [(board, {cell: 'OK'}, '1s')], 0)]
+
+ def test_an_earlier_attempts_caveat_is_still_on_the_report(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ banner = '> **Rig note.** 2 process(es) in D state at start.\n'
+
+ hil_report.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', banner)
+ self.assertIn('Rig note', (rd / hil_report.REPORT_MD).read_text())
+
+ # the rerun: clean rig, so this attempt contributes no banner of its own
+ md = hil_report.accumulate_report(self._rows('boardB', 'cdc_msc'), rd, False, '', '')
+ self.assertIn('boardA', md) # the earlier cells are kept ...
+ self.assertIn('Rig note', md,
+ 'the caveat the earlier cells were collected under was dropped')
+
+ def test_the_same_caveat_twice_is_not_stacked(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ banner = '> **Rig note.** 2 process(es) in D state at start.\n'
+ hil_report.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', banner)
+ md = hil_report.accumulate_report(self._rows('boardB', 'cdc_msc'), rd, False, '', banner)
+ self.assertEqual(md.count('Rig note'), 1)
+
+
+class MarkdownIsAlwaysARenderingOfTheJson(unittest.TestCase):
+ """The property this whole change buys: whatever wrote the report, re-rendering the
+ sidecar reproduces the markdown byte for byte. Four writers, one renderer -- asserted
+ directly rather than inferred from the writers."""
+
+ def _check(self, rd):
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual((rd / hil_report.REPORT_MD).read_text(),
+ hil_report.render_report(doc) + '\n')
+
+ def test_normal_path(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('boardA', 0, 0, [('boardA', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True,
+ '-b boardA', '> **Rig note.** x\n')
+ self._check(rd)
+
+ def test_after_an_accumulate_rerun(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('boardA', 0, 0, [('boardA', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.accumulate_report(
+ [('boardB', 0, 0, [('boardB', {'cdc_msc': 'OK'}, '1s')], 0)], rd, False, '', '')
+ self._check(rd)
+
+ def test_after_abandonment(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('boardA', 0, 0, [('boardA', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ self._check(rd)
+
+ def test_no_boards_exit(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_report(rd, {'rows': [], 'banner': '', 'scope': '',
+ 'caveat': '**HIL run selected no boards.** why\n'})
+ self._check(rd)
+
+ def test_the_pool_guard_fallback(self):
+ """The last writer to join the invariant: it composed its own markdown only because
+ hil_health could not import the renderer."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('done', 0, 0, [('done', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600,
+ prefix='> **wedged usb_hub_wq worker.**\n')
+ self._check(rd)
+
+class WriteTimeoutReport(unittest.TestCase):
+ def test_prefix_carries_the_preflight_diagnosis(self):
+ """The timeout aborts before accumulate_report, so without the prefix the artifact
+ and the PR comment lose the one line saying WHY the pool never finished."""
+ with TemporaryDirectory() as td:
+ d = Path(td)
+ hil_report.write_timeout_report(d, [{'name': 'b1'}], 4200,
+ prefix='> **wedged usb_hub_wq worker.**\n')
+ out = (d / hil_report.REPORT_MD).read_text()
+ # the abandon notice leads (run outcome), the rig-health prefix follows in the
+ # banner -- prefix used to be folded INTO the caveat, which is what made a clean
+ # --accumulate retry inherit an abandonment that had not happened
+ self.assertTrue(out.startswith('**HIL run abandoned: worker pool timed out'), out[:80])
+ self.assertIn('> **wedged usb_hub_wq worker.**', out)
+ self.assertIn('timed out after 4200s', out)
+ self.assertIn('- b1', out)
+
+ def test_writes_a_report_where_there_would_be_none(self):
+ with TemporaryDirectory() as td:
+ hil_report.write_timeout_report(Path(td), [{'name': 'ra6m5_ek'}], 4200)
+ md = (Path(td) / hil_report.REPORT_MD).read_text()
+ self.assertIn('4200s', md)
+ self.assertIn('ra6m5_ek', md)
+
+ def test_the_prior_attempts_rows_survive(self):
+ """Was: the prior MARKDOWN TEXT survives below the banner. It now re-renders from
+ the merged sidecar, so the guarantee is stated against rows -- one table with the
+ stuck boards in it, rather than a banner stapled above a duplicate table."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('done', 0, 0, [('done', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual([r['board'] for r in doc['rows']], ['done', 'stuck'])
+ md = (rd / hil_report.REPORT_MD).read_text()
+ self.assertIn('done', md)
+ self.assertIn('stuck', md)
+ self.assertIn('abandoned', md)
+ self.assertLess(md.index('abandoned'), md.index('done'))
+ self.assertEqual(md.count('| Board'), 1, 'the prior table was duplicated, not merged')
+
+ def test_custom_banner_is_used(self):
+ with TemporaryDirectory() as td:
+ hil_report.write_timeout_report(Path(td), [], 0,
+ banner='**refused to start.**\n')
+ self.assertIn('refused to start', (Path(td) / hil_report.REPORT_MD).read_text())
+
+ def test_timeout_report_writes_the_sidecar(self):
+ """This path used to write markdown only, so summarize() -- which is all an
+ agent gets -- reported the whole fleet as 'no report row' on exactly the runs
+ that failed."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_timeout_report(rd, [{'name': 'boardA'}], 3600)
+ self.assertTrue((rd / hil_report.REPORT_JSON).is_file())
+ self.assertIn('boardA', (rd / hil_report.REPORT_JSON).read_text())
+
+ def test_the_sidecar_keeps_a_previous_attempts_rows(self):
+ """An earlier attempt's finished boards are real results and this attempt has none
+ of its own, so the rows merge rather than replace."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(
+ {'rows': [{'board': 'done', 'cells': {'cdc_msc': 'pass'}, 'duration': '1s'}],
+ 'banner': '', 'scope': '', 'caveat': ''}))
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600)
+ rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows']
+ self.assertEqual([r['board'] for r in rows], ['done', 'stuck'])
+
+ def test_a_torn_sidecar_does_not_lose_the_stuck_boards(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text('{ truncated mid-')
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600)
+ rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows']
+ self.assertEqual([r['board'] for r in rows], ['stuck'])
+
+ def test_a_roster_entry_without_a_name_does_not_escape(self):
+ """The broad handler exists to stop a KeyError here stranding the runner, but a
+ report that silently loses its only board is worse than one saying '?'."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_timeout_report(rd, [{}], 3600)
+ rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows']
+ self.assertEqual([r['board'] for r in rows], ['?'])
+
+ def test_unwritable_dir_does_not_raise(self):
+ """The caller may be about to os._exit; losing the report must not also lose the
+ exit path."""
+ hil_report.write_timeout_report(Path('/proc/nonexistent/nope'), [], 0)
+
+
+class SummaryFoldsReportToBoards(unittest.TestCase):
+ """summarize() replaces the agent retyping the markdown table. Report rows are named per
+ VARIANT and a variant need not start with the board name, so the config is what maps them
+ back -- the previous string-matching design produced a defect in each of four review rounds."""
+
+ def _sum(self, boards, rows, cfg_boards=None, banner=''):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ d = Path(td.name)
+ (d / 'hil_report.json').write_text(json.dumps(
+ {'rows': [{'board': b, 'cells': c, 'duration': '1s'} for b, c in rows],
+ 'banner': banner}))
+ cfg = d / 'cfg.json'
+ cfg.write_text(json.dumps({'boards': cfg_boards or [{'name': b} for b in boards]}))
+ args = [a for b in boards for a in ('-b', b)]
+ r = subprocess.run(['python3', str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'),
+ str(cfg), *args, '--report-dir', str(d)],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ return json.loads(r.stdout)['results']
+
+ def test_variant_rows_fold_onto_their_board(self):
+ """nanoch32v203 never produces a row named after the board."""
+ got = self._sum(['nanoch32v203'],
+ [('nanoch32v203-fsdev', {'usbtest': 'pass'}),
+ ('nanoch32v203-usbfs', {'usbtest': 'pass'})],
+ cfg_boards=[{'name': 'nanoch32v203',
+ 'variant': [{'name': 'nanoch32v203-fsdev'},
+ {'name': 'nanoch32v203-usbfs'}]}])
+ self.assertEqual([r['board'] for r in got], ['nanoch32v203'])
+ self.assertTrue(got[0]['pass'])
+ self.assertTrue(got[0]['ran'])
+
+ def test_one_failing_variant_fails_the_board(self):
+ got = self._sum(['nano'],
+ [('nano-a', {'usbtest': 'pass'}), ('nano-b', {'usbtest': '❌ 29/30'})],
+ cfg_boards=[{'name': 'nano', 'variant': [{'name': 'nano-a'},
+ {'name': 'nano-b'}]}])
+ self.assertFalse(got[0]['pass'])
+ self.assertIn('29/30', got[0]['detail'])
+
+ def test_lock_contention_is_a_field_not_a_prefix(self):
+ got = self._sum(['alpha'], [('alpha', {'board-locked': 'fail'})])
+ self.assertTrue(got[0]['locked'])
+ self.assertFalse(got[0]['pass'])
+
+ def test_a_board_with_no_row_is_marked_not_run(self):
+ got = self._sum(['alpha', 'beta'], [('alpha', {'usbtest': 'pass'})])
+ self.assertTrue(got[0]['ran'])
+ self.assertFalse(got[1]['ran'])
+ self.assertFalse(got[1]['pass'])
+
+ def test_a_metric_cell_counts_by_its_icon(self):
+ got = self._sum(['a', 'b'], [('a', {'cdc_msc_throughput': '✅ C 1.2 M 3.4'}),
+ ('b', {'cdc_msc_throughput': '❌ C 0.0 M 0.0'})])
+ self.assertTrue(got[0]['pass'])
+ self.assertFalse(got[1]['pass'])
+
+ def test_skipped_cells_do_not_fail_a_board(self):
+ got = self._sum(['a'], [('a', {'usbtest': 'skip', 'cdc_msc': 'pass'})])
+ self.assertTrue(got[0]['pass'])
+
+ def test_a_plain_metric_cell_is_a_pass(self):
+ """Mirrors hil_test.py's own tally (cell_kind): failures are ALWAYS marked -- 'fail'
+ or a ❌ prefix, per TestFail's docstring -- while a passing test may return a plain
+ metric string that lands in the cell unprefixed. Treating unknown shapes as fail
+ would publish a green table as a red verdict."""
+ got = self._sum(['a'], [('a', {'device_speed': '480.0 MBps'})])
+ self.assertTrue(got[0]['pass'])
+
+ def test_a_declared_variant_of_another_board_is_not_stolen(self):
+ """A declared variant need not start with its own board's name, so it may start with
+ a DIFFERENT board's name plus '-'. The prefix fallback must not attribute it twice."""
+ got = self._sum(['alpha', 'beta'],
+ [('beta-x', {'usbtest': 'fail'})],
+ cfg_boards=[{'name': 'alpha', 'variant': [{'name': 'beta-x'}]},
+ {'name': 'beta'}])
+ self.assertTrue(got[0]['ran'])
+ self.assertFalse(got[0]['pass'])
+ self.assertFalse(got[1]['ran'], "beta must not inherit alpha's row")
+
+
+ def test_the_caveat_reaches_the_agents_verdict(self):
+ """The abandon/no-boards notice lives in the document now, and this JSON is all an
+ agent gets -- dropping it here puts the caveat back where only a human sees it."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ d = Path(td.name)
+ (d / 'hil_report.json').write_text(json.dumps(
+ {'rows': [{'board': 'boardA', 'cells': {'cdc_msc': 'pass'}, 'duration': '1s'}],
+ 'banner': '', 'scope': '',
+ 'caveat': '**HIL run abandoned: the worker pool would not shut down.**\n'}))
+ cfg = d / 'cfg.json'
+ cfg.write_text(json.dumps({'boards': [{'name': 'boardA'}]}))
+ r = subprocess.run(
+ ['python3', str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'),
+ str(cfg), '-b', 'boardA', '--report-dir', str(d)],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('abandoned', json.loads(r.stdout)['caveat'])
+
+ def test_an_older_sidecar_without_a_caveat_still_summarises(self):
+ got = self._sum(['boardA'], [('boardA', {'cdc_msc': 'pass'})])
+ self.assertTrue(got[0]['pass'])
+
+ def test_the_old_entry_point_is_gone(self):
+ """hil_summary.py's CLI moved here. A leftover file would keep working while
+ drifting from the module that now owns the fold."""
+ self.assertFalse((Path(HIL_DIR) / 'helper' / 'hil_summary.py').exists())
+
+
+class AbandonStampIsNotDestructive(unittest.TestCase):
+ """mark_report_abandoned runs on the way to os._exit, on a report it did not write.
+ Every case here was a live regression found by review."""
+
+ def _doc(self, **kw):
+ d = {'rows': [{'board': 'OLD', 'cells': {'t': 'pass'}, 'duration': '9s'}],
+ 'banner': '', 'scope': '', 'caveat': ''}
+ d.update(kw)
+ return d
+
+ def test_declining_to_stamp_does_not_republish_the_markdown(self):
+ """The guard skipped the caveat assignment but write_report ran anyway, so a
+ no-op call still overwrote THIS run's table with a re-render of an older sidecar."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(self._doc(
+ caveat='**HIL run abandoned: worker pool timed out after 3600s.**\n')))
+ (rd / hil_report.REPORT_MD).write_text('THIS RUN table with boardX\n')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ self.assertEqual((rd / hil_report.REPORT_MD).read_text(),
+ 'THIS RUN table with boardX\n')
+
+ def test_a_banner_borne_abandon_notice_also_wins(self):
+ """The pool-timeout path puts its notice in `banner` (hil_test.py:2300), not
+ `caveat`. SKILL.md gives the two notices OPPOSITE rules, so stamping the vaguer
+ one on top tells the agent to publish rows it is meant to discard."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)], rd, True, '', '',
+ caveat='**HIL run abandoned: worker pool timed out after 3600s.** 2 never'
+ ' reported.\n')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ md = (rd / hil_report.REPORT_MD).read_text()
+ self.assertTrue(md.startswith('**HIL run abandoned: worker pool timed out'), md[:80])
+ self.assertNotIn('would not shut down', md)
+
+ def test_a_missing_sidecar_still_stamps_the_markdown(self):
+ """Master read the MARKDOWN and prepended unconditionally, so it always stamped.
+ pr_comment.yml cats only hil_report.md -- giving up here publishes a clean green
+ table under an abandoned, non-zero job."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_MD).write_text('**✅ 27 passed · ❌ 0 failed**\n')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ md = (rd / hil_report.REPORT_MD).read_text(encoding='utf-8')
+ self.assertIn('abandoned', md)
+ self.assertIn('27 passed', md)
+
+ def test_a_torn_sidecar_still_stamps_the_markdown(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text('{ truncated mid-')
+ (rd / hil_report.REPORT_MD).write_text('**✅ 27 passed**\n')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ self.assertIn('abandoned', (rd / hil_report.REPORT_MD).read_text(encoding='utf-8'))
+
+ def test_the_wording_matches_the_skill_contract(self):
+ """SKILL.md pins this banner as 'the table below IS this run's ... Report the
+ results AND the abandonment'. Calling it 'partial' sends the agent to re-run
+ boards that already passed."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(self._doc()))
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ caveat = json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat']
+ self.assertNotIn('partial', caveat)
+ self.assertIn('unverified', caveat)
+
+
+class WriteReportFailsLoudly(unittest.TestCase):
+ def test_a_render_failure_does_not_leave_a_committed_json(self):
+ """It wrote the JSON, then rendered. A render raise left the sidecar saying
+ 'abandoned' beside a markdown that still read as a clean green table -- breaking
+ the one invariant this module exists to hold."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_MD).write_text('STALE GREEN TABLE\n')
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(
+ {'rows': ['boardA'], 'banner': '', 'scope': '', 'caveat': ''}))
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ md = (rd / hil_report.REPORT_MD).read_text()
+ # either both moved or neither did -- never a sidecar the markdown contradicts
+ self.assertEqual('abandoned' in doc.get('caveat', ''), 'abandoned' in md,
+ 'the sidecar was committed without its markdown')
+
+ def test_a_non_dict_row_does_not_cost_the_abandon_stamp(self):
+ """A row that is a bare string raised out of render_report, so the stamp was lost
+ entirely -- the failure mode this whole function exists to prevent."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(
+ {'rows': ['boardA', {'board': 'good', 'cells': {'t': 'pass'}}],
+ 'banner': '', 'scope': '', 'caveat': ''}))
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ md = (rd / hil_report.REPORT_MD).read_text()
+ self.assertIn('abandoned', md)
+ self.assertIn('good', md)
+
+ def test_an_unwritable_dir_reaches_the_callers_warning(self):
+ """write_report swallowing OSError made write_timeout_report's broad handler --
+ and hil_test's fallback-of-the-fallback -- dead code: no artifact, no message."""
+ import io
+ from contextlib import redirect_stdout
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ hil_report.write_timeout_report(Path('/proc/nonexistent/nope'),
+ [{'name': 'b1'}], 3600, prefix='x\n')
+ self.assertIn('warning', buf.getvalue().lower(), 'the failure was silent')
+
+
+class PoolTimeoutCellIsHonest(unittest.TestCase):
+ def test_a_stuck_board_with_a_prior_row_still_gets_the_cell(self):
+ """`not in done` skipped the cell for any board carrying an earlier attempt's row,
+ so a board that just ate the 60-minute guard summarized as pass:true."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('stm32f4', 0, 0, [('stm32f4', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.write_timeout_report(rd, [{'name': 'stm32f4'}], 3600)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ verdict = hil_report.summarize({'boards': [{'name': 'stm32f4'}]}, ['stm32f4'], doc)
+ self.assertFalse(verdict['results'][0]['pass'],
+ 'a board that hung the pool was published as a pass')
+
+ def test_a_clean_retry_clears_the_cell(self):
+ """accumulate_report clears stale board-locked and BOUNDARY_CELL cells but not
+ this one, so a board that passed clean on the retry stayed red forever."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600)
+ hil_report.accumulate_report(
+ [('stuck', 0, 0, [('stuck', {'cdc_msc': 'OK'}, '2s')], 0)], rd, False, '', '')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertNotIn('pool-timeout', doc['rows'][0]['cells'])
+ verdict = hil_report.summarize({'boards': [{'name': 'stuck'}]}, ['stuck'], doc)
+ self.assertTrue(verdict['results'][0]['pass'])
+
+ def test_a_torn_sidecar_does_not_destroy_an_intact_markdown(self):
+ """Re-rendering from an unusable sidecar threw away real results the human copy
+ still had. Master concatenated below its banner and kept them."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n| b | OK |\n')
+ (rd / hil_report.REPORT_JSON).write_text('{ truncated')
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600)
+ md = (rd / hil_report.REPORT_MD).read_text()
+ self.assertIn('| a | OK |', md, "an earlier attempt's real results were destroyed")
+ self.assertIn('stuck', md)
+
+
+class SummarizeSeesEveryRow(unittest.TestCase):
+ def test_board_name_rows_reach_a_variant_boards_verdict(self):
+ """hil_test writes lock-contention and pool-timeout rows keyed by BOARD name, but
+ variants_of returns only declared variant names -- so for nanoch32v203 and
+ ch32v307v_r1_1v0 those rows were invisible and a held lock published as a
+ hardware FAIL that hil-validate.js never retried."""
+ cfg = {'boards': [{'name': 'nano',
+ 'variant': [{'name': 'nano-fsdev'}, {'name': 'nano-usbfs'}]}]}
+ doc = {'rows': [{'board': 'nano', 'cells': {'board-locked': 'fail'},
+ 'duration': None}], 'banner': '', 'scope': '', 'caveat': ''}
+ r = hil_report.summarize(cfg, ['nano'], doc)['results'][0]
+ self.assertTrue(r['ran'])
+ self.assertTrue(r['locked'], 'a held lock was published as a hardware failure')
+
+ def test_a_malformed_row_does_not_kill_the_cli(self):
+ """summarize is the one reader with no defense, and it is the only one an agent's
+ verdict depends on."""
+ out = hil_report.summarize({'boards': [{'name': 'a'}]}, ['a'],
+ {'rows': [{'cells': {}}, {'board': 'a',
+ 'cells': {'t': 'pass'}}]})
+ self.assertTrue(out['results'][0]['pass'])
+
+
+class NoBoardsExitKeepsWhatRan(unittest.TestCase):
+ def test_it_does_not_wipe_an_accumulated_sidecar(self):
+ """Master wrote only markdown here, so the sidecar survived. Writing rows:[]
+ unconditionally makes an --accumulate rerun whose filters empty erase every
+ board that had already passed."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)], rd, True, '', '')
+ hil_report.mark_report_no_boards(rd, 'No boards left after the flasher filter',
+ fresh=False)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual([r['board'] for r in doc['rows']], ['a'])
+ self.assertIn('selected no boards', doc['caveat'])
+ self.assertIn('selected no boards', (rd / hil_report.REPORT_MD).read_text())
+
+
+class TheMergeBehavioursAreActuallyPinned(unittest.TestCase):
+ """accumulate_report's docstring cites these three as the reason not to split it, yet
+ deleting any of them left the whole suite green. Mutation-verified."""
+
+ def test_a_cleared_boundary_drops_the_previous_attempts_mark(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('b', 0, 0, [('b-v', {hil_report.BOUNDARY_CELL: 'fail'}, '1s')], 0)],
+ rd, True, '', '')
+ hil_report.accumulate_report(
+ [('b', 0, 0, [('b-v', {'cdc_msc': 'OK'}, '2s')], 0)], rd, False, '', '')
+ cells = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'][0]['cells']
+ self.assertNotIn(hil_report.BOUNDARY_CELL, cells)
+
+ def test_a_board_that_really_ran_drops_its_stale_lock_cell(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('b', 0, 0, [('b', {hil_report.LOCKED_CELL: 'fail'}, None)], 0)], rd, True, '', '')
+ hil_report.accumulate_report(
+ [('b', 0, 0, [('b', {'cdc_msc': 'OK'}, '2s')], 0)], rd, False, '', '')
+ rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows']
+ self.assertEqual([r['board'] for r in rows], ['b'])
+ self.assertNotIn(hil_report.LOCKED_CELL, rows[0]['cells'])
+
+ def test_a_filtered_rerun_keeps_the_previous_duration(self):
+ """A -t-filtered re-run reports duration None; blanking the column loses the only
+ record of how long the full run took."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('b', 0, 0, [('b', {'cdc_msc': 'OK'}, '119s')], 0)], rd, True, '', '')
+ hil_report.accumulate_report(
+ [('b', 0, 0, [('b', {'cdc_msc': 'OK'}, None)], 0)], rd, False, '', '')
+ self.assertEqual(json.loads(
+ (rd / hil_report.REPORT_JSON).read_text())['rows'][0]['duration'], '119s')
+
+
+class TheFooterCountsAreNotSwapped(unittest.TestCase):
+ """SKILL.md tells the operator to paste the footer counts verbatim, and swapping the
+ failed/skipped tallies left the suite green."""
+
+ def test_each_kind_is_counted_under_its_own_label(self):
+ md = hil_report.render_matrix([
+ ('b', {'p1': 'pass', 'p2': 'pass', 'f1': 'fail',
+ 's1': f'{hil_report.REPORT_CELL["skip"]} board wedged'}, '1s')])
+ self.assertIn(f'{hil_report.REPORT_CELL["pass"]} 2 passed', md)
+ self.assertIn(f'{hil_report.REPORT_CELL["fail"]} 1 failed', md)
+ self.assertIn(f'{hil_report.REPORT_CELL["skip"]} 1 skipped', md)
+
+
+class HilCiUploadsTheAccumulateMergeBase(unittest.TestCase):
+ """hil_ci.sh rm -rf's REMOTE_DIR at the start of every run, and accumulate_report
+ merges onto the sidecar in the run's cwd -- so without an upload a remote
+ `--accumulate` retry silently starts from nothing and its one-row table REPLACES the
+ full-fleet one. The copy-back at the end has always existed; the upload did not."""
+
+ def _gate(self, *args):
+ """Run the real gate block out of hil_ci.sh and return its ACCUMULATE verdict.
+
+ Executed, not grepped: the previous pair of tests searched the source text and
+ stayed green when `if [ "$ACCUMULATE" = 1 ]` was mutated to `if true`, because the
+ comment block above it mentions --accumulate five times."""
+ sh = (Path(HIL_DIR) / 'hil_ci.sh').read_text(encoding='utf-8')
+ a = sh.index('ACCUMULATE=$(python3 -')
+ b = sh.index(') || ACCUMULATE=0', a) + len(') || ACCUMULATE=0')
+ script = 'ARGS=("$@")\n' + sh[a:b] + '\necho "$ACCUMULATE"'
+ r = subprocess.run(['bash', '-c', script, '_', *args],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ return r.stdout.strip()
+
+ def test_every_spelling_argparse_accepts_is_detected(self):
+ """hil_test.py declares `-a, --accumulate`, so argparse also takes -av, -va,
+ --accum and --acc; hil-validate.js tells the operator to retry 'adding -v'."""
+ for spelling in ('--accumulate', '-a', '-av', '-va', '--accum', '--acc'):
+ self.assertEqual(self._gate(spelling), '1', f'{spelling} was not detected')
+
+ def test_a_run_without_it_is_not_treated_as_accumulate(self):
+ for spelling in ('-b', '-v', '--retry'):
+ self.assertEqual(self._gate(spelling), '0', f'{spelling} falsely detected')
+
+ def test_the_sidecar_is_uploaded_and_gated(self):
+ sh = (Path(HIL_DIR) / 'hil_ci.sh').read_text(encoding='utf-8')
+ up = [ln for ln in sh.splitlines()
+ if 'scp' in ln and 'hil_report.json' in ln and '$REMOTE:' in ln]
+ self.assertTrue(up, 'nothing uploads hil_report.json; --accumulate has no merge base')
+ self.assertIn('if [ "$ACCUMULATE" = 1 ]', sh, 'the upload is not gated')
+
+ def test_a_missing_merge_base_is_loud(self):
+ """The damage: --accumulate with nothing to merge onto succeeds and quietly
+ publishes a small table where a full one used to be."""
+ warn = [ln for ln in (Path(HIL_DIR) / 'hil_ci.sh').read_text().splitlines()
+ if 'warning' in ln.lower() and 'accumulate' in ln.lower()]
+ self.assertTrue(warn, 'no warning when --accumulate has no local sidecar')
+
+
+class RunOutcomeAndRigHealthAreSeparate(unittest.TestCase):
+ """`banner` describes the CONDITIONS cells were collected under, so it carries across a
+ retry. `caveat` describes how a RUN ENDED, so it must not: a clean retry that reports
+ an earlier attempt's abandonment tells the agent a green run failed."""
+
+ def test_a_clean_retry_drops_the_previous_abandon_notice(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '', '')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '2s')], 0)],
+ rd, False, '', '')
+ self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat'], '')
+
+ def test_rig_health_still_carries_across_the_retry(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.write_timeout_report(rd, [{'name': 's'}], 3600,
+ prefix='> **Rig note.** wedged\n')
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)],
+ rd, False, '', '')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertIn('Rig note', doc['banner'])
+
+ def test_a_second_attempts_abandon_is_recorded(self):
+ """_already_abandoned matched a notice carried forward from an EARLIER attempt, so
+ a genuinely new abandon wrote nothing and the run's own failure vanished."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report(
+ [('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)], rd, True, '',
+ '> **Rig note.** x\n',
+ caveat='**HIL run abandoned: worker pool timed out after 3600s.**\n')
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '2s')], 0)],
+ rd, False, '', '')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ self.assertIn('would not shut down',
+ json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat'])
+
+
+class AMalformedSidecarNeverCostsTheReport(unittest.TestCase):
+ """hil_ci.sh now uploads a sidecar as the merge base, so a non-conforming one is
+ reachable from outside the harness."""
+
+ def _write(self, rd, doc):
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(doc))
+
+ def test_a_null_banner_does_not_kill_a_successful_run(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ self._write(rd, {'rows': [{'board': 'a', 'cells': {'t': 'pass'}, 'duration': '1s'}],
+ 'banner': None, 'caveat': None, 'scope': ''})
+ hil_report.accumulate_report([('b', 0, 0, [('b', {'t': 'OK'}, '1s')], 0)],
+ rd, False, '', '')
+ self.assertTrue((rd / hil_report.REPORT_MD).is_file())
+
+ def test_a_null_cells_row_still_gets_its_pool_timeout_cell(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ self._write(rd, {'rows': [{'board': 'boardA', 'cells': None, 'duration': '61s'}],
+ 'banner': '', 'caveat': '', 'scope': ''})
+ hil_report.write_timeout_report(rd, [{'name': 'boardA'}], 3600)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ v = hil_report.summarize({'boards': [{'name': 'boardA'}]}, ['boardA'], doc)
+ self.assertFalse(v['results'][0]['pass'],
+ 'a board that ate the whole pool guard was published as a pass')
+
+ def test_an_awkward_sidecar_still_gets_the_abandon_stamp(self):
+ """Any raise inside the dict branch was swallowed and the markdown fallback was
+ unreachable, so the stamp was lost from BOTH artifacts."""
+ for bad in ({'rows': [{'board': 'a', 'cells': {'t': 'p'}, 'duration': 120}],
+ 'banner': '', 'caveat': '', 'scope': ''},
+ {'rows': [{'board': 'a', 'cells': {'t': ['x']}, 'duration': '1s'}],
+ 'banner': None, 'caveat': '', 'scope': ''}):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ self._write(rd, bad)
+ (rd / hil_report.REPORT_MD).write_text('**✅ 27 passed**\n')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ self.assertIn('abandoned', (rd / hil_report.REPORT_MD).read_text(encoding='utf-8'),
+ f'no stamp for {bad}')
+
+ def test_a_malformed_roster_entry_still_leaves_an_artifact(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ import io
+ from contextlib import redirect_stdout
+ with redirect_stdout(io.StringIO()):
+ hil_report.write_timeout_report(rd, ['plainstring'], 3600)
+ self.assertTrue((rd / hil_report.REPORT_MD).is_file(), 'no artifact at all')
+
+
+class PoolTimeoutOutranksAStaleLock(unittest.TestCase):
+ def test_a_wedge_is_not_published_as_lock_contention(self):
+ """locked was computed across every cell and short-circuited detail, so a board
+ that wedged the rig on the retry was reported as LOCKED -- and hil-validate.js
+ re-runs those, paying another pool guard on a board that just hung it."""
+ doc = {'rows': [{'board': 'boardX',
+ 'cells': {'board-locked': 'fail', 'pool-timeout': 'fail'},
+ 'duration': None}], 'banner': '', 'caveat': '', 'scope': ''}
+ r = hil_report.summarize({'boards': [{'name': 'boardX'}]}, ['boardX'], doc)['results'][0]
+ 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):
+ """It is called BEFORE the fresh wipe, so it re-published last run's green table
+ under this run's red job -- the stale-table failure it exists to prevent."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '', '')
+ hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=True)
+ self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'], [])
+
+ def test_an_accumulate_run_keeps_them(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '', '')
+ hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=False)
+ self.assertEqual([r['board'] for r in json.loads(
+ (rd / hil_report.REPORT_JSON).read_text())['rows']], ['a'])
+
+ def test_it_does_not_overwrite_an_abandon_notice(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '', '')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=False)
+ self.assertIn('abandoned',
+ json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat'])
+
+
+class TheNoBoardsCallSiteIsWired(unittest.TestCase):
+ """The fresh/accumulate branches of mark_report_no_boards were tested by calling it
+ DIRECTLY, so both passed while hil_test.py's one real call site never passed the flag
+ at all -- an --accumulate run whose filter emptied still wiped the accumulated rows.
+ This drives hil_test.py itself; the no-boards exit needs only a config and a filter
+ that matches nothing, so it costs no hardware."""
+
+ def _run(self, *extra):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / 'cfg.json').write_text(json.dumps(
+ {'boards': [{'name': 'alpha', 'uid': '1', 'flasher': {'name': 'jlink', 'uid': '2'}}]}))
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(
+ {'rows': [{'board': 'earlier', 'cells': {'t': 'pass'}, 'duration': '1s'}],
+ 'banner': '', 'scope': '', 'caveat': ''}))
+ r = subprocess.run(
+ [sys.executable, str(Path(HIL_DIR) / 'hil_test.py'),
+ '--flasher', 'nonexistent', *extra, str(rd / 'cfg.json')],
+ capture_output=True, text=True, timeout=120,
+ env={**os.environ, 'HIL_REPORT_DIR': str(rd)})
+ self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
+ return json.loads((rd / hil_report.REPORT_JSON).read_text())
+
+ def test_an_accumulate_run_keeps_the_accumulated_rows(self):
+ doc = self._run('--accumulate')
+ self.assertEqual([r['board'] for r in doc['rows']], ['earlier'],
+ "the call site did not pass fresh=not args.accumulate")
+ self.assertIn('selected no boards', doc['caveat'])
+
+ def test_a_fresh_run_does_not_republish_them(self):
+ doc = self._run()
+ self.assertEqual(doc['rows'], [])
+ self.assertIn('selected no boards', doc['caveat'])
+
+
+class EveryWriterRendersBeforeItCommits(unittest.TestCase):
+ def test_accumulate_report_does_not_commit_json_then_fail_to_render(self):
+ """accumulate_report hand-rolled the write instead of calling write_report, so a
+ render failure left the sidecar ahead of the markdown -- the exact ordering
+ write_report's docstring forbids."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_JSON).write_text(json.dumps(
+ {'rows': [{'board': 'boardA', 'cells': {'t': 'pass'}, 'duration': 119.0}],
+ 'banner': '', 'caveat': '', 'scope': ''}))
+ hil_report.accumulate_report([('boardB', 0, 0, [('boardB', {'t': 'OK'}, '1s')], 0)],
+ rd, False, '', '')
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual((rd / hil_report.REPORT_MD).read_text(),
+ hil_report.render_report(doc) + '\n')
+
+
+class MissingSidecarDoesNotDestroyTheMarkdown(unittest.TestCase):
+ def test_an_absent_sidecar_keeps_the_prior_table(self):
+ """`recovered` was only cleared when the sidecar was TORN, not when it was absent
+ -- reachable from hil_ci.sh's asymmetric copy-back and build.yml's skip marker."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n| b | OK |\n')
+ hil_report.write_timeout_report(rd, [{'name': 'stuck'}], 3600)
+ self.assertIn('| a | OK |', (rd / hil_report.REPORT_MD).read_text())
+
+
+class LoadIsTheOnlyTrustBoundary(unittest.TestCase):
+ """hil_ci.sh uploads a sidecar as the merge base, so these shapes arrive from OUTSIDE
+ the harness. Every one of these raised past a handler before."""
+
+ def _seed(self, rd, raw):
+ (rd / hil_report.REPORT_JSON).write_text(raw if isinstance(raw, str)
+ else json.dumps(raw))
+
+ def test_a_non_list_rows_does_not_raise(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ self._seed(rd, {'rows': 1, 'banner': '', 'caveat': '', 'scope': ''})
+ hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)],
+ rd, False, '', '')
+ self.assertTrue((rd / hil_report.REPORT_MD).is_file())
+
+ def test_an_unhashable_cell_value_does_not_raise(self):
+ """render_matrix does REPORT_CELL.get(v, v); an unhashable value raised TypeError
+ on the NORMAL accumulate path."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ self._seed(rd, {'rows': [{'board': 'a', 'cells': {'t': ['x'], 'u': 'pass'},
+ 'duration': '1s'}],
+ 'banner': '', 'caveat': '', 'scope': ''})
+ hil_report.accumulate_report([('b', 0, 0, [('b', {'t': 'OK'}, '1s')], 0)],
+ rd, False, '', '')
+ cells = {r['board']: r['cells']
+ for r in json.loads((rd / hil_report.REPORT_JSON).read_text())['rows']}
+ self.assertNotIn('t', cells['a'], 'a corrupt cell must drop, not become a pass')
+ self.assertIn('u', cells['a'])
+
+ def test_summarize_survives_a_malformed_sidecar_via_load(self):
+ """The CLI is the one reader an agent's verdict depends on."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ (rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'a'}]}))
+ self._seed(rd, {'rows': [{'board': 1, 'cells': 'notadict'},
+ {'board': 'a', 'cells': {'t': 'pass'}}],
+ 'banner': '', 'caveat': '', 'scope': ''})
+ r = subprocess.run(
+ [sys.executable, str(Path(HIL_DIR) / 'helper' / 'hil_report.py'),
+ str(rd / 'cfg.json'), '-b', 'a', '--report-dir', str(rd)],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertTrue(json.loads(r.stdout)['results'][0]['pass'])
+
+
+class NoBoardsGuardOnlyAppliesWhenAccumulating(unittest.TestCase):
+ def test_a_fresh_run_carries_nothing_from_the_prior_sidecar(self):
+ """rows were reset on fresh but banner and scope were not, so a leftover or
+ uploaded sidecar republished a stale rig-health note and a stale scope line under
+ this run's notice."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('old', 0, 0, [('old', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '3 board(s) — a, b, c',
+ '> **Rig note.** stale D-state holder\n')
+ hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=True)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual(doc['rows'], [])
+ self.assertEqual(doc['banner'], '', 'a stale rig-health banner was republished')
+ self.assertEqual(doc['scope'], '', 'a stale scope note was republished')
+ self.assertNotIn('Rig note', (rd / hil_report.REPORT_MD).read_text())
+
+ def test_an_accumulate_run_keeps_banner_and_scope(self):
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('old', 0, 0, [('old', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '3 board(s) — a, b, c',
+ '> **Rig note.** real\n')
+ hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=False)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertIn('Rig note', doc['banner'])
+ self.assertEqual([r['board'] for r in doc['rows']], ['old'])
+
+ def test_a_fresh_run_is_not_blocked_by_a_prior_abandon(self):
+ """The guard runs BEFORE the fresh wipe, so guarding a fresh run left the previous
+ attempt's rows AND its abandon notice published as this run's."""
+ td = TemporaryDirectory()
+ self.addCleanup(td.cleanup)
+ rd = Path(td.name)
+ hil_report.accumulate_report([('old', 0, 0, [('old', {'t': 'OK'}, '1s')], 0)],
+ rd, True, '', '')
+ hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')
+ hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=True)
+ doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
+ self.assertEqual(doc['rows'], [])
+ self.assertIn('selected no boards', doc['caveat'])
+
+
+if __name__ == '__main__':
+ unittest.main()
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_select.py b/test/hil/test/test_hil_select.py
deleted file mode 100644
index 9a1261878..000000000
--- a/test/hil/test/test_hil_select.py
+++ /dev/null
@@ -1,689 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MIT
-# Unit tests for hil_select.py — pure logic, no hardware, no git. Run directly:
-# python3 test/hil/test/test_hil_select.py
-#
-# Imports stay stdlib + hil_select/hil_util/hil_flash ONLY: the pre-commit hil-test
-# hook runs this suite, on GitHub's bare runner in the pre-commit workflow as well as
-# locally, and that runner has no pyserial/pymtp. hil_flash is admissible because it
-# is stdlib + hil_util only (test_hil_util.BottomLayer enforces the stdlib closure of
-# both) and the roster-dispatch tests need its flash_* table; never import hil_test,
-# which pulls pyserial.
-import glob
-import json
-import os
-import sys
-import unittest
-
-# the modules under test live in the parent dir (test/hil), not here
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-import hil_flash
-from helper import hil_select
-from helper.hil_util import device_tests, dual_tests
-
-REPO = os.path.dirname(os.path.dirname(os.path.dirname(
- os.path.dirname(os.path.abspath(__file__)))))
-
-
-def real_rosters():
- """The actual rig rosters, for regression tests that need real-world data
- (a specific board/family/only-list) rather than the synthetic ROSTER above."""
- rosters = []
- for name in ('tinyusb.json', 'hfp.json'):
- path = os.path.join(REPO, 'test/hil', name)
- with open(path) as f:
- rosters.append((f'test/hil/{name}', json.load(f)['boards']))
- return rosters
-
-
-def roster_flashers():
- """(roster path, board) for every board in the live rosters, `boards-skip`
- included: a parked board's flasher name must still dispatch, so that unparking it
- is not what discovers the name went stale."""
- for name in ('tinyusb.json', 'hfp.json'):
- path = os.path.join(REPO, 'test/hil', name)
- with open(path) as f:
- cfg = json.load(f)
- for key in ('boards', 'boards-skip'):
- for b in cfg.get(key, []):
- yield f'test/hil/{name}', b
-
-
-def on_roster(tc, *names):
- """The subset of `names` currently in the live rig rosters, skipping the test
- when none are, because parking/unparking a board is routine rig maintenance.
-
- That skip now matters MORE than it used to, not less: this suite is a blocking
- pre-commit hook AND build.yml's selector steps gate on it (a failing suite falls
- open to the full matrix), so an assertion that depends on a specific board being
- present goes red on every PR -- including src/-only ones that never touched the
- rig -- until someone fixes the roster. Keep roster-dependent assertions behind
- on_roster."""
- have = {b['name'] for _, boards in real_rosters() for b in boards}
- got = [n for n in names if n in have]
- if not got:
- tc.skipTest(f'not in the rig roster: {", ".join(names)}')
- return got
-
-
-ROSTER = [
- # device-only, rp2040 family
- {'name': 'raspberry_pi_pico', 'uid': 'u1', 'flasher': {'name': 'openocd'},
- 'tests': {'device': True, 'host': True, 'dual': True}},
- # device-only, stm32f4 family
- {'name': 'stm32f407disco', 'uid': 'u2', 'flasher': {'name': 'jlink'},
- 'tests': {'device': True, 'host': False, 'dual': False}},
- # host-only board
- {'name': 'raspberry_pi_pico2', 'uid': 'u3', 'flasher': {'name': 'openocd'},
- 'tests': {'device': False, 'host': True, 'dual': False}},
- # only-list board (espressif-style), flashed by the CI leg that splits on esptool
- {'name': 'espressif_s3_devkitm', 'uid': 'u4', 'flasher': {'name': 'esptool'},
- 'tests': {'only': ['device/cdc_msc_freertos', 'host/device_info']}},
-]
-ROSTERS = [('test/hil/tinyusb.json', ROSTER)]
-
-
-def sel(files):
- return hil_select.classify(files, REPO, ROSTERS)
-
-
-class TestPortRule(unittest.TestCase):
- def test_dcd_rp2040_selects_pico_family_only(self):
- s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico', s['boards'])
- self.assertNotIn('stm32f407disco', s['boards'])
- self.assertNotIn('espressif_s3_devkitm', s['boards'])
- # device role: no host tests in pico's list
- self.assertTrue(all(not t.startswith('host/') for t in s['boards']['raspberry_pi_pico']))
- # host-only boards drop out entirely on a device-role change
- self.assertNotIn('raspberry_pi_pico2', s['boards'])
-
- def test_shared_port_file_is_both_roles(self):
- s = sel(['src/portable/synopsys/dwc2/dwc2_common.c'])
- self.assertFalse(s['full'])
- self.assertNotIn('raspberry_pi_pico', s['boards']) # rp2040 is not a dwc2 family
- self.assertIn('stm32f407disco', s['boards']) # stm32f4 is
-
-
-class TestCoreRoleRule(unittest.TestCase):
- def test_usbd_selects_all_device_tests_everywhere(self):
- s = sel(['src/device/usbd.c'])
- self.assertFalse(s['full'])
- self.assertNotIn('raspberry_pi_pico2', s['boards']) # host-only board dropped
- pico = s['boards']['raspberry_pi_pico']
- self.assertTrue(set(device_tests).issubset(set(pico)))
- self.assertTrue(set(dual_tests).issubset(set(pico))) # dual survives device role
- self.assertTrue(all(not t.startswith('host/') for t in pico))
- # only-list board: selection intersects its only-list
- esp = s['boards']['espressif_s3_devkitm']
- self.assertEqual(esp, ['device/cdc_msc_freertos'])
-
- def test_host_change_drops_device(self):
- s = sel(['src/host/usbh.c'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico2', s['boards'])
- self.assertNotIn('stm32f407disco', s['boards']) # device-only board dropped
-
-
-class TestClassRule(unittest.TestCase):
- def test_cdc_device_selects_cdc_examples_only(self):
- s = sel(['src/class/cdc/cdc_device.c'])
- self.assertFalse(s['full'])
- pico = s['boards']['raspberry_pi_pico']
- self.assertIn('device/cdc_msc', pico)
- self.assertIn('device/cdc_dual_ports', pico)
- self.assertNotIn('device/msc_dual_lun', pico) # CFG_TUD_CDC 0 there
- self.assertNotIn('device/usbtest', pico) # CFG_TUD_CDC 0 there
- self.assertTrue(all(not t.startswith('host/') for t in pico))
-
- def test_msc_host_selects_host_side(self):
- s = sel(['src/class/msc/msc_host.c'])
- self.assertFalse(s['full'])
- self.assertNotIn('stm32f407disco', s['boards']) # device-only board
- pico2 = s['boards']['raspberry_pi_pico2']
- self.assertIn('host/msc_file_explorer', pico2)
- self.assertTrue(all(not t.startswith('device/') for t in pico2))
-
-
-class TestClassIncludeEdges(unittest.TestCase):
- """A class header another class includes reaches that class's examples too.
- src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, so
- midi_test's firmware contains audio.h - but the class rule derives macros from
- the directory name alone, so an audio.h change used to select only
- device/audio_test_freertos. On boards that skip that example the per-board
- intersection emptied and an audio.h-only PR ran ZERO HIL on them."""
- def test_edges_derived_from_includes(self):
- edges = hil_select.class_include_edges(REPO)
- self.assertEqual(edges.get('audio/audio.h'), {'midi'})
- self.assertEqual(edges.get('cdc/cdc.h'), {'net'})
-
- def test_audio_header_selects_midi_example(self):
- s = hil_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
- self.assertFalse(s['full'])
- # every board that runs device/midi_test at all must run it here (boards with
- # a tests.only list, e.g. espressif, run the freertos examples instead)
- by_name = {b['name']: b for _, bs in real_rosters() for b in bs}
- checked = 0
- for name, tests in s['boards'].items():
- if 'device/midi_test' in hil_select.board_tests(by_name[name]):
- self.assertIn('device/midi_test', tests, name)
- checked += 1
- self.assertTrue(checked)
-
- def test_audio_header_reaches_boards_that_skip_audio(self):
- # both skip device/audio_test_freertos: without the midi edge their
- # intersection is empty and they drop out of the selection entirely
- boards = on_roster(self, 'metro_m4_express', 'nrf54lm20dk')
- s = hil_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
- for board in boards:
- self.assertEqual(s['boards'].get(board), ['device/midi_test'], board)
-
- def test_edge_is_per_header_not_per_class(self):
- # midi includes audio.h, not audio_device.h: an audio_device change must
- # not drag midi's examples in
- s = hil_select.classify(['src/class/audio/audio_device.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for tests in s['boards'].values():
- if tests != 'all':
- self.assertNotIn('device/midi_test', tests)
-
-
-class TestFallbackRules(unittest.TestCase):
- def test_unknown_tool_is_full(self):
- s = sel(['tools/random_new_script.py'])
- self.assertTrue(s['full'])
-
- def test_docs_only_is_empty_not_full(self):
- s = sel(['docs/info/contributing.rst', 'README.rst'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {})
-
- def test_bsp_family_selects_family_boards(self):
- s = sel(['hw/bsp/rp2040/family.cmake'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico', s['boards'])
- self.assertEqual(s['boards']['raspberry_pi_pico'], 'all')
- self.assertNotIn('stm32f407disco', s['boards'])
-
- def test_bsp_board_narrows_to_board(self):
- s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
- self.assertFalse(s['full'])
- self.assertEqual(list(s['boards'].keys()), ['raspberry_pi_pico'])
-
- def test_example_change_selects_that_example(self):
- s = sel(['examples/device/cdc_msc/src/main.c'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards']['raspberry_pi_pico'], ['device/cdc_msc'])
-
- def test_core_common_is_full(self):
- for f in ['src/tusb.c', 'src/common/tusb_fifo.c', 'src/osal/osal_freertos.h']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_board_test_example_is_full(self):
- # board_test is the park/teardown firmware hil_test.py flashes on every board,
- # not an unlisted example: a regression there must not skip the whole rig
- for f in ['examples/device/board_test/src/main.c',
- 'examples/device/board_test/CMakeLists.txt']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_harness_is_full(self):
- for f in ['test/hil/hil_test.py', '.github/workflows/build.yml', 'hw/mcu/nxp/x.c', 'lib/foo/x.c']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_mixed_roles_no_pruning(self):
- s = sel(['src/device/usbd.c', 'src/host/usbh.c'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico2', s['boards'])
- self.assertIn('stm32f407disco', s['boards'])
-
- def test_cmakelists_and_requirements_are_full(self):
- for f in ['src/CMakeLists.txt', 'examples/CMakeLists.txt',
- 'examples/device/CMakeLists.txt', 'test/hil/requirements.txt']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_docs_txt_is_noncode(self):
- s = sel(['docs/info/changelog.txt'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {})
-
-
-class TestArgsEmission(unittest.TestCase):
- def test_args_for_scoped_selection(self):
- s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
- args = hil_select.selection_args(s, ROSTERS)
- a = args['tinyusb.json']
- self.assertIn('-b raspberry_pi_pico', a)
- self.assertNotIn('stm32f407disco', a)
- self.assertIn('-bt raspberry_pi_pico:', a) # device-only subset of a device+host board
-
- def test_args_full_is_empty(self):
- s = sel(['tools/random_new_script.py'])
- self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
-
- def test_args_all_board_gets_bare_b(self):
- s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
- a = hil_select.selection_args(s, ROSTERS)['tinyusb.json']
- self.assertIn('-b raspberry_pi_pico', a)
- self.assertNotIn('-bt', a)
-
- def test_args_by_flasher_splits_esp_from_the_rest(self):
- s = sel(['src/device/usbd.c'])
- per = hil_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
- self.assertIn('espressif_s3_devkitm', per['esptool'])
- self.assertIn('raspberry_pi_pico', per['openocd'])
- self.assertNotIn('espressif_s3_devkitm', per.get('openocd', '') + per.get('jlink', ''))
-
- def test_args_by_flasher_omits_a_flasher_with_no_selected_board(self):
- # the esp CI leg must see no args at all here, not a filter matching zero boards
- s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
- per = hil_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
- self.assertEqual(per, {'openocd': '-b raspberry_pi_pico'})
-
- def test_args_by_flasher_full_is_empty(self):
- s = sel(['tools/random_new_script.py'])
- self.assertEqual(hil_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
-
- def test_cli_diff_file(self):
- import subprocess, tempfile, json as j
- with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
- f.write('src/class/cdc/cdc_device.c\n')
- path = f.name
- r = subprocess.run([sys.executable, os.path.join(REPO, 'test/hil/helper/hil_select.py'),
- '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
- capture_output=True, text=True)
- self.assertEqual(r.returncode, 0, r.stderr)
- out = j.loads(r.stdout)
- self.assertFalse(out['full'])
- self.assertIn('tinyusb.json', out['args'])
- self.assertTrue(any('cdc_device' in line for line in out['reasons']))
- # A core-class diff must select boards THROUGH THE CLI: the in-process tests
- # inject their own repo root, so only this subprocess path catches a broken
- # repo_root derivation -- which once made every repo-relative glob match
- # nothing and turned this exact diff into a silent full-HIL skip.
- self.assertTrue(out['boards'],
- 'CLI selected zero boards for a src/class change: repo_root broken?')
- os.unlink(path)
-
-
-class TestRealRosterPortFamilies(unittest.TestCase):
- """Regression for port_families() missing espressif's dwc2 reference, which
- lives in a component CMakeLists.txt rather than family.cmake/family.mk."""
- def test_dwc2_change_selects_espressif_boards(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertIn(board, s['boards'])
-
-
-class TestOptionGatedPort(unittest.TestCase):
- """Regression: family_support.cmake compiles some ports from a build option
- (MAX3421_HOST=1 -> hcd_max3421.c), so a board's family file never names them."""
- # host-side option board (max3421 as host controller), off any max3421 family
- OPT_ROSTER = [('test/hil/opt.json', [
- {'name': 'fake_dual_board', 'uid': 'o1', 'flasher': {'name': 'jlink'},
- 'build': {'args': ['MAX3421_HOST=1']},
- 'tests': {'device': True, 'host': False, 'dual': True}},
- {'name': 'fake_host_board', 'uid': 'o2', 'flasher': {'name': 'jlink'},
- 'variant': [{'name': 'fake_host_board', 'flags': '-DMAX3421_HOST=1'}],
- 'tests': {'device': False, 'host': True, 'dual': False}},
- {'name': 'fake_off_board', 'uid': 'o3', 'flasher': {'name': 'jlink'},
- 'variant': [{'name': 'fake_off_board', 'defines': ['MAX3421_HOST=0']}],
- 'tests': {'device': True, 'host': True, 'dual': True}},
- ])]
-
- def test_real_roster_max3421_selects_option_board(self):
- boards = on_roster(self, 'metro_m4_express')
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertIn(board, s['boards'])
-
- def test_option_selects_via_args_defines_and_flags(self):
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, self.OPT_ROSTER)
- self.assertFalse(s['full'])
- self.assertIn('fake_dual_board', s['boards']) # build.args
- self.assertIn('fake_host_board', s['boards']) # variant flags
- self.assertNotIn('fake_off_board', s['boards']) # variant defines, but =0
-
- def test_device_role_port_does_not_pull_host_only_option_board(self):
- s = hil_select.classify(['src/portable/analog/max3421/dcd_max3421.c'], REPO, self.OPT_ROSTER)
- self.assertFalse(s['full'])
- self.assertNotIn('fake_host_board', s['boards']) # host-only board, device change
- self.assertIn('fake_dual_board', s['boards']) # device-capable option board
-
- def test_gates_parsed_from_family_support(self):
- self.assertEqual(hil_select.port_option_gates(REPO).get('analog/max3421'),
- {'MAX3421_HOST'})
-
- def test_board_cmake_option_counts(self):
- """A board can enable a gated port in its own BSP rather than via the roster
- (hw/bsp/espressif/boards/*/board.cmake -> set(MAX3421_HOST 1)); board_options()
- must see those too, or such a board joining the roster is silently dropped."""
- self.assertIn('MAX3421_HOST',
- hil_select.bsp_board_options('adafruit_feather_esp32s3', REPO))
- self.assertIn('CFG_TUH_RPI_PIO_USB',
- hil_select.bsp_board_options('adafruit_fruit_jam', REPO))
- # commented-out `# set(MAX3421_HOST 1)` must not count
- self.assertNotIn('MAX3421_HOST',
- hil_select.bsp_board_options('feather_nrf52840_express', REPO))
-
- def test_board_cmake_option_selects_off_family_board(self):
- # adafruit_feather_esp32s3 is not on any rig roster; stand it in as one to
- # prove the BSP-sourced option alone pulls a max3421 change onto the board
- roster = [('test/hil/opt.json', [
- {'name': 'adafruit_feather_esp32s3', 'uid': 'o1', 'flasher': {'name': 'esptool'},
- 'tests': {'device': False, 'host': True, 'dual': False}}])]
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
- self.assertFalse(s['full'])
- self.assertIn('adafruit_feather_esp32s3', s['boards'])
-
- def test_board_mk_option_is_ignored(self):
- """Make-only options must not select: HIL CI builds with CMake exclusively, so
- hw/bsp/nrf/boards/nrf5340dk/board.mk's MAX3421_HOST compiles nothing here."""
- roster = [('test/hil/opt.json', [
- {'name': 'nrf5340dk', 'uid': 'o1', 'flasher': {'name': 'jlink'},
- 'tests': {'device': False, 'host': True, 'dual': False}}])]
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {})
-
-
-class TestPortFamiliesCmakeOnly(unittest.TestCase):
- """port_families() is CMake-only (HIL CI never builds with Make) and matches on
- 'port_dir/' so a port dir is not a prefix of a sibling."""
- def test_make_only_family_is_not_a_family(self):
- # hw/bsp/pic32mz has family.mk but no family.cmake
- self.assertEqual(hil_select.port_families('microchip/pic32mz', REPO), set())
-
- def test_prefix_port_does_not_inherit_sibling_families(self):
- # bare-substring matching let 'microchip/pic' match '.../microchip/pic32mz/...'
- self.assertEqual(hil_select.port_families('microchip/pic', REPO), set())
-
- def test_make_only_port_forces_full(self):
- s = sel(['src/portable/microchip/pic32mz/dcd_pic32mz.c'])
- self.assertTrue(s['full'])
- self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
-
- def test_cmake_families_still_found(self):
- self.assertEqual(hil_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
- self.assertIn('stm32f4', hil_select.port_families('synopsys/dwc2', REPO))
-
-
-class TestPortFamiliesCoverage(unittest.TestCase):
- """Systematic guard: every real dcd_*/hcd_* port directory should map to at
- least one board family, so a future family.cmake/CMakeLists.txt layout that
- port_families() doesn't scan fails loudly instead of silently dropping boards
- (as espressif's dwc2 reference did - see TestRealRosterPortFamilies)."""
- # Ports with no board family: not a bug, just not wired into any rig board.
- # Add here (with a reason) only if port_families() legitimately can't find one.
- # A port listed here force-fulls (fail-open), so it is never under-selected.
- NO_FAMILY = {
- 'template', # reference/example port, not built by any board
- # hw/bsp/pic32mz has family.mk only (no family.cmake), and port_families()
- # is CMake-only because HIL CI builds every board with CMake - so this port
- # is compiled for no HIL board.
- 'microchip/pic32mz',
- 'microchip/pic', # same: only ever referenced from pic32mz's family.mk
- }
-
- @staticmethod
- def _dcd_hcd_ports():
- portable_root = os.path.join(REPO, 'src/portable')
- ports = []
- for entry in sorted(os.listdir(portable_root)):
- d = os.path.join(portable_root, entry)
- if not os.path.isdir(d):
- continue
- if glob.glob(os.path.join(d, 'dcd_*.c')) or glob.glob(os.path.join(d, 'hcd_*.c')):
- ports.append(entry)
- continue
- for sub in sorted(os.listdir(d)):
- sd = os.path.join(d, sub)
- if os.path.isdir(sd) and (glob.glob(os.path.join(sd, 'dcd_*.c')) or
- glob.glob(os.path.join(sd, 'hcd_*.c'))):
- ports.append(f'{entry}/{sub}')
- return ports
-
- def test_every_port_maps_to_a_family(self):
- ports = self._dcd_hcd_ports()
- self.assertTrue(ports) # sanity: the scan itself found something
- for port in ports:
- if port in self.NO_FAMILY:
- continue
- fams = hil_select.port_families(port, REPO)
- self.assertTrue(fams, f'{port}: no family references this port '
- f'(port_families() scan gap, or add to NO_FAMILY)')
-
-
-class TestRealRosterOnlyListTests(unittest.TestCase):
- """Regression for roster-only-list tests (e.g. espressif's hid_composite_freertos)
- being invisible to the selector because it only knew the shared hil_util lists."""
- def test_only_list_example_change_selects_it(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['examples/device/hid_composite_freertos/src/main.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertEqual(s['boards'][board], ['device/hid_composite_freertos'])
-
- def test_class_change_includes_only_list_boards(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/class/hid/hid_device.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertIn(board, s['boards'])
-
-
-class TestPortAndCoreRoleUseExtras(unittest.TestCase):
- """Regression: the port rule and core-role rule must thread the roster-only
- test universe (extras) the same way the class rule already does, so a DCD
- or device-stack change doesn't silently drop espressif's only-list tests
- (e.g. hid_composite_freertos) that aren't in the shared device_tests list."""
- def test_dcd_change_includes_only_list_test(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- tests = s['boards'][board]
- self.assertIn('device/hid_composite_freertos', tests)
- self.assertIn('device/cdc_msc_freertos', tests)
- self.assertIn('device/audio_test_freertos', tests)
- self.assertIn('device/usbtest', tests)
-
- def test_core_device_change_includes_only_list_test(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/device/usbd.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- tests = s['boards'][board]
- self.assertIn('device/hid_composite_freertos', tests)
- self.assertIn('device/cdc_msc_freertos', tests)
- self.assertIn('device/audio_test_freertos', tests)
- self.assertIn('device/usbtest', tests)
-
- def test_host_change_does_not_leak_device_only_list_test(self):
- s = hil_select.classify(['src/host/usbh.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board, tests in s['boards'].items():
- if tests == 'all':
- continue
- self.assertNotIn('device/hid_composite_freertos', tests, board)
-
-
-class TestFamilies(unittest.TestCase):
- """`families` exists for consumers that build (not just test) the diff: most
- families have no rig board, so `boards` alone would compile nothing for them."""
- def test_off_rig_port_still_reports_family(self):
- s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {}) # no same7x board on the rig
- self.assertEqual(s['families'], ['same7x'])
-
- def test_port_families_are_reported(self):
- s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
- self.assertIn('rp2040', s['families'])
-
- def test_bsp_family_and_board_report_family(self):
- self.assertEqual(sel(['hw/bsp/rp2040/family.cmake'])['families'], ['rp2040'])
- self.assertEqual(sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])['families'],
- ['rp2040'])
-
- def test_docs_only_has_no_families(self):
- self.assertEqual(sel(['docs/info/contributing.rst'])['families'], [])
-
- def test_full_selection_still_reports_families(self):
- """A full-matrix file must not hide the families of the other changed files:
- consumers that build from `families` (e.g. /pre-pr) ignore `boards` when full."""
- s = sel(['src/common/tusb_fifo.c', 'src/portable/microchip/samx7x/dcd_samx7x.c'])
- self.assertTrue(s['full'])
- self.assertIn('same7x', s['families'])
- # full stays full: every roster board, and no args to narrow the run
- self.assertEqual(set(s['boards']), {b['name'] for b in ROSTER})
- self.assertTrue(all(v == 'all' for v in s['boards'].values()))
- self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
- self.assertEqual(hil_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
-
- def test_family_order_does_not_matter(self):
- # same as above with the full-matrix file last (was the only order that worked)
- s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c', 'src/common/tusb_fifo.c'])
- self.assertTrue(s['full'])
- self.assertIn('same7x', s['families'])
-
-
-class TestGitDiffArgv(unittest.TestCase):
- def test_diff_disables_rename_detection(self):
- """Without --no-renames git reports only a rename's destination, so moving an
- HIL-relevant file to a non-code path would be classified as non-code only."""
- self.assertIn('--no-renames', hil_select.GIT_DIFF_ARGV)
-
-
-class TestPortWithoutFamilyIsFull(unittest.TestCase):
- """A port dir no family file references must widen (full matrix), not silently
- contribute zero boards — the fail-open contract."""
- def test_unreferenced_port_forces_full(self):
- orig = hil_select.port_families
- hil_select.port_families = lambda port_dir, repo_root: set()
- try:
- s = sel(['src/portable/vendor/newip/dcd_newip.c'])
- finally:
- hil_select.port_families = orig
- self.assertTrue(s['full'])
- self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
-
-
-class TestOpenocdVidPid(unittest.TestCase):
- """The roster's optional flasher `vid_pid` field (openocd-verbatim, e.g.
- "0x1a86 0x8010", more pairs appended) pins openocd's probe discovery so it
- never opens foreign usbfs nodes. It must be emitted BEFORE the args: the
- rescue cfgs run `init` internally (rp2350-rescue.cfg errors on any
- config-stage command after its init; rp2040.cfg under RESCUE scans before a
- trailing flag is even parsed), and no rig cfg sets a competing list
- (the 2026-08-10 convoy mechanism)."""
-
- def test_vid_pid_flag_precedes_args(self):
- cmd = hil_flash._openocd_cmd_base(
- {'uid': 'S1', 'args': '-f target/wch-riscv.cfg', 'vid_pid': '0x1a86 0x8010'})
- self.assertIn('-c "adapter usb vid_pid 0x1a86 0x8010" -f target/wch-riscv.cfg', cmd)
- self.assertTrue(cmd.endswith('-f target/wch-riscv.cfg'), cmd)
-
- def test_rescue_cfg_command_keeps_vid_pid_before_init(self):
- """rescue_openocd swaps the target cfg for one that runs `init` internally;
- a vid_pid flag after the args would error there (rp2350) or be skipped
- (rp2040) -- in exactly the wedged-rig scenario the pin exists for."""
- flasher = {'name': 'openocd', 'uid': 'S1', 'vid_pid': '0x2e8a 0x000c',
- 'args': '-c "set RESCUE 1" -f target/rp2040.cfg'}
- cmd = hil_flash._openocd_cmd_base(flasher)
- self.assertLess(cmd.index('adapter usb vid_pid'), cmd.index('-f target/'), cmd)
-
- def test_vid_pid_multiple_pairs(self):
- cmd = hil_flash._openocd_cmd_base(
- {'uid': 'S1', 'args': '-f i.cfg', 'vid_pid': '0x2e8a 0x000c 0x2e8a 0x000d'})
- self.assertIn('-c "adapter usb vid_pid 0x2e8a 0x000c 0x2e8a 0x000d"', cmd)
-
- def test_no_field_no_flag_but_warns(self):
- # the roster lint only covers the committed rosters; a dev PC's local.json entry
- # without the field must at least say what it is giving up -- on STDERR, since
- # hil_test captures stdout per test and would swallow it on a passing run
- import io
- from contextlib import redirect_stderr
- hil_flash._VID_PID_WARNED.discard('S-warn')
- cap = io.StringIO()
- with redirect_stderr(cap):
- cmd = hil_flash._openocd_cmd_base({'uid': 'S-warn', 'args': '-f i.cfg'})
- self.assertNotIn('vid_pid', cmd)
- self.assertIn('vid_pid', cap.getvalue())
-
- def test_roster_openocd_entries_all_pin_vid_pid(self):
- # every openocd probe on the rig has a known VID/PID; a new entry without the
- # pin silently reintroduces open-everything discovery
- for path, board in roster_flashers():
- f = board['flasher']
- # tinyusb.json only: hfp.json is the hifiphile rig owner's file, and a
- # blocking repo-wide lint over someone else's roster would red every PR the
- # moment they add an openocd board (hil_flash treats the field as optional)
- if f['name'] == 'openocd' and path.endswith('tinyusb.json'):
- self.assertIn('vid_pid', f,
- f"{path}: {board['name']} openocd flasher lacks vid_pid")
- self.assertNotIn('vid_pid', f.get('args', ''),
- f"{path}: {board['name']} packs vid_pid into args; use the field")
-
-
-class TestRosterFlashersDispatch(unittest.TestCase):
- """hil_test and hil_pool_check resolve a board's flasher with a bare
- getattr(hil_flash, f'flash_{name}'), and hil_test does it inside a redirect_stdout —
- so a renamed or typo'd roster name raises an AttributeError whose output is swallowed,
- with nothing pointing at the roster as the thing to edit. Renaming a flash_*/reset_*
- pair without updating every roster must fail here instead."""
-
- def test_flash_and_reset_exist_for_every_roster_flasher(self):
- for path, board in roster_flashers():
- name = board['flasher']['name'].lower()
- for fn in (f'flash_{name}', f'reset_{name}'):
- self.assertTrue(callable(getattr(hil_flash, fn, None)),
- f'{path}: {board["name"]} uses flasher "{name}" '
- f'but hil_flash.{fn} does not exist')
-
- def test_firmware_suffix_known_for_every_roster_flasher(self):
- """find_firmware falls back to accepting .elf-or-.bin when a flasher is missing
- from FLASHER_SUFFIX, silently restoring the mismatch that map exists to catch."""
- for path, board in roster_flashers():
- name = board['flasher']['name'].lower()
- self.assertIn(name, hil_flash.FLASHER_SUFFIX,
- f'{path}: {board["name"]} uses flasher "{name}" '
- f'with no hil_flash.FLASHER_SUFFIX entry')
-
-
-class FlasherRecoverEntry(unittest.TestCase):
- """Optional roster key: a SECOND flasher used only to deliver recovery while a usbfs
- node is poisoned. Boards whose primary flasher cannot get past a convoy (jlink,
- stlink, lm4flash) name an openocd entry here instead of changing how they are
- normally flashed."""
-
- def test_recover_flasher_prefers_the_optional_entry(self):
- prim = {'name': 'jlink', 'uid': 'X', 'args': '-device MIMXRT1064xxx6A'}
- rec = {'name': 'openocd', 'uid': 'X', 'args': '-f interface/jlink.cfg -f target/foo.cfg'}
- self.assertEqual(hil_flash.recover_flasher({'flasher': prim, 'flasher_recover': rec}), rec)
- self.assertEqual(hil_flash.recover_flasher({'flasher': prim}), prim)
-
- def test_openocd_over_jlink_is_convoy_safe_without_a_pin(self):
- """libjaylink discovery returns early unless idVendor == 0x1366 (SEGGER) and the PID
- is in its table, and only THEN calls libusb_open (discovery_usb.c) -- it never opens
- a foreign node. `adapter usb vid_pid` is a no-op for this driver: jlink.c reads
- adapter_serial / usb address / usb location, never the vid/pid."""
- self.assertTrue(hil_flash.convoy_safe(
- {'name': 'openocd', 'args': '-f interface/jlink.cfg -f target/stm32f4x.cfg'}))
-
- def test_openocd_with_neither_a_pin_nor_jlink_is_not_safe(self):
- self.assertFalse(hil_flash.convoy_safe(
- {'name': 'openocd', 'args': '-f interface/stlink.cfg -f target/stm32h7x.cfg'}))
-
- def test_the_existing_rules_are_unchanged(self):
- self.assertTrue(hil_flash.convoy_safe(
- {'name': 'openocd', 'vid_pid': '0x2e8a 0x000c', 'args': '-f interface/cmsis-dap.cfg'}))
- self.assertFalse(hil_flash.convoy_safe({'name': 'jlink', 'uid': 'X'}))
- self.assertTrue(hil_flash.convoy_safe({'name': 'esptool'}))
-
-
-if __name__ == '__main__':
- unittest.main(verbosity=1)
diff --git a/test/hil/test/test_hil_util.py b/test/hil/test/test_hil_util.py
index 9c3d5edef..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')
@@ -98,7 +97,7 @@ class RunCmdModes(unittest.TestCase):
class BottomLayer(unittest.TestCase):
def test_bad_timeout_env_falls_back(self):
- # hil_select (the PR-diff selector) imports hil_util for the example rosters;
+ # ci_select (the PR-diff selector) imports hil_util for the example rosters;
# a malformed HIL_CMD_TIMEOUT must not crash the selector at import and knock
# CI back to the full-matrix fallback
import subprocess
@@ -108,7 +107,7 @@ class BottomLayer(unittest.TestCase):
env={**os.environ, 'HIL_CMD_TIMEOUT': 'bogus'},
capture_output=True, text=True, timeout=30)
self.assertEqual(r.returncode, 0, r.stderr)
- # the warning must NOT be on stdout: hil_select's stdout is machine-read JSON
+ # the warning must NOT be on stdout: ci_select's stdout is machine-read JSON
self.assertEqual(r.stdout.strip(), '180')
self.assertIn('warning', r.stderr) # but a silent fallback hides the misconfiguration
@@ -132,22 +131,27 @@ class BottomLayer(unittest.TestCase):
# hil_examples.py used to make this structural (a list of strings cannot grow a
# dependency); with the rosters folded into hil_util the invariant needs teeth:
# everything the bare GitHub runner imports (selector + this suite) must stay
- # stdlib + local. Adding pyserial/pymtp here breaks hil_select on CI.
+ # stdlib + local. Adding pyserial/pymtp here breaks ci_select on CI.
import ast
hil_dir = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ONLY the modules the bare runner can import -- not every stem in the tree.
# Globbing the directory allowed `import pymtp` (and hil_test, usbtest,
# mtp_test) through, so the pymtp case this test names could never fail: that
# module runs ctypes.CDLL(find_library('mtp')) at import and raises where there
- # is no libmtp, taking hil_select down with it.
- local = {'helper', 'hil_util', 'hil_select', 'hil_flash',
- 'hil_health', 'hil_lock', 'hil_pool_check'}
+ # is no libmtp, taking ci_select down with it.
+ local = {'helper', 'hil_util', 'ci_select', 'hil_flash',
+ 'hil_health', 'hil_lock', 'hil_pool_check', 'build', 'build_utils'}
allowed = set(sys.stdlib_module_names) | local
# hil_pool_check included: test_hil_util_is_a_single_module_instance imports it
# on the bare runner, and its `import serial` is function-local for exactly
# this reason -- hoisting it must fail HERE, not on every PR's pre-commit CI
- for mod in ('helper/hil_util', 'hil_flash', 'helper/hil_select',
- 'helper/hil_health', 'helper/hil_lock', 'helper/hil_pool_check'):
+ # ../../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/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
@@ -163,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
@@ -226,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/tinyusb.json b/test/hil/tinyusb.json
index 549a17cd0..8fd4683a4 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -157,11 +157,14 @@
{
"name": "metro_m4_express",
"uid": "9995AD485337433231202020FF100A34",
- "build": {
- "args": [
- "MAX3421_HOST=1"
- ]
- },
+ "variant": [
+ {
+ "name": "metro_m4_express",
+ "defines": [
+ "MAX3421_HOST=1"
+ ]
+ }
+ ],
"tests": {
"device": true,
"host": false,
@@ -197,6 +200,20 @@
}
},
{
+ "name": "lpcxpresso55s28",
+ "uid": "2BF1839A7D51F553A15AB03FD08F70AB",
+ "tests": {
+ "device": true,
+ "host": false,
+ "dual": false
+ },
+ "flasher": {
+ "name": "jlink",
+ "uid": "000727031389",
+ "args": "-device LPC55S28"
+ }
+ },
+ {
"name": "ra4m1_ek",
"uid": "152E163038303131393346E46F26574B",
"tests": {
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),