Skip to content

feat(recovery): opt-in deep USB recovery rung below the port reset - #26

Merged
andrescera merged 1 commit into
mainfrom
feat/deep-port-recovery
Jul 31, 2026
Merged

feat(recovery): opt-in deep USB recovery rung below the port reset#26
andrescera merged 1 commit into
mainfrom
feat/deep-port-recovery

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

Adds one opt-in escalation below the existing USB port reset, for the case the
reset itself cannot fix: a reset the device never comes back from.

New property deep-port-recovery (boolean, default false). When
enabled, and only after the port reset and every reopen inside
reset-settle-max-ms have already failed, the element escalates once:

  1. device-level authorized 0→1 — if the device object survived and still
    reports the vid:pid captured before the reset;
  2. port-level disable 1→0 (1 s hold) — if the device object is gone.

Then one further settle pass, which still requires a delivered frame, before
falling through to the usual RESOURCE/READ disconnect error.

The mechanism lives in a new standalone helper (usb_port_recovery.{c,h}) with
no GObject, libuvc or libusb dependency and a parameterised sysfs root, so the
same rung can be driven from outside this element later without reimplementing
a privileged mechanic.

Why

Two recorded incidents left a camera at error -71 after USBDEVFS_RESET
cycling, needing a physical replug. In that state the kernel has exhausted
PORT_INIT_TRIES, logged unable to enumerate USB device, and created no
usb_device — so there is nothing for another libusb_reset_device() to reset,
and the element could only report a disconnect.

The originally-specified attribute was wrong. Measured on the board across
14 ports and 12 devices: authorized exists only on DEVICE directories,
disable only on PORT directories, and neither object carries the other's. A
device that failed enumeration has no device directory at all — which is why the
port path is resolved from the device's port symlink before the reset
rather than derived afterwards.

Why it defaults to false — this is a measured decision, not caution. A
live error -71 device was present on the board and the rung was driven against
it three times. The port cycle executes exactly as designed: PORTSC goes
Powered Connected EnabledPowered-off Not-connected Disabled and returns
with a genuine connect-status-change, and the kernel re-runs enumeration with a
fresh address. The device did not come back, 0/3. The kernel had already
been doing the same power cycle on every one of its own retries
(usb usb9-port1: attempt power cycle) to no effect. Enabling this by default
would spend a second settle budget and root-only sysfs writes on every wedge
with no evidence behind it.

No VBUS claim is made. The hub control-transfer round-trip answers
wHubCharacteristic 0x000a"No power switching" — corroborated by
HCCPARAMS1 = 0x0220fe64 (PPC = 0), while PORTSC still toggles. The
capability bit is unreliable here and the board's Type-C rail is a separate GPIO
regulator, so the code, logs and docs all call this a logical port-state
cycle
.

How to verify

cmake -B build && cmake --build build && ctest --test-dir build --output-on-failure
cmake -B build-san -DENABLE_SANITIZERS=ON && cmake --build build-san \
  && ctest --test-dir build-san --output-on-failure
  • 135/135 plain, 190/190 with ASan+TSan.
  • 16 new cases: 9 helper (synthetic sysfs tree), 6 ladder-ordering, 1 API guard.
  • Red-first: neutralise the escalation call and the ordering cases fail 3/6
    with the deep rung must fire once …, got 0. The two that pass in both states
    are negative controls that assert the rung does not run.
  • Property surface: gst-inspect-1.0 libuvch264src | grep deep-port-recovery
    shows Boolean. Default: false.

Risks

  • Roughly doubles worst-case recovery time when enabled (a second full
    reset-settle-max-ms), plus a 1 s hold and a disable write that was measured
    blocking ≈2.5 s on the hub device lock. Mitigated by being opt-in and off.
  • Needs root. cerastream.service has no User=, so an embedded element can
    write; a hand-run gst-launch gets EACCES and the rung reports denied rather
    than appearing to have tried.
  • Could disturb an unrelated device — addressed by two vetoes, both from
    measured hardware: it refuses any port whose hub carries another enumerated
    device (ganged hubs are real here — the board's Terminus 1a40:0101 reports
    Ganged power switching), and refuses a device whose vid:pid no longer matches,
    since bus addresses are recycled.
  • Existing behaviour is untouched. A failed reset (LIBUSB_ERROR_NO_DEVICE)
    still returns immediately without escalating, so
    absent_device_reset_fails_through and its exact open-count assertion still
    hold. No test was deleted, skipped or weakened.

Board evidence SHA256:
918d9ab328bb01f0151ebdffdbc6d8e88943e023f07ae74c1459506ddb9361d3

A USB port reset can strand the device it was meant to fix: the kernel
re-enumerates, every attempt fails `error -71`, and after PORT_INIT_TRIES it
logs `unable to enumerate USB device` and stops. At that point there is no
`usb_device` object left, so a second `libusb_reset_device()` has nothing to
reset and the element just reports a disconnect.

This adds one escalation for that case, reached only after the reset AND every
reopen inside `reset-settle-max-ms` have already failed:

  - device-level `authorized` 0->1, when the device object survived and still
    reports the vid:pid captured before the reset;
  - port-level `disable` 1->0, when it did not.

Then one further settle pass, which still requires a delivered frame, before
falling through to the usual RESOURCE/READ error.

The mechanism lives in a standalone, sysfs-root-parameterised helper with no
GObject, libuvc or libusb dependency, so the same rung can be driven from
outside this element later without reimplementing a privileged mechanic.

The plan's original attribute was wrong and is corrected here. Measured on the
board across 14 ports and 12 devices: `authorized` exists only on DEVICE
directories, `disable` only on PORT directories, and neither object carries the
other's. A device that failed enumeration has no device directory at all, which
is why the port path is resolved from the device's `port` symlink BEFORE the
reset rather than derived afterwards.

Two safety vetoes, both from measured hardware behaviour. The rung refuses a
port whose hub carries any other enumerated device, because ganged hubs are
real here (the board's Terminus 1a40:0101 reports `Ganged power switching`) and
no sysfs attribute reports a hub's switching mode. It also refuses a device
whose vid:pid no longer matches, because bus addresses are recycled.

Default FALSE, and for a measured reason. A live `error -71` device was present
on the board and the rung was driven against it three times: the port cycle
executes exactly as designed - PORTSC drops to Powered-off/Not-connected and
returns with a genuine connect-status-change, and the kernel re-runs
enumeration with a fresh address - and the device did not come back, 0/3. The
kernel had already been performing the same power cycle on every one of its own
retries (`usb usb9-port1: attempt power cycle`) to no effect. Enabling this by
default would spend a second settle budget and root-only sysfs writes on every
wedge with no evidence behind it.

No VBUS claim is made anywhere. The hub control-transfer round-trip reports
`wHubCharacteristic 0x000a` - "No power switching" - corroborated by
HCCPARAMS1=0x0220fe64 (PPC=0), while PORTSC still toggles; the capability bit
is unreliable here and the board's Type-C rail is a separate GPIO regulator.
The code, the logs and the docs all call this a logical port-state cycle.

Tests: 9 helper cases against a synthetic sysfs tree, 6 ladder-ordering cases,
1 API-surface guard. Written red-first; the ordering cases fail 3/6 with the
escalation call neutralised. 135/135 plain and 190/190 under ASan+TSan.

Board-Evidence-SHA256: 918d9ab328bb01f0151ebdffdbc6d8e88943e023f07ae74c1459506ddb9361d3
@andrescera
andrescera merged commit a312ddc into main Jul 31, 2026
5 checks passed
@andrescera
andrescera deleted the feat/deep-port-recovery branch July 31, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant