| #!/usr/bin/env python3 |
| """Run the Linux kernel usbtest/testusb battery against a TinyUSB usbtest device. |
| |
| Device firmware: examples/device/usbtest (VID:PID cafe:4010). The firmware |
| advertises its capability tier in bcdDevice low byte; the battery is selected |
| accordingly (see examples/device/usbtest/README.md). |
| |
| Requires: usbtest kernel module (CONFIG_USB_TEST), testusb binary (built from |
| kernel tools/usb/testusb.c), sudo for driver binding + usbfs ioctls. |
| |
| testusb reporting quirks this script works around: |
| - its exit code is always 0 when the device exists: results are parsed from stdout |
| - a case gated off by the driver's capability profile (or an in-kernel parameter |
| check) returns -EOPNOTSUPP, which testusb silently skips: a missing result line |
| means NOT RUN, and is reported as a failure since every case in the selected |
| battery is expected to run. |
| |
| Binding uses the 5-field new_id form referencing Gadget Zero (0525:a4a0) so the |
| dynamic id inherits its capability profile (autoconf + ctrl_out + iso + intr). |
| Never register a plain "vid pid" dynamic id with usbtest: the dynid then has |
| driver_info == 0 and usbtest_probe() dereferences it without a NULL check |
| (kernel oops). autoconf is also what enables bulk endpoint discovery; the |
| capability flags only unlock cases, they don't require the endpoints to exist. |
| """ |
| |
| import argparse |
| import fcntl |
| import json |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import time |
| from contextlib import redirect_stdout |
| from pathlib import Path |
| |
| sys.path.append(os.path.dirname(os.path.abspath(__file__))) # PYTHONSAFEPATH drops it |
| |
| VID = 'cafe' |
| PID = '4010' |
| GZ_REF = '0525 a4a0' # copy Gadget Zero's capability profile (ctrl_out+iso+intr) |
| SYS_USB = Path('/sys/bus/usb/devices') |
| DRIVER = Path('/sys/bus/usb/drivers/usbtest') |
| PATTERN_PARAM = Path('/sys/module/usbtest/parameters/pattern') |
| # In hil_lock's dir, shared by every run on the rig, but not a *.lock: hil_lock treats those |
| # as boards (status, release --all) |
| REGISTER_LOCK_NAME = 'usbtest-new_id.registry' |
| REGISTER_LOCK_TIMEOUT = 30 # a holder only checks and writes new_id, itself bounded at 15 s |
| RECOVER_FLASH_TIMEOUT = 90 # bound on the post-hang reflash; typical flash is 10-20s |
| RECOVER_RESET_TIMEOUT = 30 # bound on the post-hang probe reset; jlink ResetTarget ~130ms, stlink --rst --go ~100ms |
| RECOVER_SETTLE = 5 # after the recovery step, to let a freed ioctl unwind |
| # How long a HUNG case is watched before it counts as a wedge. HUNG only says the kill was |
| # not reaped within 5 s; a finite hold on the DUT's device lock looks the same and is reaped |
| # once it ends. Reserved on every HUNG path, recovery or not. |
| WEDGE_CONFIRM_S = 30 |
| RECOVER_REAP = 5 # after the settle: a freed testusb reaps within this |
| # The unbounded work around the recovery step: json.loads of the roster entry, the child's |
| # first `import hil_flash`, convoy_safe, the BUDGET back-fill and the JSON print. 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 |
| |
| |
| def recovery_reserve(flasher: dict) -> int: |
| """Seconds this flasher's post-hang recovery can spend: the confirmation window, ONE |
| bounded step (a probe reset where the flasher has one, else a reflash) plus run_cmd's |
| post-SIGKILL reap, the settle and the reap check after it, and the overhead above. |
| The caller's outer kill must sit past this or it lands mid-step and orphans the flasher |
| on the probe.""" |
| import hil_flash |
| name = (flasher.get('name') or '').lower() |
| step = RECOVER_RESET_TIMEOUT if hil_flash.reset_primitive(name) else RECOVER_FLASH_TIMEOUT |
| return (WEDGE_CONFIRM_S + step + _hu().REAP_GRACE + RECOVER_SETTLE + RECOVER_REAP |
| + RECOVER_OVERHEAD) |
| |
| |
| HELPER_TIMEOUT = 30 # default bound for sudo helpers (dmesg/modprobe/setpci/tee) |
| |
| # Battery per tier, in run order: control sanity, simple bulk, queued, unaligned, unlink, |
| # halt/toggle, throughput last. |
| TIER_CASES = { |
| 1: [0, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 17, 18, 19, 20, 11, 12, 24, 13, 29, 27, 28], |
| 2: [14, 21], |
| 3: [25, 26], |
| 4: [15, 16, 22, 23], |
| } |
| |
| UNLINK_CASES = (11, 12, 24) # URB unlink mid-transfer: can strand a hub TT buffer (hil_tt) |
| |
| # Per-case testusb parameters (full speed / high speed). All -s/-v values are multiples |
| # of 512 so transfers stay packet-aligned at both speeds: the device streams whole max-size |
| # packets and a non-aligned IN length would babble. 14/21 must never run with defaults |
| # (vary >= length is -EINVAL in the kernel). |
| PARAMS = { |
| 0: ('-c 1', '-c 1'), |
| 9: ('-c 256', '-c 500'), # HS 1000 took 56 of 60 s with 5 batteries on a hub; 500 leaves ~2x |
| 10: ('-c 64 -g 16', '-c 256 -g 16'), |
| **{n: ('-c 128 -s 1024 -v 512', '-c 512 -s 1024 -v 512') for n in (1, 2, 3, 4, 17, 18, 19, 20)}, |
| **{n: ('-c 8 -s 1024 -g 8', '-c 32 -s 1024 -g 16') for n in (5, 6, 7, 8)}, |
| **{n: ('-c 64 -s 1024 -g 8', '-c 256 -s 1024 -g 8') for n in UNLINK_CASES}, |
| 13: ('-c 16 -s 512', '-c 64 -s 512'), |
| 29: ('-c 16 -s 512', '-c 64 -s 512'), |
| 27: ('-c 16 -s 1024 -g 32', '-c 128 -s 1024 -g 32'), |
| 28: ('-c 16 -s 1024 -g 32', '-c 128 -s 1024 -g 32'), |
| 14: ('-c 64 -s 512 -v 61', '-c 256 -s 512 -v 61'), |
| 21: ('-c 64 -s 512 -v 61', '-c 256 -s 512 -v 61'), |
| 25: ('-c 32 -s 512', '-c 256 -s 1024'), |
| 26: ('-c 32 -s 512', '-c 256 -s 1024'), |
| **{n: ('-c 16 -s 512 -g 8', '-c 64 -s 1024 -g 8') for n in (15, 16, 22, 23)}, |
| } |
| |
| CASE_NAMES = { |
| 0: 'NOP', 1: 'bulk write', 2: 'bulk read', 3: 'bulk write vary', 4: 'bulk read vary', |
| 5: 'bulk sg write', 6: 'bulk sg read', 7: 'bulk sg write vary', 8: 'bulk sg read vary', |
| 9: 'ch9 subset', 10: 'queued control', 11: 'unlink reads', 12: 'unlink writes', |
| 13: 'ep halt set/clear', 14: 'ctrl_out write/read', 15: 'iso write', 16: 'iso read', |
| 17: 'bulk write unaligned', 18: 'bulk read unaligned', 19: 'bulk write premapped', |
| 20: 'bulk read premapped', 21: 'ctrl_out unaligned', 22: 'iso write unaligned', |
| 23: 'iso read unaligned', 24: 'unlink queued writes', 25: 'int write', 26: 'int read', |
| 27: 'bulk write perf', 28: 'bulk read perf', 29: 'toggle clear', |
| } |
| |
| RE_PASS = re.compile(r'test (\d+),\s*(\d+)\.(\d+) secs') |
| RE_FAIL = re.compile(r'test (\d+) --> (\d+) \((.*)\)') |
| |
| |
| def run(cmd, **kw): |
| # NOT subprocess.run(timeout=): CPython's post-timeout path is an UNBOUNDED wait() that |
| # never returns on a D-state child -- the hang sysfs_write's timeout exists to catch. |
| timeout = kw.pop('timeout', None) |
| data = kw.pop('input', None) # subprocess.run-only kwarg; Popen takes stdin |
| kw.pop('capture_output', None) # ditto: expressed by the PIPEs below |
| kw.setdefault('text', True) |
| kw.setdefault('encoding', 'utf-8') |
| kw.setdefault('errors', 'replace') # strict decode would raise out of _sudo_soft |
| # NO start_new_session: these helpers (dmesg, modprobe, setpci, tee) must stay in our |
| # process group so hil_test's outer killpg reaps them with us. |
| timeout = timeout if timeout is not None else HELPER_TIMEOUT |
| proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if data is not None else None, |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw) |
| try: |
| out, err = proc.communicate(input=data, timeout=timeout) |
| return subprocess.CompletedProcess(cmd, proc.returncode, out, err) |
| except subprocess.TimeoutExpired: |
| # Under sudo our child is only the wrapper; the root grandchild survives this and |
| # is left for the report and hil_pool_check to name. Close our pipe ends so an |
| # abandoned child costs no fds. |
| try: |
| proc.kill() # same group as us: never killpg, that would kill us too |
| except OSError: |
| pass |
| try: |
| proc.communicate(timeout=5) |
| except subprocess.TimeoutExpired: |
| for pipe in (proc.stdout, proc.stderr, proc.stdin): |
| try: |
| if pipe is not None: |
| pipe.close() |
| except OSError: |
| pass # unkillable: abandon it, the caller reports the timeout |
| raise |
| |
| |
| def sudo(cmd, **kw): |
| if os.geteuid() != 0: |
| cmd = ['sudo', '-n'] + cmd |
| r = run(cmd, **kw) |
| if r.returncode != 0 and 'password is required' in (r.stderr or ''): |
| sys.exit(f'sudo needs a password for: {" ".join(cmd)}\n' |
| 'Run as root, or grant this user NOPASSWD sudo.') |
| return r |
| |
| |
| def sysfs_write(path, data, check=True): |
| # A driver-registry write (new_id/remove_id/bind) blocks in D state while a device lock |
| # is held: fail fast instead of piling up unkillable writers. A timeout alone does not |
| # say which holder: a peer's in-flight testusb case holds its lock for the whole case |
| # (usbdev_do_ioctl), a wedged device holds it for good. |
| # |
| # Verified in v6.12.96: unbind_store -> device_driver_detach -> |
| # device_release_driver_internal -> __device_driver_lock (drivers/base/dd.c), which |
| # takes device_lock() -- the UNINTERRUPTIBLE variant, unlike the sysfs read path -- and |
| # ALSO device_lock(parent), because usb_bus_type sets need_parent_lock = true |
| # (drivers/usb/core/driver.c:2048). So one such write against a wedged device blocks |
| # unkillably while holding the HUB's lock: that is the mechanism by which a single |
| # wedged port takes its whole bus down, and why this fails fast instead. |
| try: |
| r = sudo(['tee', str(path)], input=data, timeout=15) |
| except subprocess.TimeoutExpired: |
| sys.exit(f'write "{data}" > {path} blocked >15s: a device lock is held -- possible ' |
| 'device-lock contention or a wedged device. Check dmesg before any recovery ' |
| '(usb-kernel-recover skill).') |
| if check and r.returncode != 0: |
| sys.exit(f'write "{data}" > {path} failed: {r.stderr.strip()}') |
| return r.returncode == 0 |
| |
| |
| def _hu(): |
| """The helper module, imported lazily like every other helper use in this file.""" |
| from helper import hil_util |
| return hil_util |
| |
| |
| def _tt(): |
| from helper import hil_tt |
| return hil_tt |
| |
| |
| def _uo(): |
| from helper import usb_observe |
| return usb_observe |
| |
| |
| SERIAL_GRACE = 1.0 # tighter than usb_observe'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 usb_observe.read_sysfs for why `serial` can block.""" |
| return _uo().read_sysfs(str(path), SERIAL_GRACE) |
| |
| |
| _DEV_CACHE: dict = {} # serial -> sysname, see find_device |
| |
| |
| def _describe(d, serial): |
| """The device at sysfs dir `d`, whose `serial` the caller has read and matched.""" |
| return { |
| 'sysname': d.name, |
| 'serial': serial, |
| 'node': '/dev/bus/usb/%03d/%03d' % (int((d / 'busnum').read_text()), |
| int((d / 'devnum').read_text())), |
| 'speed': (d / 'speed').read_text().strip(), |
| 'tier': int((d / 'bcdDevice').read_text().strip()[-2:], 16), |
| } |
| |
| |
| def _reread(sysname, serial): |
| """Re-describe an already-resolved device, CONFIRMING its serial. |
| |
| idVendor/idProduct/busnum/devnum/speed are lock-free (sysfs.c:688-705), so they cannot |
| block on a wedged peer -- but every identical board answers them the same, so they |
| prove nothing about identity. `serial` does, at one bounded read: a sysname is a |
| topology path, and after a renumber (controller reset, reboot) it can name a DIFFERENT |
| cafe:4010 board whose verdicts would be filed under this one. Returns None when the |
| serial is gone, mismatched or unconfirmed -- caller falls back to a full scan. |
| """ |
| d = SYS_USB / sysname |
| try: |
| if ((d / 'idVendor').read_text().strip() != VID |
| or (d / 'idProduct').read_text().strip() != PID): |
| return None |
| dev_serial = _read_sysfs(d / 'serial') |
| if not isinstance(dev_serial, str) or dev_serial.lower() != serial.lower(): |
| return None # gone, mismatched, or unconfirmable -> full scan decides |
| return _describe(d, dev_serial) |
| except (OSError, ValueError): |
| return None |
| |
| |
| def find_device(serial): |
| """Locate the usbtest device in sysfs, return info dict or None. |
| |
| Cached by serial: this is called after EVERY case, and a full scan pays a bounded |
| but real `serial` read for every cafe:4010 peer on the rig. With another board |
| wedged that cost lands on a HEALTHY battery ~30 times over, truncating it into |
| BUDGET entries. The fast path pays ONE bounded read -- our own device's serial, the |
| only attribute that tells identical boards apart (see _reread). |
| """ |
| if serial: |
| sysname = _DEV_CACHE.get(serial.lower()) |
| if sysname: |
| hit = _reread(sysname, serial) |
| if hit: |
| return hit |
| _DEV_CACHE.pop(serial.lower(), None) |
| matches = [] |
| for dev in SYS_USB.iterdir(): |
| try: |
| if (dev / 'idVendor').read_text().strip() != VID or \ |
| (dev / 'idProduct').read_text().strip() != PID: |
| 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 usb_observe.read_sysfs. |
| dev_serial = _read_sysfs(dev / 'serial') |
| if dev_serial is None: |
| continue |
| if serial and dev_serial.lower() != serial.lower(): |
| continue |
| matches.append(_describe(dev, dev_serial)) |
| except (OSError, ValueError): |
| continue |
| if not matches: |
| return None |
| if serial and len(matches) == 1: |
| _DEV_CACHE[serial.lower()] = matches[0]['sysname'] |
| if len(matches) > 1: |
| if serial: |
| # Dual-port parts (nanoch32v203, ch32v307) briefly enumerate BOTH ports with |
| # one serial around a variant reflash, and picking one could bind the stale |
| # port -- report ambiguity so the caller retries until it drops. |
| return {'ambiguous': sorted(m['sysname'] for m in matches)} |
| sys.exit(f'multiple {VID}:{PID} devices found, use --serial: ' |
| + ', '.join(m["serial"] for m in matches)) |
| return matches[0] |
| |
| |
| def check_host_compat(dev): |
| """Refuse to run when the DUT's upstream host controller is known-incompatible: |
| the MosChip MCS9990 (9710:9990) outright (buggy FRINDEX silicon: EHCI never |
| schedules int-OUT URBs and mangles unlinked reads - verified A/B 2026-07-09), |
| and the Renesas uPD720201/02 unless it runs firmware >= 2.0.2.6 (see below).""" |
| for attempt in range(3): |
| try: |
| root = Path(f"/sys/bus/usb/devices/usb{int(dev['node'].split('/')[-2])}") |
| drv = (root / '../driver').resolve().name |
| pci = (root / '..').resolve() |
| vid_did = ((pci / 'vendor').read_text().strip(), (pci / 'device').read_text().strip()) |
| break |
| except (OSError, ValueError): |
| # transient sysfs error (racing a re-enumeration): retry so a blip does not |
| # silently pass an incompatible host, then fail open but say so |
| if attempt == 2: |
| print('warning: cannot probe the upstream host controller; ' |
| 'skipping the host compatibility check', file=sys.stderr) |
| return |
| time.sleep(1) |
| if vid_did == ('0x9710', '0x9990'): |
| sys.exit(f'REFUSING to run: DUT is behind a MosChip MCS9990 ({pci.name}), which is ' |
| 'incompatible with usbtest: broken FRINDEX silicon - int-OUT URBs are never ' |
| 'placed in the EHCI periodic schedule and unlinked reads complete as short ' |
| 'transfers (EREMOTEIO). Move the DUT to an xHCI port.') |
| if drv.startswith('xhci') and vid_did in (('0x1912', '0x0014'), ('0x1912', '0x0015')): |
| # The Renesas uPD720201/uPD720202 must run firmware >= 2.0.2.6 (K2026090.mem; |
| # RAM-uploaded, so it reverts to ROM on every power cycle): on ROM firmware its |
| # command ring dies under unlink stress and the hub worker deadlocks holding the |
| # device lock, needing a host power cycle (ch32v307 2026-07-10; ra6m5 test 24, |
| # mimxrt1015 2026-07-11). Both parts expose the FW version at PCI config 0x6c. |
| # Necessary, not sufficient -- batteries have killed the controller on current |
| # firmware too, which per-board skips in the rig config handle. |
| fw = None |
| try: |
| r = _sudo_soft(['setpci', '-s', pci.name, '0x6c.l']) |
| if r.returncode == 0: |
| fw = int(r.stdout.strip(), 16) |
| except (OSError, ValueError): |
| pass |
| if fw is None: |
| sys.exit(f'REFUSING to run: cannot read host xHCI Renesas ({pci.name}) firmware ' |
| 'version (setpci missing or not permitted) - usbtest requires verified ' |
| 'firmware >= 0x00202609 (2.0.2.6); on older firmware the command ring ' |
| 'dies under unlink stress. Install pciutils / fix sudo, or load the ' |
| 'firmware and re-check.') |
| if fw < 0x00202609: |
| sys.exit(f'REFUSING to run: host xHCI Renesas ({pci.name}) firmware 0x{fw:08x} ' |
| '< 0x00202609 (2.0.2.6) - its command ring dies under usbtest unlink ' |
| 'stress. Load the latest firmware (K2026090.mem; it is RAM-uploaded and ' |
| 'reverts to ROM on every power cycle).') |
| |
| |
| def register_usbtest_id(): |
| """Register cafe:4010 with usbtest: one new_id write per module load, before any battery. |
| |
| Every new_id write runs driver_attach, which takes the device lock of every cafe:4010 |
| interface on the rig; a peer's testusb case holds its lock for the whole case, so a write |
| beside running batteries waits for the slowest case and trips sysfs_write's bound. The |
| kernel keeps no duplicate check and lists the id before driver_attach runs, so the check |
| and the write share one flock: two initializers make one write, and neither returns |
| while the other's attach is in flight. A wrong entry is repaired by a module reload |
| (usbtest skill, "Repair a wrong profile"), never by remove_id + this.""" |
| if not DRIVER.exists(): |
| r = _sudo_soft(['modprobe', 'usbtest']) |
| if r.returncode != 0 or not DRIVER.exists(): |
| sys.exit(f'cannot load usbtest module: {r.stderr.strip()}') |
| from helper import hil_lock |
| path = os.path.join(hil_lock.BOARD_LOCK_DIR, REGISTER_LOCK_NAME) |
| try: |
| os.makedirs(hil_lock.BOARD_LOCK_DIR, exist_ok=True) |
| fh = open(path, 'a') |
| except OSError as e: |
| sys.exit(f'usbtest id registration lock {path}: {e.strerror}') |
| with fh: |
| deadline = time.monotonic() + REGISTER_LOCK_TIMEOUT |
| while True: |
| try: |
| fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) |
| break |
| except BlockingIOError: |
| if time.monotonic() > deadline: |
| sys.exit(f'usbtest id registration lock {path} held >{REGISTER_LOCK_TIMEOUT}s') |
| time.sleep(0.1) |
| except OSError as e: |
| sys.exit(f'usbtest id registration lock {path}: {e.strerror}') |
| try: |
| listed = (DRIVER / 'new_id').read_text().splitlines() |
| except OSError as e: |
| sys.exit(f'cannot read {DRIVER / "new_id"}: {e.strerror}') |
| if f'{VID} {PID}' not in listed: |
| sysfs_write(DRIVER / 'new_id', f'{VID} {PID} 0 {GZ_REF}') |
| |
| |
| def bind_usbtest(dev): |
| """Bind the device's interface 0 to usbtest. The id is registered already |
| (register_usbtest_id), so this writes only this device's own bind/unbind.""" |
| intf = f'{dev["sysname"]}:1.0' |
| deadline = time.monotonic() + 3 |
| while time.monotonic() < deadline: |
| drv = SYS_USB / intf / 'driver' |
| if drv.is_symlink(): |
| if drv.resolve().name == 'usbtest': |
| return |
| # claimed by a foreign driver: steal the interface |
| sysfs_write(drv / 'unbind', intf) |
| sysfs_write(DRIVER / 'bind', intf, check=False) |
| time.sleep(0.2) |
| sys.exit(f'interface {intf} did not bind to usbtest') |
| |
| |
| def set_pattern(value): |
| try: |
| if PATTERN_PARAM.read_text().strip() != str(value): |
| sysfs_write(PATTERN_PARAM, str(value)) |
| except OSError as e: # FileNotFoundError (no param), PermissionError (root-only), ... |
| sys.exit(f'{PATTERN_PARAM} not usable ({e.strerror}): this usbtest module build may lack ' |
| 'the "pattern" param, or it is not readable') |
| |
| |
| def _sudo_soft(cmd, **kw): |
| """sudo() for calls whose failure must never abort the battery: run() re-raises |
| TimeoutExpired, and two of these are evaluated inside run_case's own timeout handler |
| -- a raise there loses the HUNG verdict, the recovery and the JSON report.""" |
| try: |
| return sudo(cmd, **kw) |
| except (OSError, ValueError, subprocess.SubprocessError, SystemExit) as e: |
| # SystemExit too: sudo() sys.exit()s on 'a password is required', unwinding out of |
| # run_case's timeout handler before the HUNG verdict is recorded -- which leaves |
| # unrecovered_hang False and reports a board still holding a D-state device lock as |
| # not wedged |
| why = f'{cmd[0]}: {type(e).__name__}: {e}' |
| print(why, file=sys.stderr) |
| return subprocess.CompletedProcess(cmd, 1, '', why) |
| |
| |
| def dmesg_tail(): |
| r = _sudo_soft(['dmesg']) |
| lines = [l for l in r.stdout.splitlines() if 'usbtest' in l] |
| return '\n'.join(lines[-8:]) |
| |
| |
| sudo_forbidden = False # set by main when a post-hang recovery was requested |
| |
| |
| def needs_sudo(node): |
| """Device nodes are usually opened directly (udev rule); sudo only if not.""" |
| return not os.access(node, os.W_OK) and os.geteuid() != 0 |
| |
| |
| def run_case(num, dev, testusb, quick, timeout): |
| fs_hs = PARAMS[num][0 if dev['speed'] == '12' else 1] |
| if quick: |
| fs_hs = re.sub(r'-c (\d+)', lambda m: f'-c {max(1, int(m.group(1)) // 8)}', fs_hs) |
| # -A <node> confines testusb's ftw() device scan to the DUT: with -D alone it opens every |
| # usbfs node, blocking on any peer's held device lock (#4047). -A must precede -D, it clears it. |
| cmd = [testusb, '-A', dev['node'], '-D', dev['node'], '-t', str(num)] + fs_hs.split() |
| result = {'num': num, 'name': CASE_NAMES[num], 'params': fs_hs} |
| if needs_sudo(dev['node']): |
| if sudo_forbidden: |
| # a re-enumeration after main's check can hand back a node only root may open |
| result.update(status='FAIL', detail=f"{dev['node']} is not writable: refusing a " |
| 'sudo-wrapped testusb under a recovery request') |
| return result |
| cmd = ['sudo', '-n'] + cmd |
| |
| # NO start_new_session: testusb must stay in OUR process group so the caller's outer |
| # killpg still reaps it. |
| # errors='replace': testusb output is not guaranteed UTF-8, and a strict decode would |
| # raise out of here and out of main(), printing no JSON at all (battery '0/30'). |
| p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| text=True, encoding='utf-8', errors='replace') |
| try: |
| out, _ = p.communicate(timeout=timeout) |
| except subprocess.TimeoutExpired: |
| # Under sudo we only kill the wrapper; its root-owned testusb keeps the inherited |
| # stdout pipe, so the reap below times out and the overrun is reported as HUNG. |
| # Accepted rather than escalated: the rig's udev rules make the device node |
| # writable, so sudo is the exception, and the harness must never sudo-kill a pid |
| # it cannot prove is its own. |
| try: |
| p.kill() |
| except OSError: |
| pass |
| try: |
| out, _ = p.communicate(timeout=5) |
| except subprocess.TimeoutExpired: |
| # SIGKILL had no effect: the child is in uninterruptible sleep on the DUT's |
| # device lock or an in-kernel usbfs ioctl. A finite hold is reaped once it ends, |
| # so watch the child for WEDGE_CONFIRM_S before calling it a wedge (#3944). Under |
| # sudo the child is the wrapper and reaps at once, which proves nothing. |
| if cmd[0] != 'sudo': |
| t0 = time.monotonic() |
| try: |
| p.wait(timeout=WEDGE_CONFIRM_S) |
| waited = time.monotonic() - t0 |
| result.update(status='FAIL', dmesg=dmesg_tail(), late_cleared=True, |
| detail=f'timeout after {timeout}s (the kill landed ' |
| f'{waited:.0f}s late; testusb reaped during the ' |
| f'confirmation watch)') |
| return result |
| except subprocess.TimeoutExpired: |
| pass |
| result.update(status='HUNG', detail=f'testusb stuck in D state after {timeout}s', |
| dmesg=dmesg_tail()) |
| # not JSON: main() watches the child after the recovery step (sudo: cannot) |
| result['_proc'] = None if cmd[0] == 'sudo' else p |
| return result |
| result.update(status='FAIL', detail=f'timeout after {timeout}s', dmesg=dmesg_tail()) |
| return result |
| |
| m = RE_PASS.search(out) |
| if m and int(m.group(1)) == num: |
| secs = float(f'{m.group(2)}.{m.group(3)}') |
| result.update(status='PASS', secs=secs) |
| if num in (27, 28) and secs > 0: |
| opts = dict(zip(fs_hs.split()[::2], fs_hs.split()[1::2])) |
| total = int(opts['-c']) * int(opts['-s']) * int(opts['-g']) |
| result['mbps'] = round(total / secs / 1e6, 2) |
| return result |
| |
| m = RE_FAIL.search(out) |
| if m and int(m.group(1)) == num: |
| result.update(status='FAIL', detail=f'errno {m.group(2)} ({m.group(3)})', |
| dmesg=dmesg_tail()) |
| return result |
| |
| if cmd[0] == 'sudo' and ('password is required' in out or 'a terminal is required' in out): |
| result.update(status='FAIL', detail='sudo needs a password to run testusb: the device node ' |
| 'is not writable') |
| return result |
| |
| # testusb prints '<speed> speed\t<node>\t<ifnum>' once it has opened the node |
| # (tools/usb/testusb.c handle_testdev). Without it testusb never reached the ioctl: an |
| # open failure, a usage error. Not NOTRUN, which sends the reader to the binding profile. |
| if not re.search(rf'^\S+ speed\t{re.escape(dev["node"])}\t\d+$', out, re.M): |
| result.update(status='FAIL', detail=f'testusb did not run the case (rc {p.returncode})', |
| stderr=out.strip()) |
| return result |
| |
| # no result line: the kernel returned -EOPNOTSUPP (capability profile or |
| # in-kernel parameter gate) and testusb skipped silently |
| result.update(status='NOTRUN', detail='case gated off: check binding profile/pattern', |
| stderr=out.strip()) |
| return result |
| |
| |
| def recover_hang(board_json, fw, proc, dev): |
| """The ONE bounded post-hang recovery step, which recovery_reserve budgets: a probe reset |
| where the flasher has one -- it fails the in-flight URB at the source so the ioctl returns |
| and the queued kill lands, is non-destructive and ~130 ms -- else a reflash of the firmware |
| under test (esptool). Never a root-port cycle: that bounces every fixture under the port |
| and cannot remove power anyway (usb-kernel-recover). True only when the killed testusb |
| reaped: a clean reset or flash proves the probe reached the MCU, not that the D-state |
| holder let go.""" |
| if not (board_json and fw): |
| print('no --recover-board/--recover-fw: the device stays wedged', file=sys.stderr) |
| return False |
| try: |
| board = json.loads(board_json) |
| bname, fname = board['name'], board['flasher']['name'] |
| import hil_flash # deferred: stdlib-only unless recovery actually runs |
| flash_fn = hil_flash.flash_primitive(fname) |
| except Exception as e: # malformed/short json, import failure, unknown flasher |
| print(f'recovery unavailable ({e})', file=sys.stderr) |
| return False |
| # our own testusb is D-state on this DUT's node, so a flasher that enumerates by OPENING |
| # usbfs nodes blocks on it, survives SIGKILL and becomes a second stray |
| if not hil_flash.convoy_safe(board['flasher']): |
| print(f'{fname} is not convoy-safe for delivery (it enumerates by ' |
| f'opening usbfs nodes, and this DUT has a D-state holder on ' |
| f'its own node): skipping the recovery rather than adding a ' |
| f'second stray. Give the board a flasher_recover entry (openocd ' |
| f'pinned with vid_pid or over a VID-filtered interface cfg) to ' |
| f'enable recovery for it.', |
| file=sys.stderr) |
| return False |
| reset_fn = hil_flash.reset_primitive(fname) |
| # flasher banners must stay off stdout, which carries the --json result; a raising |
| # flasher must not cost the battery its JSON report |
| try: |
| with redirect_stdout(sys.stderr): |
| if reset_fn: |
| print(f'auto-recovering: resetting {bname} via {fname} probe', file=sys.stderr) |
| ret = reset_fn(board, timeout=RECOVER_RESET_TIMEOUT) |
| print(f'reset rc {ret.returncode}', file=sys.stderr) |
| else: |
| print(f'auto-recovering: reflashing {bname} via {fname}', file=sys.stderr) |
| ret = flash_fn(board, fw, timeout=RECOVER_FLASH_TIMEOUT) |
| if ret.returncode != 0: |
| print(f'reflash failed (rc {ret.returncode})', file=sys.stderr) |
| except Exception as e: |
| print(f'recovery step raised: {e}', file=sys.stderr) |
| time.sleep(RECOVER_SETTLE) # let the freed ioctl unwind |
| if proc is None: |
| print('cannot confirm the recovery: testusb ran under sudo, so the ' |
| 'killed child is the wrapper', file=sys.stderr) |
| return False |
| try: |
| proc.wait(timeout=RECOVER_REAP) |
| except subprocess.TimeoutExpired: |
| print(f'{dev["sysname"]}: testusb still in D state on {dev["node"]} ' |
| f'-- the device lock was never released', file=sys.stderr) |
| return False |
| print('recovery freed the device: testusb reaped', file=sys.stderr) |
| return True |
| |
| |
| def strays_on(serial): |
| """D-state testusb processes holding the usbfs node of the device with this serial. |
| |
| recover_hang only runs inside a live battery: a run cancelled or SIGKILLed mid-wedge |
| leaves its testusb orphaned on the node, and the next run's flasher blocks on it (#4126). |
| Matched from the holder's `-D` node to the device, never by scanning for our serial: the |
| held device's `serial` attribute is exactly the one that cannot be read. |
| """ |
| uo = _uo() |
| return [h for h in uo.dstate_holders('testusb') |
| if (uo.usb_node_serial(h['node']) or '').lower() == serial.lower()] |
| |
| |
| def recover_strays(board, strays): |
| """Free this board's node from `strays` (strays_on) before it is flashed: recover_hang's |
| probe reset through the convoy-safe recovery flasher, then confirm every stray is gone. |
| |
| Returns '' when they are gone, or when the convoy-safe recovery flasher has no reset-only |
| mode (esptool), whose flash then delivers the reset. Otherwise the reason the |
| board stays wedged: flashing it would block on the held node and add another stray. |
| """ |
| import hil_flash |
| name = board['name'] |
| nodes = ', '.join(sorted({s['node'] for s in strays})) |
| pids = ', '.join(str(s['pid']) for s in strays) |
| print(f'{name}: D-state testusb (pid {pids}) holds {nodes}, left by an earlier run') |
| rec = hil_flash.recover_flasher(board) |
| fname = rec['name'] |
| if not hil_flash.convoy_safe(rec): |
| return f'{name}: a D-state testusb holds {nodes} and {fname} cannot deliver a reset past it' |
| reset_fn = hil_flash.reset_primitive(fname) |
| if reset_fn is None: |
| print(f'{fname} has no reset-only mode; its flash delivers the reset') |
| return '' |
| print(f'auto-recovering: resetting {name} via {fname} probe') |
| try: |
| reset_fn({**board, 'flasher': rec}, timeout=RECOVER_RESET_TIMEOUT) |
| except Exception as e: # the reap check below is the verdict either way |
| print(f'recovery step raised: {e}') |
| time.sleep(RECOVER_SETTLE) |
| deadline = time.monotonic() + RECOVER_REAP |
| while True: |
| held = [s for s in strays if _uo().proc_holds(s)] |
| if not held: |
| print('reset freed the device: testusb reaped') |
| return '' |
| if time.monotonic() >= deadline: |
| return (f'{name}: testusb pid {", ".join(str(s["pid"]) for s in held)} still in ' |
| f'D state on {nodes} after a reset via {fname}') |
| time.sleep(0.5) |
| |
| |
| def main(): |
| p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| p.add_argument('--serial', help='board uid (USB serial string) to select the device') |
| p.add_argument('--tier', type=int, choices=sorted(TIER_CASES), |
| help='override tier (default: from device bcdDevice)') |
| p.add_argument('--tests', help='comma-separated case numbers, overrides tier battery') |
| p.add_argument('--quick', action='store_true', help='divide iteration counts by 8') |
| p.add_argument('--json', action='store_true', help='machine-readable output on stdout') |
| p.add_argument('--testusb', default=None, help='path to testusb binary') |
| p.add_argument('--timeout', type=int, default=120, help='per-case timeout in seconds') |
| p.add_argument('--recover-board', help='board JSON (name + flasher) for the post-hang ' |
| 'recovery (a probe reset, else a reflash); without it a HUNG case leaves ' |
| 'the device wedged') |
| p.add_argument('--recover-fw', help='firmware path the post-hang recovery reflashes when ' |
| 'the flasher has no reset primitive') |
| 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 bound set this BELOW it, ' |
| 'reserving the remainder for the post-hang recovery -- see ' |
| 'recovery_reserve() for what that step costs') |
| args = p.parse_args() |
| t_start = time.monotonic() |
| sys.stdout.reconfigure(line_buffering=True) # per-case results visible when piped/logged |
| |
| testusb = args.testusb or shutil.which('testusb') or os.path.expanduser('~/testusb') |
| if not os.access(testusb, os.X_OK): |
| sys.exit('testusb binary not found: build kernel tools/usb/testusb.c ' |
| 'and install it, or pass --testusb') |
| |
| # retry briefly: after a flash the enumeration may still be settling, and a dual-port |
| # part's stale same-serial node takes a moment to drop off (see find_device) |
| deadline = time.monotonic() + 8 |
| while True: |
| dev = find_device(args.serial) |
| if dev and 'ambiguous' not in dev: |
| 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') |
| sys.exit(f'no {VID}:{PID} device' |
| + (f' with serial {args.serial}' if args.serial else '')) |
| time.sleep(0.5) |
| |
| # a stale/foreign device advertising an out-of-range tier must not silently run an |
| # empty battery ('0/0 passed' would read as green in CI) |
| tier = args.tier or dev['tier'] |
| if not 1 <= tier <= max(TIER_CASES): |
| sys.exit(f"device advertises tier {tier} (bcdDevice ...{tier:02x}); reflash a usbtest build " |
| f"or pass --tier 1..{max(TIER_CASES)} — refusing to run an unknown/empty battery") |
| if args.tests: |
| cases = [] |
| for tok in args.tests.split(','): |
| tok = tok.strip() |
| if not tok.isdecimal() or int(tok) not in PARAMS: # isdecimal rejects unicode digits |
| sys.exit(f'--tests: {tok!r} is not a known case number (valid 0..{max(PARAMS)})') |
| # results are keyed by case number: an unrun duplicate would vanish from the report |
| if int(tok) in cases: |
| sys.exit(f'--tests: case {int(tok)} is listed twice') |
| cases.append(int(tok)) |
| else: |
| cases = [n for t in range(1, tier + 1) for n in TIER_CASES[t]] |
| |
| info = f"device {dev['serial']} {dev['node']} speed={dev['speed']} tier={tier}" |
| if not args.json: |
| print(info) |
| |
| # before touching the device: an incompatible host exits here, before any bind |
| check_host_compat(dev) |
| # run_case would wrap testusb in sudo, where a HUNG case's reap only proves the wrapper |
| # exited, so the recovery the caller asked for could never be confirmed |
| global sudo_forbidden |
| sudo_forbidden = bool(args.recover_board) |
| if sudo_forbidden and needs_sudo(dev['node']): |
| sys.exit(f"{dev['node']} is not writable: testusb would run under sudo, where a " |
| 'post-hang recovery cannot be confirmed; install tools/88-tinyusb.rules') |
| |
| register_usbtest_id() |
| results = [] |
| unrecovered_hang = False |
| try: |
| bind_usbtest(dev) |
| set_pattern(0) # tier 1 firmware sources zeros; also required by perf cases 27/28 |
| |
| abort_reason = None # set on any early exit; drives the BUDGET back-fill below |
| for num in cases: |
| # An ordinary case timeout is a FAIL and the loop continues, each burning |
| # --timeout+5s, so without this the run can still be in the case loop when the |
| # outer timeout SIGKILLs it before it emits JSON. Checked before dispatch: worst |
| # overshoot is one case. |
| if args.budget and time.monotonic() - t_start > args.budget: |
| abort_reason = f'battery budget {args.budget}s exhausted' |
| break |
| results.append(run_case(num, dev, testusb, args.quick, args.timeout)) |
| r = results[-1] |
| if not args.json: |
| extra = f" {r.get('secs', '')}s" if r['status'] == 'PASS' else f" {r.get('detail', '')}" |
| extra += f" {r['mbps']} MB/s" if 'mbps' in r else '' |
| print(f"test {num:2d} {r['name']:22s} {r['status']:6s}{extra}") |
| if r.get('late_cleared'): |
| # something held the DUT's device lock past the kill: the rest of this |
| # battery could stall the same way, and a re-run costs less than the guesswork |
| abort_reason = f'battery ended on a late-cleared timeout in case {num}' |
| print(f'case {num} timed out; the kill landed late, not a wedge', file=sys.stderr) |
| break |
| if r['status'] == 'HUNG': |
| # Set before the recovery: an exception there reaches the finally with it set. |
| unrecovered_hang = True |
| abort_reason = 'battery aborted on a kernel-side hang' |
| print('aborting battery: kernel-side hang, device wedged mid-transfer', |
| file=sys.stderr) |
| unrecovered_hang = not recover_hang(args.recover_board, args.recover_fw, |
| r.pop('_proc'), dev) |
| break |
| if (num in UNLINK_CASES or r['status'] != 'PASS') and \ |
| not _tt().reset_tt(dev['sysname'], dev['speed']): |
| # unconfirmed (hil_tt.reset_tt): the rest may stall on the same port. Not a |
| # wedge of this device -- a sibling port's enumeration can hold the hub. |
| abort_reason = f'Reset_TT after case {num} unconfirmed' |
| if r['status'] == 'PASS': # the battery must not read as a clean pass |
| r.update(status='FAIL', detail=abort_reason) |
| break |
| # re-resolve: a re-enumeration changes the node path. The concrete serial, not |
| # args.serial (may be None), so this never retargets another cafe:4010 device. |
| live = find_device(dev['serial']) |
| if live and live.get('ambiguous'): |
| # the dual-port WCH parts answer one serial on two nodes around a |
| # re-enumeration: stop and latch board_wedged rather than file the rest |
| # under a device we cannot identify |
| abort_reason = (f'serial {dev["serial"]} matches more than one device ' |
| f'({", ".join(live["ambiguous"])}) after case {num}') |
| unrecovered_hang = True |
| break |
| if not live: |
| # a serial read that gave up looks like a disconnect but is probably a |
| # wedge: fail closed and latch board_wedged |
| if _uo().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 |
| abort_reason = f'device dropped off the bus after case {num}' |
| # after the LAST case nothing is back-filled, so the run would read as a clean |
| # pass; a FAIL/NOTRUN keeps its own errno and dmesg |
| if num == cases[-1] and r['status'] == 'PASS': |
| r.update(status='FAIL', detail=abort_reason) |
| break |
| dev = live |
| if abort_reason: |
| # one BUDGET entry per case never dispatched: a shrunken denominator (4/5, not |
| # 4/30) hides that most of the battery never ran |
| ran = {r['num'] for r in results} |
| results += [{'num': n, 'status': 'BUDGET', 'detail': f'not run: {abort_reason}'} |
| for n in cases if n not in ran] |
| finally: |
| # Nothing is written: remove_id/unbind take the uninterruptible device_lock (see |
| # register_usbtest_id; the unbind path has wedged a host xHCI), and cafe:4010 is |
| # usbtest's own PID, so a binding left behind claims nothing else. |
| if unrecovered_hang and any(r['status'] == 'HUNG' for r in results): |
| # testusb still holds the device lock in a usbfs ioctl (see usb-kernel-recover) |
| print('unrecovered hang: the device keeps its usbfs lock until the DUT is reset ' |
| 'or the host is power-cycled (usb-kernel-recover skill)', file=sys.stderr) |
| elif unrecovered_hang: |
| # the ambiguous/unreadable-serial aborts: no case hung, so no held lock is known |
| print(f'reported wedged: {abort_reason}', file=sys.stderr) |
| |
| # BUDGET, not NOTRUN: NOTRUN is a case the kernel gated off, a real result that stays in |
| # `failed`; counting never-run cases as failures sends a maintainer bisecting them |
| notrun = [r for r in results if r['status'] == 'BUDGET'] |
| failed = [r for r in results if r['status'] not in ('PASS', 'BUDGET')] |
| ran = len(results) |
| if args.json: |
| # `wedged` also covers the aborts with no HUNG case, which the caller cannot infer |
| print(json.dumps({'serial': dev['serial'], 'speed': dev['speed'], 'tier': tier, |
| 'passed': ran - len(failed) - len(notrun), |
| 'failed': len(failed), 'notrun': len(notrun), |
| 'wedged': bool(unrecovered_hang), |
| 'cases': results}, indent=2)) |
| else: |
| print(f"{ran - len(failed) - len(notrun)}/{ran} passed" |
| + (f", {len(notrun)} not run" if notrun else "")) |
| for r in failed: |
| print(f" FAILED test {r['num']}: {r.get('detail', '')}") |
| if r.get('dmesg'): |
| print(' ' + r['dmesg'].replace('\n', '\n ')) |
| if failed: |
| print('diagnose failed cases with the usbtest skill, "Failed case" ' |
| '(.claude/skills/usbtest/SKILL.md)', file=sys.stderr) |
| # BUDGET entries and a wedged device after a passing last case are not a success either |
| return len(failed) + len(notrun) or int(unrecovered_hang) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |