Repository navigation
test: HIL sweep for how long a host USB-drain stall the firmware tolerates - #176
boringethan wants to merge 1 commit into
Conversation
…rates PipelineRunner consumes sinks inline on the thread that drains LiveUsbSource._batch_queue, so a slow sink stops dev.read() being called. With ~0.2 s of packet queue plus a shared 4-slot batch queue the host absorbs only a few hundred ms before back-pressure reaches the sensor's 4-deep (~100 ms) histo queue. In the field that escalated to a module dropping off the USB bus, with no host-side warning at any point: StreamInterface only warns after a full second of blocked put. StallSink blocks the drain for a configurable time every Nth live batch (injected through the public ScanRequest.sinks list — no library change), and a log-signature collector records which firmware failure stage each stall length reaches: queue-full -> USB TX blocked -> camera overrun -> false end-of-scan -> endpoint lost. The false-end-of-scan column is the number sensor-fw#101 needs to be specified against; the queue-full column is the acceptance metric for #116 / PR #113. Only the #116 budget is asserted (STALL_BUDGET_S = 0.3 s); shipping code is expected to fail it, which is the point. The unit file feeds the classifier verbatim firmware printf from the bloodflow-app#300 log — including the multi-line records a line-oriented matcher would miss — so the harness is trusted before it costs bench time. Refs #116, OpenwaterHealth/openmotion-bloodflow-app#300 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First HIL run — sweep results + 5 defects foundRan this harness on hardware for the first time today (console + both sensors, Results — the deliverablesensor-fw 1.8.2-rc.4
Tolerance boundary: between 0.5 s and 1.0 s. The headline number for #116: at a 1.0 s stall the host lost 667 frames and Two controls:
Defects
Portability note (not a defect): Design findings
Suite-wide: a wedged rig (both sensors in USB Fixes for 1–5 are written and were used to produce the numbers above; happy to |
Addendum — correction: the numbers above were taken uncompressedCorrecting my previous comment. Every figure in it (the 0.5–1.0 s tolerance boundary, the The bloodflow app pushes Compressed results (rc.4, flags
|
| stall | queue-full | camera overrun | host warned |
|---|---|---|---|
| 0.1–0.5 s | – | – | – |
| 1.0 s | 1084 | 2 | – |
| 2.0 s | 1651 | – | 36 |
The 1.0 s point reproduced the field failure exactly — the same chain as the
bloodflow-app scan of 2026-07-24, on both modules simultaneously:
Error in USART2: Overrun error / failed to setup receive for Camera 1 channel
Camera 1 has stopped posting data
Camera 1: rail off for 10000 ms (regulator cool-off)
[DIAG] overruns c1-c8: 1 0 0 0 0 0 0 0
Uncompressed never reached it: 18 scan-points across three sweeps, 3278 USB failures,
zero camera overruns.
Repeat experiment — 15x the 1.0 s point per arm
| config | full-load trials | camera deaths | mean queue-full |
|---|---|---|---|
| compressed | 24 | 1 (~4%) | 1084 |
| uncompressed | 18 | 0 | 1324 |
Not statistically significant (Fisher p≈1.0) — 15 trials cannot separate 4% from 0%.
Reporting it as characterisation, not proof.
The mean-queue-full column is the more informative result: the uncompressed arm sustained
22% more back-pressure and still killed nothing. So the escalation to camera death is
not driven by back-pressure magnitude — if it were, uncompressed should be worse. What
distinguishes the arms is the compressed path's extra per-frame work, i.e. sensor-fw#70.
Three further defects
-
Doesn't pin the firmware debug flags.
printf_ondoesflags | DEBUG_FLAG_USB_PRINTF
and restoresprevon teardown — correct in isolation, but it means the effective
configuration is inherited from whatever ran before. For a harness whose stated purpose is
measuring shipping behaviour, the production set should be pinned explicitly. Note flags
persist across runs, so OR-only logic cannot turn a bit off: my first attempt at an
uncompressed arm silently stayed compressed because the sensors still held0xC0. -
USB_TX_BLOCKEDonly matches the uncompressed send path. The regex is
USBD_HISTO_SendData failed|COMM USB TX Timeout, but the compressed path logs
[CMP] USB FAIL: status=%d. In production config the column read 0 while the log
carried 14350[CMP] USB FAILlines. Needs\[CMP\] USB FAILadded, or the column
silently reads zero in the only configuration we ship. -
No per-point recovery — a camera death corrupts every later point. When the overrun
killed camera 1 at run 9 of 15, it stayed dead for runs 10–15 (sensor-fw#102: rail
re-powered, FPGA held in reset). Packet census:4 cams→ 9238 packets before,
3 cams→ 4889 after; queue-full stepped 1084 → 770 (≈¾ load). Those six points were
reported as normal data while running at 75% load. Suggest asserting the active camera
count against the requested mask per point, and re-configuring (or failing) on mismatch.
As it stands, the escalation the harness exists to detect silently invalidates the rest
of the sweep.
Side finding for sensor-fw#102
That camera stayed dead across six complete scan stop/start cycles, not just the
remainder of one scan — a full teardown and restart does not revive it; only
configure_camera_sensors does. Worth weighing against the "not planned" rationale: this is
a channel lost until something reprograms the FPGA, which for back-to-back clinical scans
means until the app is restarted.
What
A HIL harness that answers the question openmotion-sdk#116 has never had a number for: how long can the host stall the USB drain before the sensor firmware gives up?
PipelineRunnerconsumes sinks inline on the thread drainingLiveUsbSource._batch_queue, so a slow sink stopsdev.read()from being called. With ~0.2 s of packet queue plus a shared 4-slot batch queue, the host absorbs only a few hundred ms before back-pressure reaches the sensor's deliberately shallow 4-deep (~100 ms) histogram queue. In the field (OpenwaterHealth/openmotion-bloodflow-app#300) a sub-second stall on a 20-minute scan escalated to the RIGHT module dropping off the USB bus — with no host-side warning at any point, becauseStreamInterfaceonly warns after a full second of blockedput.How
StallSinkblocks the drain for a configurable time every Nthlivebatch. It goes in through the publicScanRequest.sinkslist (all_sinks = default_sinks + list(request.sinks)), so no library code changes — the harness measures shipping behaviour, not a patched variant. Default storage sinks stay in the chain so the number is the real system's tolerance.A log-signature collector then records which firmware failure stage each stall length reaches:
HISTO_QUEUE_FULLUSB_TX_BLOCKEDUSBD_HISTO_SendData failed/COMM USB TX TimeoutCAMERA_OVERRUNError in USARTn/SPIn: Overrun error,failed to setup receiveFALSE_SCAN_ENDENDPOINT_LOSTHOST_PUT_WARNEDSweep is 0.1 / 0.2 / 0.3 / 0.5 / 1.0 / 2.0 s over 40 s scans, ~5 minutes of bench time. The summary test prints an escalation table.
Assertions
Deliberately minimal — this is characterization. The one hard assertion is
STALL_BUDGET_S = 0.3 s: a stall within budget must not reach queue-full. Shipping code is expected to fail that, which is the point; it's the metric #113 has to move.test_zz_summaryis a reporter, and the sweep fails fast if a stall takes a module off the bus, since later sweep points would be untrustworthy without a power-cycle.Verification
tests/test_usb_drain_stall_unit.py(15 tests,unitmarker, no hardware) feeds the classifier verbatim firmware printf from the #300 field log — including the multi-line records that a line-oriented matcher would silently miss, the SPI-not-USART overrun variant that killed L7, and benign traffic that must not be classified. The harness is trusted before it costs bench time.The HIL sweep itself has not been run yet — it needs a console + both sensors. That bench run is the deliverable; this PR is the instrument.
Refs #116, OpenwaterHealth/openmotion-bloodflow-app#300
🤖 Generated with Claude Code