From fb22410ec8d120c26b7f9d0effad6ebb20cb7177 Mon Sep 17 00:00:00 2001 From: Andres Cera Date: Fri, 31 Jul 2026 12:37:31 -0500 Subject: [PATCH] feat(recovery): opt-in deep USB recovery rung below the port reset 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 --- AGENTS.md | 34 +- README.md | 32 ++ libuvch264src/src/gstlibuvch264src.c | 180 +++++++- libuvch264src/src/gstlibuvch264src_internal.h | 24 ++ libuvch264src/src/sources.txt | 1 + libuvch264src/src/usb_port_recovery.c | 271 ++++++++++++ libuvch264src/src/usb_port_recovery.h | 107 +++++ tests/CMakeLists.txt | 46 ++ tests/test_compat.c | 27 ++ tests/test_reconnect.c | 292 +++++++++++++ tests/test_usb_port_recovery.c | 408 ++++++++++++++++++ 11 files changed, 1409 insertions(+), 13 deletions(-) create mode 100644 libuvch264src/src/usb_port_recovery.c create mode 100644 libuvch264src/src/usb_port_recovery.h create mode 100644 tests/test_usb_port_recovery.c diff --git a/AGENTS.md b/AGENTS.md index 945c90b..e74d4ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,9 @@ gstlibuvch264src/ │ │ ├── spspps_cache.{c,h} # SPS/PPS/VPS disk cache (path safety, resolution key) │ │ ├── spspps_path.h # Pure path-builder (no GObject dep, unit-testable) │ │ ├── ptz_control.{c,h} # PTZ probe/set helpers + control socket bind/unbind/thread -│ │ ├── uvc_device.{c,h} # USB teardown helper + V4L2 capability probe -│ │ └── quirks.{c,h} # vid:pid quirk table: DOUBLE_PROBE + MAX_PIXEL_RATE; one row (Osmo Pocket 3, sets both) +│ │ ├── uvc_device.{c,h} # USB teardown helper + V4L2 capability probe +│ │ ├── quirks.{c,h} # vid:pid quirk table: DOUBLE_PROBE + MAX_PIXEL_RATE; one row (Osmo Pocket 3, sets both) +│ │ └── usb_port_recovery.{c,h} # deep USB recovery: device `authorized` / port `disable` rung (no GObject, no libuvc, sysfs-root parameterized) │ ├── docs/notes/ │ │ ├── reconnect-spike.md # Spike verdict: libuvc dead-handle teardown is SAFE │ │ ├── bmaxpayload-analysis.md # max-payload bandwidth tuning analysis @@ -68,6 +69,7 @@ gstlibuvch264src/ │ ├── test_cache_race.c # SPS/PPS cache concurrent read/write race (TSan) │ ├── test_transfer_buffers.c # transfer-buffers property: sentinel/clamp/reconnect re-arm, fork-only gated │ ├── test_quirks.c # vid:pid quirk lookup/limits, QUIRK_DOUBLE_PROBE, Osmo pixel-rate cap + double probe +│ ├── test_usb_port_recovery.c # deep-recovery helper against a synthetic sysfs tree: rung selection + leaf-target vetoes │ ├── board/ # MANUAL, hardware-only; never registered with ctest │ │ └── wedge-recovery.sh# Gated-SIGKILL wedge + real-libusb_reset_device recovery timing │ ├── fuzz_nal.c # NAL parser fuzz harness (libFuzzer entry point) @@ -103,6 +105,7 @@ gstlibuvch264src/ | PTZ probe/set + control socket | `libuvch264src/src/ptz_control.c` | | USB teardown + V4L2 probe | `libuvch264src/src/uvc_device.c` | | Wedged-device USB port-reset recovery | `libuvch264src/src/gstlibuvch264src.c` → `gst_libuvc_h264_src_reset_silent_device()` | +| Deep USB recovery rung (post-reset `error -71`) | `libuvch264src/src/usb_port_recovery.c`; ladder position in `gstlibuvch264src.c` → `gst_libuvc_h264_src_deep_recovery()` | | SPS/PPS cache | `libuvch264src/src/spspps_cache.c` | | Error mapping helper | `libuvch264src/src/gstlibuvch264src_error.c` | | vid:pid quirk seam (table + lookup) | `libuvch264src/src/quirks.c` | @@ -186,6 +189,27 @@ Frames the device must deliver after a recovery before the one-shot port reset r Controls the silence-triggered wedge-recovery port reset. The default remains `true` and preserves the always-on recovery behavior. Set it to `false` for a device or scenario where issuing `USBDEVFS_RESET` risks stranding the camera; sustained silence then skips the reset and falls through to the normal `RESOURCE/READ` disconnect error path. +### `deep-port-recovery` (boolean, default `false`) + +Opt-in escalation for the case the port reset cannot fix: a reset the device never comes back from. The kernel retries enumeration `PORT_INIT_TRIES` times, each failing `error -71` (`EPROTO`), then logs `unable to enumerate USB device` and stops — and at that point the device object is gone, so there is nothing left for another `libusb_reset_device()` to reset. + +When `true`, the element escalates **once per silence episode**, and only after the port reset AND every reopen inside `reset-settle-max-ms` have already failed: + +1. **Device-level `authorized` 0→1**, if the device object survived and still reports the vid:pid captured before the reset. A logical deauthorise + re-probe of exactly one device; the port's power state is untouched. +2. **Port-level `disable` 1→0** (1 s hold), if the device object is gone. Clears `PORT_POWER` and the latched `C_CONNECTION`/`C_ENABLE` bits so the hub sees a genuine connect-change. + +Then one further settle pass — polling, reopen, and a **delivered frame** — before falling through to the usual `RESOURCE/READ` disconnect error. This means an enabled rung can roughly **double** the worst-case recovery time, which is a second reason it is opt-in. + +**Why it defaults to `false`.** On board `192.168.78.131` the rung is proven to **fire** and proven **not to recover**: the port cycle drives xHCI `PORTSC` from `Powered Connected Enabled` to `Powered-off Not-connected Disabled Link:Disabled` and back with `Change: CSC`, the kernel re-runs enumeration from scratch with a fresh address — and the device still failed `error -71`, 3/3 cycles at a 10 s hold. Turning it on by default would spend a second budget and root-only sysfs writes on every wedge with no evidence behind it. Flipping the default requires a separate commit with a real ×3 recovery. See `.omo/evidence/device-platform-wave4/task-11-board-proof.md`. + +**Bounds and safety.** + +- **It is a port STATE cycle, not a proven VBUS removal.** `PORTSC` reports `Powered-off`, but the same root hub advertises `wHubCharacteristic 0x000a` = *"No power switching"* and the board's Type-C 5 V rail is a separate GPIO regulator. Nothing here establishes that VBUS physically dropped; the logs say "logical re-probe" for that reason. +- **Never touches a hub carrying another device.** No sysfs attribute reports a hub's power-switching mode, and ganged hubs are real on this hardware (the board's Terminus `1a40:0101` reports `Ganged power switching`), so a port whose hub has any other enumerated child is refused outright. +- **Never touches a device that is not ours.** Bus addresses are recycled; the vid:pid captured before the reset is re-checked, and a mismatch at either the device path or the port's current occupant is refused. +- **Needs privilege.** USB sysfs attributes are root-writable only. Embedded in a root service (cerastream's unit has no `User=`) the writes land; run as a normal user they return `denied` and are logged as such rather than silently doing nothing. +- The target is resolved from sysfs **while the device is still open** — after a failed re-enumeration there is no device directory left to resolve a port from. + ### `deliverable-caps` (GstCaps, read-only) The post-quirk mode ladder `negotiate()` actually selects from — what this element will **accept**, not what the device **advertises**. `NULL` until a device has been negotiated; a consumer must read `NULL` as *unknown*, never as *no modes*. @@ -230,6 +254,7 @@ Set `control-socket=true` to enable. The socket accepts JSON commands for `PAN_T - The one-shot re-arms only after the device has PROVEN it recovered (`reset-rearm-frames`, default 30 — ~1 s at 30 fps). Re-arming on the first frame back lets a device that emits one frame and re-wedges reset the port forever. - Cost to a genuinely absent device: nothing — the reset fails and the error surfaces immediately, without spending the budget. - Real-hardware coverage: `tests/board/wedge-recovery.sh` (manual, board-only, never registered with ctest) induces a real wedge with a gated SIGKILL and measures reset-to-advancing-frames against the bound. +- **When the reset itself does not take**, the opt-in `deep-port-recovery` rung escalates once more (see its property docs). It is default-off, sits strictly below the reset in the ladder, and is skipped entirely whenever the reset already recovered the device. **Disconnect detection (always on):** When the UVC device is unplugged mid-stream, libuvc stops delivering frames silently — in callback mode it does **not** invoke the callback with a NULL frame, it simply goes quiet (Task 4 spike). `create()` therefore infers a disconnect from sustained silence: it counts consecutive `g_async_queue_timeout_pop` timeouts (each `TIMEOUT_DURATION` = 1 s), and after `DISCONNECT_TIMEOUT_COUNT` (5) in a row — i.e. ~5 s with no frame — it treats the device as gone. The counter resets on every real frame and in `start()`, so an isolated gap never trips it. On a confirmed disconnect with `reconnect=false` (the default), it posts `GST_ELEMENT_ERROR(RESOURCE, READ)` and returns `GST_FLOW_ERROR`; downstream (cerastream) handles the error. @@ -407,6 +432,11 @@ The `.deb` version is derived **purely from git tags** at publish time via the ` - Do NOT drop the `uvc_exit()`/`uvc_init()` on the recovery path — a libuvc context held across a port reset cannot reopen the re-enumerated device, and without the refresh the recovery never completes. - Do NOT treat `LIBUSB_ERROR_NOT_FOUND` from `libusb_reset_device()` as a failure — it means the reset re-enumerated the device, which is success. - Do NOT re-arm the port-reset one-shot on the first frame after a recovery; a device that emits one frame and re-wedges would then reset the port in an endless loop. +- Do NOT write `authorized` on a `usbN-portM` directory or `disable` on a device directory. They live on different objects and neither exists on the other — measured 14/14 ports and 12/12 devices on the board. A device that failed enumeration has **no device directory at all**, which is exactly why the port path must be captured before the reset. +- Do NOT describe the `deep-port-recovery` port cycle as a power cycle in logs, docs or commit messages. `PORTSC` reaching `Powered-off` is not proof the board's VBUS rail dropped, and the root hub in question advertises "No power switching" while still honouring the write. Only a hub control-transfer round-trip that proves per-port switching would justify the stronger claim, and this element does not make one. +- Do NOT run the deep rung on a port whose hub carries another device, and do NOT replace the sibling-port check with a hub-descriptor capability read. Ganged hubs exist on this hardware, and the descriptor is demonstrably unreliable here (the xHCI root hub reports "No power switching" yet its `PORTSC` power bit does toggle). +- Do NOT enable `deep-port-recovery` by default on the strength of the rung firing. It fired cleanly 3/3 on real hardware and recovered nothing; the default flips only on a board-proven ×3 recovery. +- Do NOT reach the deep rung before the reset, or when the reset already recovered the device. The ordering is pinned by `test_reconnect.c`'s `deep_recovery_*` cases and is the whole point of the rung being an escalation. - Do NOT give the reset-recovery path the full `reconnect` retry ladder — the exhaustion tests assert an exact reopen-attempt count, and `reconnect` is the property that buys the ladder. - Do NOT call `force_usb_release()` before `uvc_close()` — it was a double-free/UAF vector; the fix lets `uvc_close()` own the single `libusb_close()`. - Do NOT push one `GstBuffer` per NAL unit. The pad templates advertise `alignment=au`; the element must emit one buffer per ACCESS UNIT. Splitting a multi-slice picture across buffers that each claim to be a whole access unit mis-frames every downstream consumer that trusts the caps. diff --git a/README.md b/README.md index a7b5b13..f396cc5 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,37 @@ gst-launch-1.0 libuvch264src index=0 reset-settle-max-ms=15000 \ ! queue ! h264parse ! fakesink ``` +### Deep port recovery (opt-in, default off) + +A port reset can leave a device that never comes back: the kernel retries +enumeration, each attempt fails with `error -71`, and it gives up with +`unable to enumerate USB device`. There is no device left to reset a second +time — only the port. + +`deep-port-recovery=true` adds one escalation for exactly that case, after the +reset and all of its reopens have already failed. If the device object survived +it does a device-level `authorized` re-probe; if it did not, it cycles the +port's `disable` attribute so the hub sees a fresh connect-change. Either way it +then requires a real delivered frame before calling the device recovered, and +otherwise falls through to the usual `RESOURCE/READ` error. + +It writes USB sysfs, so it needs to run as root — embedded in a root service it +works, and as a normal user it logs that it was denied instead of pretending. +It refuses to act on a port whose hub carries any other device, and on a device +whose vid:pid no longer matches the one it recorded before the reset. + +**This is a logical port-state cycle, not a proven VBUS power removal.** It is +off by default because on the reference board it demonstrably fires — the port +really does drop to `Powered-off` and re-enumerate from scratch — and the +`error -71` device still did not come back, 3 out of 3 attempts. Turn it on only +where it has been shown to help. + +``` +gst-launch-1.0 libuvch264src index=0 deep-port-recovery=true \ + ! video/x-h264,width=1920,height=1080,framerate=30/1 \ + ! queue ! h264parse ! fakesink +``` + ### Reconnect on Disconnect Set `reconnect=true` to enable in-element auto-reconnect when the device is unplugged @@ -340,6 +371,7 @@ This element stamps PTS as pipeline running-time. Residual A/V drift with a Blue | `reset-settle-max-ms` | uint | `8000` | Budget (ms) for the element's own readiness loop after a port reset: re-enumeration polling + reopen retries + wait for the first real frame. A budget, not a delay. Does **not** bound the synchronous libuvc teardown, which can push the total past it | | `reset-rearm-frames` | uint | `30` | Frames the device must deliver after a recovery before the one-shot port reset re-arms for a later wedge | | `auto-port-reset` | bool | `true` | Issue the silence-triggered USB port reset; set `false` to skip `USBDEVFS_RESET` and use normal disconnect handling | +| `deep-port-recovery` | bool | `false` | Escalate once more when the port reset AND its reopens have both failed: a device-level `authorized` re-probe, or a port-level `disable` cycle if the device no longer enumerates. Needs root; never runs on a hub carrying another device. Off by default — proven to fire on hardware, not proven to recover | Action signal: `set-ptz(pan, tilt, zoom)` — drives all three axes in one call; returns `TRUE` if at least one supported axis succeeded. diff --git a/libuvch264src/src/gstlibuvch264src.c b/libuvch264src/src/gstlibuvch264src.c index c1925dc..62726b4 100644 --- a/libuvch264src/src/gstlibuvch264src.c +++ b/libuvch264src/src/gstlibuvch264src.c @@ -34,6 +34,7 @@ enum { PROP_RESET_SETTLE_MAX_MS, PROP_RESET_REARM_FRAMES, PROP_AUTO_PORT_RESET, + PROP_DEEP_PORT_RECOVERY, PROP_DELIVERABLE_CAPS, PROP_LAST }; @@ -67,6 +68,13 @@ enum { #define RESET_POLL_INITIAL_MS 25 #define RESET_POLL_MAX_MS 200 #define RECONNECT_BACKOFF_INITIAL_S 1 +/* How long the deep rung holds a port disabled between its two writes. The + * kernel's own inter-retry cycle only holds power off for 2 * bPwrOn2PwrGood + * (40 ms on the measured board) and demonstrably does not clear this state, so + * a longer hold is the only variable worth changing; a second is far past every + * hub's power-good window and still small next to the settle budget it is + * charged against. Not device-derived - no camera's timing is encoded here. */ +#define DEEP_RECOVERY_PORT_HOLD_MS 1000 /* Opt-in USB payload override (Task 12, gated on bmaxpayload-analysis.md §5). * MAX_PAYLOAD_DEFAULT is the sentinel: 0 = "use the device-negotiated value", @@ -306,6 +314,24 @@ static void gst_libuvc_h264_src_class_init(GstLibuvcH264SrcClass *klass) { "silent; disable to use normal disconnect handling", TRUE, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + /* Default FALSE, and it stays FALSE until a board proves it RECOVERS - not + * merely that it fires. On the measured board the port cycle demonstrably + * regenerates a connect-change (PORTSC drops to Powered-off/Not-connected and + * comes back with C_CONNECTION set, and the kernel re-runs enumeration with a + * fresh address) yet the device still failed `error -71` on 3/3 attempts. + * Shipping it on by default would spend seconds of a recovery budget on an + * escalation with no evidence behind it, and would write root-only sysfs on + * every wedge. See task-11-board-proof.md. */ + g_object_class_install_property(gobject_class, PROP_DEEP_PORT_RECOVERY, + g_param_spec_boolean("deep-port-recovery", "Deep port recovery", + "After a port reset AND its reopen retries have both " + "failed, escalate once to a USB sysfs re-probe: " + "device-level `authorized` while the device object " + "survives, else a port-level `disable` cycle. Needs " + "privilege to write USB sysfs and never runs on a hub " + "carrying another device", + FALSE, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + /* The mode ladder negotiate() actually selects from, i.e. what this element * will ACCEPT - not what the device advertises. NULL until a device has been * negotiated. Read it to publish honest options to an operator instead of the @@ -368,6 +394,8 @@ static void gst_libuvc_h264_src_init(GstLibuvcH264Src *self) { self->consecutive_timeouts = 0; self->reset_recovery_used = FALSE; self->frames_since_reset = 0; + self->deep_port_recovery = FALSE; + self->deep_recovery_used = FALSE; self->reconnect_enabled = FALSE; self->max_payload = MAX_PAYLOAD_DEFAULT; self->max_payload_effective = 0; @@ -376,6 +404,7 @@ static void gst_libuvc_h264_src_init(GstLibuvcH264Src *self) { self->reset_settle_max_ms = RESET_SETTLE_MAX_MS_DEFAULT; self->reset_rearm_frames = RESET_REARM_FRAMES_DEFAULT; self->auto_port_reset = TRUE; + memset(&self->recovery_target, 0, sizeof self->recovery_target); self->frame_offset = 0; self->base_time = G_MAXUINT64; self->prev_pts = G_MAXUINT64; @@ -901,6 +930,11 @@ static void gst_libuvc_h264_src_set_property(GObject *object, guint prop_id, self->auto_port_reset = g_value_get_boolean(value); GST_OBJECT_UNLOCK(self); break; + case PROP_DEEP_PORT_RECOVERY: + GST_OBJECT_LOCK(self); + self->deep_port_recovery = g_value_get_boolean(value); + GST_OBJECT_UNLOCK(self); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec); break; @@ -964,6 +998,11 @@ static void gst_libuvc_h264_src_get_property(GObject *object, guint prop_id, g_value_set_boolean(value, self->auto_port_reset); GST_OBJECT_UNLOCK(self); break; + case PROP_DEEP_PORT_RECOVERY: + GST_OBJECT_LOCK(self); + g_value_set_boolean(value, self->deep_port_recovery); + GST_OBJECT_UNLOCK(self); + break; case PROP_DELIVERABLE_CAPS: GST_OBJECT_LOCK(self); gst_value_set_caps(value, self->deliverable_caps); @@ -1299,6 +1338,8 @@ static gboolean gst_libuvc_h264_src_start(GstBaseSrc *src) { self->consecutive_timeouts = 0; self->reset_recovery_used = FALSE; self->frames_since_reset = 0; + self->deep_recovery_used = FALSE; + usb_recovery_target_clear(&self->recovery_target); // Resolve the device selector up-front, before touching libuvc, so a // malformed index fails loudly here instead of silently selecting device 0. @@ -1426,6 +1467,8 @@ static gboolean gst_libuvc_h264_src_stop(GstBaseSrc *src) { // Close the listening fd and unlink the per-instance socket path. gst_libuvc_h264_src_control_socket_unbind(self); + usb_recovery_target_clear(&self->recovery_target); + // Stop streaming. uvc_stop_streaming() is synchronous: it clears running, // broadcasts cb_cond, and pthread_join()s the callback thread before it // returns (Task 4 spike, reconnect-spike.md §2/§3), so no post-stop settle @@ -1784,6 +1827,75 @@ static gboolean gst_libuvc_h264_src_await_first_frame(GstLibuvcH264Src *self, } } +static gboolean gst_libuvc_h264_src_settle_until_frame( + GstLibuvcH264Src *self, const GstLibuvcDeviceSelector *selector, + gint64 deadline, gint64 started, gint *attempt); + +static GstLibuvcDeepRecoveryHook gst_libuvc_deep_recovery_hook = NULL; + +void gst_libuvc_h264_src_set_deep_recovery_hook( + GstLibuvcDeepRecoveryHook hook) { + gst_libuvc_deep_recovery_hook = hook; +} + +/* Resolve the sysfs paths of the device this element currently holds. Called + * while the handle is still LIVE, because the state this exists to escalate out + * of - a reset the device never came back from - destroys the device directory + * the port would otherwise be resolved through. */ +static void gst_libuvc_h264_src_capture_recovery_target( + GstLibuvcH264Src *self) { + usb_recovery_target_clear(&self->recovery_target); + if (self->uvc_dev == NULL) { + return; + } + + guint8 bus = uvc_get_bus_number(self->uvc_dev); + guint8 address = uvc_get_device_address(self->uvc_dev); + if (!usb_recovery_target_capture("/sys", bus, address, + &self->recovery_target)) { + GST_DEBUG_OBJECT(self, + "Deep recovery: no sysfs target for bus %u address %u; the escalation " + "will report no-target if it is ever reached", bus, address); + } +} + +/* The rung BELOW the port reset, reached only once both the reset AND every + * reopen inside the budget have failed. Opt-in, one shot per silence episode, + * and it never decides for itself that it recovered anything: the caller still + * has to see a delivered frame. */ +static gboolean gst_libuvc_h264_src_deep_recovery(GstLibuvcH264Src *self) { + gboolean enabled; + GST_OBJECT_LOCK(self); + enabled = self->deep_port_recovery; + GST_OBJECT_UNLOCK(self); + + if (!enabled || self->deep_recovery_used) { + return FALSE; + } + self->deep_recovery_used = TRUE; + + UsbDeepRecoveryOutcome outcome; + if (gst_libuvc_deep_recovery_hook != NULL) { + outcome = gst_libuvc_deep_recovery_hook(self); + } else { + outcome = usb_deep_recovery_run(&self->recovery_target, + DEEP_RECOVERY_PORT_HOLD_MS); + } + + gboolean acted = outcome == USB_DEEP_RECOVERY_DEVICE_REPROBED + || outcome == USB_DEEP_RECOVERY_PORT_CYCLED; + if (acted) { + GST_WARNING_OBJECT(self, + "Deep recovery: %s. This is a LOGICAL re-probe of the USB port state, " + "not a proven VBUS power removal", + usb_deep_recovery_outcome_name(outcome)); + } else { + GST_WARNING_OBJECT(self, "Deep recovery did not run: %s", + usb_deep_recovery_outcome_name(outcome)); + } + return acted; +} + /* Recover a WEDGED device: still enumerated, still answering every control * transfer, delivering nothing. ONE USB port reset per silence episode (the * one-shot the caller arms), then a READINESS-DRIVEN return to streaming. @@ -1817,6 +1929,8 @@ static gboolean gst_libuvc_h264_src_recover_wedged_device( return FALSE; } + gst_libuvc_h264_src_capture_recovery_target(self); + if (!gst_libuvc_h264_src_reset_silent_device(self)) { return FALSE; } @@ -1849,10 +1963,61 @@ static gboolean gst_libuvc_h264_src_recover_wedged_device( } gint attempt = 0; + if (gst_libuvc_h264_src_settle_until_frame(self, &selector, deadline, started, + &attempt)) { + return TRUE; + } + + // Everything above - one port reset, then every reopen the budget allowed - + // has failed. `deep-port-recovery` opts into ONE further escalation from + // here, and only from here: it is the rung below a reset that did not take, + // never a substitute for one. A flush outranks it, and so does the one-shot. + if (!g_atomic_int_get(&self->flushing) + && gst_libuvc_h264_src_deep_recovery(self)) { + // Same reasoning as the post-reset re-init above: the escalation puts the + // device through another re-enumeration, so the context must not straddle + // it either. + uvc_exit(self->uvc_ctx); + self->uvc_ctx = NULL; + if (uvc_init(&self->uvc_ctx, NULL) < 0) { + self->uvc_ctx = NULL; + GST_WARNING_OBJECT(self, + "Deep recovery: could not re-init libuvc afterwards"); + return FALSE; + } + + // A second budget, deliberately: the first one is already spent, and an + // escalation with no time to prove itself would be theatre. It doubles the + // worst case, which is why the whole rung is opt-in. + gint64 second_deadline = g_get_monotonic_time() + + (gint64) budget_ms * G_TIME_SPAN_MILLISECOND; + if (gst_libuvc_h264_src_settle_until_frame(self, &selector, second_deadline, + started, &attempt)) { + return TRUE; + } + } + + GST_WARNING_OBJECT(self, + "Wedge recovery gave up after %" G_GINT64_FORMAT " ms (budget %u ms, " + "%d reopen attempt(s)); the device never delivered a frame. An overrun " + "past the budget is one synchronous libuvc call that was already in " + "flight when the budget expired", + (g_get_monotonic_time() - started) / G_TIME_SPAN_MILLISECOND, budget_ms, + attempt); + return FALSE; +} + +/* Poll re-enumeration, reopen, and require a DELIVERED frame, until `deadline`. + * Split out of the recovery so the post-escalation retry proves recovery by + * exactly the same standard as the first pass - a successful + * uvc_start_streaming() is not evidence, only a frame is. */ +static gboolean gst_libuvc_h264_src_settle_until_frame( + GstLibuvcH264Src *self, const GstLibuvcDeviceSelector *selector, + gint64 deadline, gint64 started, gint *attempt) { guint retry_ms = RESET_POLL_INITIAL_MS; while (!g_atomic_int_get(&self->flushing) && g_get_monotonic_time() < deadline) { - if (!gst_libuvc_h264_src_reopen_once(self, &selector, deadline)) { + if (!gst_libuvc_h264_src_reopen_once(self, selector, deadline)) { // MEASURED on hardware: the first reopen after a real port reset routinely // fails - the device is listed again but still refuses uvc_open() or the // format negotiation while it finishes coming back. Inside the budget that @@ -1869,7 +2034,7 @@ static gboolean gst_libuvc_h264_src_recover_wedged_device( retry_ms = MIN(retry_ms * 2, (guint) RESET_POLL_MAX_MS); continue; } - attempt++; + (*attempt)++; gint64 remaining = deadline - g_get_monotonic_time(); gint64 proof_us = MIN(remaining, (gint64) TIMEOUT_DURATION); @@ -1879,7 +2044,7 @@ static gboolean gst_libuvc_h264_src_recover_wedged_device( "Wedge recovery: frames advancing again %" G_GINT64_FORMAT " ms after the port reset (reopen attempt %d)", (g_get_monotonic_time() - started) / G_TIME_SPAN_MILLISECOND, - attempt); + *attempt); return TRUE; } @@ -1887,18 +2052,11 @@ static gboolean gst_libuvc_h264_src_recover_wedged_device( // the handle down and try again rather than trusting the successful start. GST_DEBUG_OBJECT(self, "Wedge recovery: reopen attempt %d started but delivered no frame", - attempt); + *attempt); gst_libuvc_h264_src_teardown_handle(self); gst_libuvc_h264_src_drain_queue(self); } - GST_WARNING_OBJECT(self, - "Wedge recovery gave up after %" G_GINT64_FORMAT " ms (budget %u ms, " - "%d reopen attempt(s)); the device never delivered a frame. An overrun " - "past the budget is one synchronous libuvc call that was already in " - "flight when the budget expired", - (g_get_monotonic_time() - started) / G_TIME_SPAN_MILLISECOND, budget_ms, - attempt); return FALSE; } diff --git a/libuvch264src/src/gstlibuvch264src_internal.h b/libuvch264src/src/gstlibuvch264src_internal.h index 010c379..d49680b 100644 --- a/libuvch264src/src/gstlibuvch264src_internal.h +++ b/libuvch264src/src/gstlibuvch264src_internal.h @@ -6,6 +6,7 @@ #include #include #include "gstlibuvch264src.h" +#include "usb_port_recovery.h" G_BEGIN_DECLS @@ -88,6 +89,20 @@ struct _GstLibuvcH264Src { guint reset_settle_max_ms; guint reset_rearm_frames; gboolean auto_port_reset; + /* Opt-in escalation BELOW the port reset (Task 11). A port reset can leave a + * device that never re-enumerates - `error -71` four times, then `unable to + * enumerate USB device` - at which point the reset rung has nothing left to + * reset. deep_port_recovery (PROP_DEEP_PORT_RECOVERY, guarded by + * GST_OBJECT_LOCK like the bounds above) opts into one sysfs escalation after + * the reset-and-reopen cycle has already failed; deep_recovery_used is its + * one-shot, re-armed on the same delivered-frame proof as the reset's. + * + * recovery_target is resolved from sysfs while the device is still OPEN, + * because after a failed re-enumeration there is no device directory left to + * resolve a port from. It is owned here and cleared in stop(). */ + gboolean deep_port_recovery; + gboolean deep_recovery_used; + UsbRecoveryTarget recovery_target; GstClock *clock; GstClockTime base_time; GstClockTime prev_pts; @@ -163,6 +178,15 @@ void gst_libuvc_h264_src_set_reset_poll_hook(GstLibuvcResetPollHook hook); gboolean gst_libuvc_h264_src_reset_silent_device(GstLibuvcH264Src *self); +/* Test seam for the deep-recovery rung, mirroring the reset hook. The real rung + * writes root-only USB sysfs attributes, which a test host neither has nor + * should have; the hook reports the outcome the element must act on and lets a + * test assert WHEN in the ladder the rung fired. A NULL hook (production + * default) resolves the captured target and performs the real writes. */ +typedef UsbDeepRecoveryOutcome (*GstLibuvcDeepRecoveryHook)( + GstLibuvcH264Src *self); +void gst_libuvc_h264_src_set_deep_recovery_hook(GstLibuvcDeepRecoveryHook hook); + G_END_DECLS #endif /* GST_LIBUVC_H264_SRC_INTERNAL_H */ diff --git a/libuvch264src/src/sources.txt b/libuvch264src/src/sources.txt index f792734..0fee583 100644 --- a/libuvch264src/src/sources.txt +++ b/libuvch264src/src/sources.txt @@ -5,3 +5,4 @@ ptz_control.c spspps_cache.c gstlibuvch264src_error.c quirks.c +usb_port_recovery.c diff --git a/libuvch264src/src/usb_port_recovery.c b/libuvch264src/src/usb_port_recovery.c new file mode 100644 index 0000000..ad3da5c --- /dev/null +++ b/libuvch264src/src/usb_port_recovery.c @@ -0,0 +1,271 @@ +#include "usb_port_recovery.h" + +#include +#include +#include +#include +#include +#include + +/* Read a small sysfs attribute into `buf`, NUL-terminated and trimmed. sysfs + * attributes are generated on read, so a single read(2) returns the whole + * value; anything longer than the caller's buffer is not an attribute we + * understand and is rejected rather than truncated. */ +static gboolean read_attr(const gchar *dir, const gchar *attr, gchar *buf, + gsize len) { + gchar *path = g_build_filename(dir, attr, NULL); + int fd = open(path, O_RDONLY | O_CLOEXEC); + g_free(path); + if (fd < 0) { + return FALSE; + } + + ssize_t n = read(fd, buf, len - 1); + close(fd); + if (n <= 0) { + return FALSE; + } + buf[n] = '\0'; + g_strchomp(buf); + return buf[0] != '\0'; +} + +static gboolean read_attr_uint(const gchar *dir, const gchar *attr, guint base, + guint64 *out) { + gchar buf[64]; + if (!read_attr(dir, attr, buf, sizeof buf)) { + return FALSE; + } + + gchar *end = NULL; + errno = 0; + guint64 value = g_ascii_strtoull(buf, &end, (gint) base); + if (errno != 0 || end == buf || *end != '\0') { + return FALSE; + } + *out = value; + return TRUE; +} + +/* Write one value to a sysfs attribute. Deliberately a raw write(2): the glib + * file helpers write a temporary and rename it, which sysfs cannot accept. + * Returns TRUE on success; on failure sets *error to the outcome that explains + * it, so a caller can tell "no privilege" apart from "no such knob". */ +static gboolean write_attr(const gchar *dir, const gchar *attr, + const gchar *value, UsbDeepRecoveryOutcome *error) { + gchar *path = g_build_filename(dir, attr, NULL); + /* O_TRUNC so the write REPLACES the value rather than overwriting its first + * bytes: sysfs ignores the file offset, but nothing else guarantees that, and + * a partial overwrite would leave a stale tail behind the new value. */ + int fd = open(path, O_WRONLY | O_TRUNC | O_CLOEXEC); + g_free(path); + if (fd < 0) { + if (errno == EACCES || errno == EPERM) { + *error = USB_DEEP_RECOVERY_DENIED; + } else { + *error = errno == ENOENT ? USB_DEEP_RECOVERY_UNAVAILABLE + : USB_DEEP_RECOVERY_FAILED; + } + return FALSE; + } + + gsize len = strlen(value); + ssize_t written = write(fd, value, len); + int saved = errno; + close(fd); + if (written == (ssize_t) len) { + return TRUE; + } + + *error = (saved == EACCES || saved == EPERM) ? USB_DEEP_RECOVERY_DENIED + : USB_DEEP_RECOVERY_FAILED; + return FALSE; +} + +static gboolean identity_matches(const gchar *device_path, guint16 vendor_id, + guint16 product_id) { + guint64 vid = 0, pid = 0; + if (!read_attr_uint(device_path, "idVendor", 16, &vid) + || !read_attr_uint(device_path, "idProduct", 16, &pid)) { + return FALSE; + } + return vid == vendor_id && pid == product_id; +} + +gboolean usb_recovery_target_capture(const gchar *sysfs_root, guint8 bus, + guint8 address, UsbRecoveryTarget *out) { + g_return_val_if_fail(out != NULL, FALSE); + memset(out, 0, sizeof *out); + if (sysfs_root == NULL) { + return FALSE; + } + + gchar *devices = g_build_filename(sysfs_root, "bus", "usb", "devices", NULL); + GDir *dir = g_dir_open(devices, 0, NULL); + if (dir == NULL) { + g_free(devices); + return FALSE; + } + + gboolean found = FALSE; + const gchar *name; + while (!found && (name = g_dir_read_name(dir)) != NULL) { + /* `:.` entries are interfaces, not devices. */ + if (strchr(name, ':') != NULL) { + continue; + } + + gchar *device_path = g_build_filename(devices, name, NULL); + guint64 busnum = 0, devnum = 0, vid = 0, pid = 0; + if (read_attr_uint(device_path, "busnum", 10, &busnum) && busnum == bus + && read_attr_uint(device_path, "devnum", 10, &devnum) + && devnum == address + && read_attr_uint(device_path, "idVendor", 16, &vid) + && read_attr_uint(device_path, "idProduct", 16, &pid)) { + /* The `port` symlink is the authoritative device->port mapping. Deriving + * the port directory from the device name by string surgery would have to + * re-implement the kernel's own naming for root hubs, hub chains and + * SuperSpeed peers; the link is exact and always present on an enumerated + * non-root device. */ + gchar *link = g_build_filename(device_path, "port", NULL); + gchar *port_path = realpath(link, NULL); + g_free(link); + if (port_path != NULL) { + out->device_path = g_strdup(device_path); + out->port_path = g_strdup(port_path); + out->vendor_id = (guint16) vid; + out->product_id = (guint16) pid; + free(port_path); + found = TRUE; + } + } + g_free(device_path); + } + + g_dir_close(dir); + g_free(devices); + return found; +} + +void usb_recovery_target_clear(UsbRecoveryTarget *target) { + if (target == NULL) { + return; + } + g_clear_pointer(&target->device_path, g_free); + g_clear_pointer(&target->port_path, g_free); + target->vendor_id = 0; + target->product_id = 0; +} + +/* TRUE when any port of the same hub, other than this one, still has a child + * device. Such a hub may be switching power in ganged mode, in which case + * cycling our port cuts the others too — and no sysfs attribute reports the + * switching mode, so the only safe reading of an ambiguous hub is "do not + * touch it". */ +static gboolean hub_carries_other_devices(const gchar *port_path) { + gchar *hub_iface = g_path_get_dirname(port_path); + gchar *self_name = g_path_get_basename(port_path); + + GDir *dir = g_dir_open(hub_iface, 0, NULL); + if (dir == NULL) { + g_free(hub_iface); + g_free(self_name); + /* Cannot see the siblings, so cannot prove the port is unshared. */ + return TRUE; + } + + gboolean shared = FALSE; + const gchar *name; + while (!shared && (name = g_dir_read_name(dir)) != NULL) { + if (strstr(name, "-port") == NULL || g_strcmp0(name, self_name) == 0) { + continue; + } + gchar *child = g_build_filename(hub_iface, name, "device", NULL); + shared = g_file_test(child, G_FILE_TEST_EXISTS); + g_free(child); + } + + g_dir_close(dir); + g_free(hub_iface); + g_free(self_name); + return shared; +} + +UsbDeepRecoveryOutcome usb_deep_recovery_run(const UsbRecoveryTarget *target, + guint hold_ms) { + if (target == NULL || target->port_path == NULL + || target->device_path == NULL) { + return USB_DEEP_RECOVERY_NO_TARGET; + } + + /* Rung 1 — the device object survived the reset, so the cheap logical + * re-probe is available and is preferred: it disturbs exactly one device and + * leaves the port's power state alone. The identity re-check is what stops it + * firing at a DIFFERENT device that has since taken the address. */ + UsbDeepRecoveryOutcome error = USB_DEEP_RECOVERY_FAILED; + + gchar *occupant = g_build_filename(target->port_path, "device", NULL); + gboolean port_occupied = g_file_test(occupant, G_FILE_TEST_EXISTS); + gboolean occupant_is_ours = + port_occupied + && identity_matches(occupant, target->vendor_id, target->product_id); + g_free(occupant); + if (port_occupied && !occupant_is_ours) { + return USB_DEEP_RECOVERY_REFUSED_IDENTITY; + } + + if (g_file_test(target->device_path, G_FILE_TEST_IS_DIR)) { + if (!identity_matches(target->device_path, target->vendor_id, + target->product_id)) { + return USB_DEEP_RECOVERY_REFUSED_IDENTITY; + } + if (!write_attr(target->device_path, "authorized", "0", &error) + || !write_attr(target->device_path, "authorized", "1", &error)) { + return error; + } + return USB_DEEP_RECOVERY_DEVICE_REPROBED; + } + + /* Rung 2 — no device object at the captured path, which is the post- + * `error -71` shape. Only the port is left. */ + if (hub_carries_other_devices(target->port_path)) { + return USB_DEEP_RECOVERY_REFUSED_SHARED_HUB; + } + + if (!write_attr(target->port_path, "disable", "1", &error)) { + return error; + } + + if (hold_ms > 0) { + g_usleep((gulong) hold_ms * G_TIME_SPAN_MILLISECOND); + } + + /* The restore is not optional: a port left disabled is worse than the wedge + * it was meant to clear, because a replug cannot revive it either. */ + if (!write_attr(target->port_path, "disable", "0", &error)) { + return error; + } + return USB_DEEP_RECOVERY_PORT_CYCLED; +} + +const gchar *usb_deep_recovery_outcome_name(UsbDeepRecoveryOutcome outcome) { + switch (outcome) { + case USB_DEEP_RECOVERY_NO_TARGET: + return "no-target"; + case USB_DEEP_RECOVERY_REFUSED_SHARED_HUB: + return "refused-shared-hub"; + case USB_DEEP_RECOVERY_REFUSED_IDENTITY: + return "refused-identity"; + case USB_DEEP_RECOVERY_UNAVAILABLE: + return "unavailable"; + case USB_DEEP_RECOVERY_DENIED: + return "denied"; + case USB_DEEP_RECOVERY_FAILED: + return "failed"; + case USB_DEEP_RECOVERY_DEVICE_REPROBED: + return "device-reprobed"; + case USB_DEEP_RECOVERY_PORT_CYCLED: + return "port-cycled"; + } + return "unknown"; +} diff --git a/libuvch264src/src/usb_port_recovery.h b/libuvch264src/src/usb_port_recovery.h new file mode 100644 index 0000000..d8203f7 --- /dev/null +++ b/libuvch264src/src/usb_port_recovery.h @@ -0,0 +1,107 @@ +#ifndef GST_LIBUVC_USB_PORT_RECOVERY_H +#define GST_LIBUVC_USB_PORT_RECOVERY_H + +#include + +G_BEGIN_DECLS + +/* Deep USB recovery: the rung BELOW a USB port reset. + * + * A port reset (USBDEVFS_RESET / libusb_reset_device) can leave a device that + * never comes back: the kernel re-enumerates, every attempt fails with + * `error -71` (EPROTO), and after PORT_INIT_TRIES it logs `unable to enumerate + * USB device` and stops. The device object is then GONE, so nothing addressed + * by device path can be written any more. + * + * Which sysfs object to write is therefore decided by what SURVIVED, and the + * two layers carry DIFFERENT attributes. Measured on the RK3588 board + * (kernel 6.1.115-vendor-rk35xx), 14/14 USB ports and 12/12 USB devices: + * + * /sys/bus/usb/devices/- DEVICE has `authorized`, no `disable` + * .../:1.0/-port PORT has `disable`, no `authorized` + * + * So `authorized` is reachable only while a device object exists, and once + * enumeration has failed outright the port's `disable` is the only handle left. + * This module implements both and picks by what is actually present. + * + * THE TARGET MUST BE CAPTURED BEFORE THE RESET. After a failed re-enumeration + * there is no device directory to resolve a port from, so the port path has to + * have been read while the device was still open. + * + * HONESTY BOUND — this is a port STATE cycle, not a proven VBUS removal. + * Writing 1 to `disable` clears PORT_POWER and the latched C_CONNECTION / + * C_ENABLE bits, and writing 0 restores power so the hub sees a genuine + * connect-change. On the measured board that transition is real (xHCI PORTSC + * goes `Powered Connected Enabled` -> `Powered-off Not-connected Disabled` and + * back with `Change: CSC`), yet the same root hub advertises + * `wHubCharacteristic 0x000a` = "No power switching" and the board's Type-C + * 5 V rail is a separate GPIO regulator. Whether VBUS physically dropped was + * NOT established. Never describe this as a power cycle. */ + +typedef enum { + /* Nothing was captured before the reset, so there is no path to act on. */ + USB_DEEP_RECOVERY_NO_TARGET = 0, + /* The port's hub carries at least one OTHER enumerated device. Hubs may + * switch power in ganged mode (measured: the board's Terminus 1a40:0101 + * reports `Ganged power switching`), so cycling one port can cut every port + * on that hub. Refusing is the only safe answer, because nothing readable + * from sysfs proves the switching is per-port. */ + USB_DEEP_RECOVERY_REFUSED_SHARED_HUB, + /* A DIFFERENT device now holds the captured device path or port. Bus + * addresses are recycled, so the vid:pid recorded at capture time is what + * separates "our camera came back" from "something else moved in"; acting on + * the latter would reset a device this element has no business touching. */ + USB_DEEP_RECOVERY_REFUSED_IDENTITY, + /* Neither attribute exists at the captured paths. */ + USB_DEEP_RECOVERY_UNAVAILABLE, + /* EACCES/EPERM: USB sysfs attributes are root-writable only. An element + * embedded in a root service can escalate; one running as a normal user + * cannot, and must say so rather than look like it tried. */ + USB_DEEP_RECOVERY_DENIED, + /* The write reached the attribute and the kernel rejected it. */ + USB_DEEP_RECOVERY_FAILED, + /* Device-level `authorized` 0->1 completed: a logical deauthorise and + * re-probe of a device object that is still enumerated. */ + USB_DEEP_RECOVERY_DEVICE_REPROBED, + /* Port-level `disable` 1->0 completed. */ + USB_DEEP_RECOVERY_PORT_CYCLED, +} UsbDeepRecoveryOutcome; + +typedef struct { + /* Device directory as it was at capture time. It may legitimately be gone by + * the time the recovery runs — that is precisely the case rung 2 exists for. */ + gchar *device_path; + /* Port directory, resolved through the device's `port` symlink. Survives the + * device's disappearance, which is why it is captured rather than derived. */ + gchar *port_path; + guint16 vendor_id; + guint16 product_id; +} UsbRecoveryTarget; + +/* Resolve the sysfs paths for the device at (bus, address) and record its + * vid:pid so the recovery can refuse a path that a DIFFERENT device has taken + * over in the meantime. `sysfs_root` is "/sys" in production and a synthetic + * tree under test. Returns FALSE and leaves `out` zeroed if the device, its + * identity, or its port link cannot be resolved. */ +gboolean usb_recovery_target_capture(const gchar *sysfs_root, guint8 bus, + guint8 address, UsbRecoveryTarget *out); + +void usb_recovery_target_clear(UsbRecoveryTarget *target); + +/* Run the escalation ONCE against a captured target: + * 1. device-level `authorized` 0->1, if the device object is still there AND + * still reports the captured vid:pid; + * 2. otherwise the port-level `disable` 1->0 cycle, if no other device on the + * hub would be disturbed. + * `hold_ms` is how long the port is held disabled between the two writes. The + * write itself can BLOCK for seconds (measured 2.5 s) because disable_store() + * takes the hub device lock and an in-flight hub_event holds it. */ +UsbDeepRecoveryOutcome usb_deep_recovery_run(const UsbRecoveryTarget *target, + guint hold_ms); + +/* Stable, loggable name for an outcome. */ +const gchar *usb_deep_recovery_outcome_name(UsbDeepRecoveryOutcome outcome); + +G_END_DECLS + +#endif /* GST_LIBUVC_USB_PORT_RECOVERY_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index df4df00..27a66fb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1022,6 +1022,45 @@ set_tests_properties(v4l2_probe_nonfatal PROPERTIES TIMEOUT 30 ) +# ----------------------------------------------------------------------------- +# Deep USB recovery helper tests (Task 11). +# +# Compiles usb_port_recovery.c directly (no element TUs, no libuvc mock): the +# helper is deliberately dependency-free so the rung can be reused from outside +# this element, and its whole contract is expressible against a synthetic sysfs +# tree. Every case builds that tree in a temp dir and drives the REAL writes, so +# the device-vs-port attribute split and the leaf-target vetoes are asserted +# rather than assumed. No RESOURCE_LOCK: nothing here binds the control socket. +# ----------------------------------------------------------------------------- +add_executable(test_usb_port_recovery + test_usb_port_recovery.c + ${CMAKE_SOURCE_DIR}/libuvch264src/src/usb_port_recovery.c +) +target_include_directories(test_usb_port_recovery PRIVATE + ${CMAKE_SOURCE_DIR}/libuvch264src/src + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(test_usb_port_recovery PRIVATE + PkgConfig::GST + PkgConfig::GST_BASE + PkgConfig::GST_CHECK +) +apply_sanitizer_flags(test_usb_port_recovery "${STATIC_SUITE_SANITIZER}") + +set(_usb_port_recovery_cases + "usb_recovery_capture:test_capture_resolves_device_and_port" + "usb_recovery_capture_foreign_address:test_capture_rejects_a_foreign_address" + "usb_recovery_capture_needs_port_link:test_capture_requires_the_port_link" + "usb_recovery_device_rung:test_surviving_device_takes_the_device_rung" + "usb_recovery_port_rung:test_vanished_device_takes_the_port_rung" + "usb_recovery_refuses_shared_hub:test_hub_with_another_device_is_refused" + "usb_recovery_refuses_foreign_device:test_foreign_device_on_the_port_is_refused" + "usb_recovery_no_target:test_missing_target_is_reported_not_guessed" + "usb_recovery_denied:test_unwritable_attribute_reports_denied" +) +add_gstcheck_multicase_test(test_usb_port_recovery "usb-port-recovery" + "${STATIC_SUITE_SANITIZER}" "${_usb_port_recovery_cases}" NO_RESOURCE_LOCK) + # ----------------------------------------------------------------------------- # Live-source polish tests (Task 19). # @@ -1291,6 +1330,12 @@ set(_reconnect_cases "reset_survives_transient_reopen_failure:test_reset_survives_transient_reopen_failure" "reset_refreshes_libuvc_context:test_reset_refreshes_libuvc_context" "reset_rearm_frames_property:test_reset_rearm_frames_property" + "deep_recovery_defaults_off:test_deep_recovery_defaults_off" + "deep_recovery_disabled:test_deep_recovery_not_run_when_disabled" + "deep_recovery_order:test_deep_recovery_runs_after_the_reset_not_before" + "deep_recovery_skipped_on_reset_recovery:test_deep_recovery_skipped_when_the_reset_recovers" + "deep_recovery_retry_pass:test_deep_recovery_retries_once_and_can_recover" + "deep_recovery_refusal:test_deep_recovery_refusal_falls_through_honestly" ) # start() may spawn the control thread, so keep these off the shared socket # resource like the other start()-driven suites (RESOURCE_LOCK, applied by helper). @@ -1745,6 +1790,7 @@ set(_compat_cases "compat_caps_contract:test_compat_caps_contract" "compat_transfer_buffers_property:test_compat_transfer_buffers_property" "compat_auto_port_reset_property:test_compat_auto_port_reset_property" + "compat_deep_port_recovery_property:test_compat_deep_port_recovery_property" "compat_deliverable_caps_api:test_compat_deliverable_caps_api" "compat_pipeline_parse_h264:test_compat_pipeline_parse_h264" "compat_pipeline_parse_h265:test_compat_pipeline_parse_h265" diff --git a/tests/test_compat.c b/tests/test_compat.c index 2d2d161..7bd2be8 100644 --- a/tests/test_compat.c +++ b/tests/test_compat.c @@ -379,6 +379,32 @@ GST_START_TEST (test_compat_auto_port_reset_property) GST_END_TEST; +GST_START_TEST (test_compat_deep_port_recovery_property) +{ + GstElement *element = gst_element_factory_make (ELEMENT_NAME, NULL); + fail_unless (element != NULL, "could not instantiate '%s'", ELEMENT_NAME); + + GParamSpec *pspec = g_object_class_find_property ( + G_OBJECT_GET_CLASS (element), "deep-port-recovery"); + fail_unless (pspec != NULL, + "expected 'deep-port-recovery' property is missing"); + fail_unless (pspec->value_type == G_TYPE_BOOLEAN, + "'deep-port-recovery' should be a boolean"); + + gboolean enabled = TRUE; + g_object_get (element, "deep-port-recovery", &enabled, NULL); + fail_if (enabled, + "default 'deep-port-recovery' must remain FALSE until a board proves the " + "escalation RECOVERS a device, not merely that it fires"); + + g_object_set (element, "deep-port-recovery", TRUE, NULL); + g_object_get (element, "deep-port-recovery", &enabled, NULL); + fail_unless (enabled, "'deep-port-recovery' TRUE must read back as TRUE"); + gst_object_unref (element); +} + +GST_END_TEST; + /* The capability-publication contract cerastream binds to AT RUNTIME, by name, * against the installed plugin. A rename or signature change here does not break * any build in this repo - it breaks device enumeration in another one, silently @@ -564,6 +590,7 @@ compat_suite (void) tcase_add_test (tc, test_compat_caps_contract); tcase_add_test (tc, test_compat_transfer_buffers_property); tcase_add_test (tc, test_compat_auto_port_reset_property); + tcase_add_test (tc, test_compat_deep_port_recovery_property); tcase_add_test (tc, test_compat_deliverable_caps_api); tcase_add_test (tc, test_compat_pipeline_parse_h264); tcase_add_test (tc, test_compat_pipeline_parse_h265); diff --git a/tests/test_reconnect.c b/tests/test_reconnect.c index dd57e1e..6063ffd 100644 --- a/tests/test_reconnect.c +++ b/tests/test_reconnect.c @@ -430,6 +430,268 @@ GST_START_TEST (test_wedged_device_auto_port_reset_disabled) GST_END_TEST; +/* ------------------------------------------------------------------------- */ +/* Deep-recovery ladder ordering (Task 11) */ +/* */ +/* A port reset can leave a device that never re-enumerates. The rung below it */ +/* is opt-in, one-shot, and reachable ONLY once the reset AND every reopen in */ +/* the budget have failed — these cases pin that position in the ladder, not */ +/* the sysfs writes themselves (those are proven against a synthetic tree in */ +/* test_usb_port_recovery.c, and against hardware in task-11-board-proof.md). */ +/* ------------------------------------------------------------------------- */ + +static gint ladder_seq; /* atomic: hands out monotonic step numbers */ +static gint ladder_reset_step; /* step at which the port reset fired, 0 = never */ +static gint ladder_deep_step; /* step at which the deep rung fired, 0 = never */ +static gint ladder_reset_calls; +static gint ladder_deep_calls; +static gint ladder_deep_outcome; +static gint ladder_heal_after_deep; +static gint ladder_heal_after_reset; + +/* The reset lands and the device DOES come back on the bus — it just delivers + * nothing, which is the only shape in which the settle pass can genuinely + * exhaust and hand over to the escalation. `heal_after_reset` instead makes it + * stream, so the first pass succeeds and the escalation must stay unreached. */ +static gint +ladder_reset_hook (GstLibuvcH264Src * self) +{ + (void) self; + if (g_atomic_int_add (&ladder_reset_calls, 1) == 0) { + if (g_atomic_int_get (&ladder_heal_after_reset)) { + mock_uvc_set_frame_mode (MOCK_UVC_FRAME_VALID); + } else { + mock_uvc_set_frame_mode (MOCK_UVC_FRAME_SILENT); + } + mock_uvc_set_max_frames (0); + } + g_atomic_int_set (&ladder_reset_step, g_atomic_int_add (&ladder_seq, 1) + 1); + return 0; /* LIBUSB_SUCCESS */ +} + +/* Collapse the re-enumeration micro-backoff so a case costs its budget, not its + * poll schedule. */ +static gint64 +ladder_poll_hook (GstLibuvcH264Src * self, gint attempt, guint interval_ms) +{ + (void) self; + (void) attempt; + (void) interval_ms; + return 0; +} + +static UsbDeepRecoveryOutcome +ladder_deep_hook (GstLibuvcH264Src * self) +{ + (void) self; + g_atomic_int_inc (&ladder_deep_calls); + g_atomic_int_set (&ladder_deep_step, g_atomic_int_add (&ladder_seq, 1) + 1); + if (g_atomic_int_get (&ladder_heal_after_deep)) { + mock_uvc_set_frame_mode (MOCK_UVC_FRAME_VALID); + mock_uvc_set_max_frames (0); + } + return (UsbDeepRecoveryOutcome) g_atomic_int_get (&ladder_deep_outcome); +} + +typedef struct +{ + gint reset_calls; + gint deep_calls; + gint reset_step; + gint deep_step; + gint buffers_after; + gboolean errored; +} LadderResult; + +/* Drive one silence episode with reconnect off. `heal_after_reset` makes the + * device stream again the moment the reset lands (so the first settle pass + * succeeds and the deep rung must never be reached); `heal_after_deep` does the + * same from inside the deep hook (so only the SECOND pass can succeed). */ +static void +run_ladder_scenario (gboolean enable_deep, UsbDeepRecoveryOutcome outcome, + gboolean heal_after_reset, gboolean heal_after_deep, LadderResult * out) +{ + load_core_elements (); + register_element (); + mock_uvc_reset (); + mock_uvc_set_frame_mode (MOCK_UVC_FRAME_DISCONNECT); + mock_uvc_set_max_frames (1); + + g_atomic_int_set (&buffers_seen, 0); + g_atomic_int_set (&ladder_seq, 0); + g_atomic_int_set (&ladder_reset_step, 0); + g_atomic_int_set (&ladder_deep_step, 0); + g_atomic_int_set (&ladder_reset_calls, 0); + g_atomic_int_set (&ladder_deep_calls, 0); + g_atomic_int_set (&ladder_deep_outcome, (gint) outcome); + g_atomic_int_set (&ladder_heal_after_deep, heal_after_deep); + g_atomic_int_set (&ladder_heal_after_reset, heal_after_reset); + gst_libuvc_h264_src_set_reset_device_hook (ladder_reset_hook); + gst_libuvc_h264_src_set_reset_poll_hook (ladder_poll_hook); + gst_libuvc_h264_src_set_deep_recovery_hook (ladder_deep_hook); + + GstElement *src = NULL; + GstElement *pipeline = build_pipeline (&src); + /* A short budget so each settle pass exhausts quickly; the ordering under + * test is independent of how long either pass is given. */ + g_object_set (src, "reset-settle-max-ms", 400, NULL); + if (enable_deep) + g_object_set (src, "deep-port-recovery", TRUE, NULL); + + fail_unless (gst_element_set_state (pipeline, GST_STATE_PLAYING) + != GST_STATE_CHANGE_FAILURE, "could not set pipeline to PLAYING"); + + gint64 deadline = g_get_monotonic_time () + 5 * G_TIME_SPAN_SECOND; + while (g_atomic_int_get (&buffers_seen) < 1 + && g_get_monotonic_time () < deadline) { + g_usleep (2 * G_TIME_SPAN_MILLISECOND); + } + + gint baseline = g_atomic_int_get (&buffers_seen); + GstBus *bus = gst_element_get_bus (pipeline); + gboolean errored = FALSE; + deadline = g_get_monotonic_time () + 40 * G_TIME_SPAN_SECOND; + while (g_get_monotonic_time () < deadline) { + GstMessage *msg = + gst_bus_pop_filtered (bus, GST_MESSAGE_ERROR | GST_MESSAGE_EOS); + if (msg != NULL) { + errored = (GST_MESSAGE_TYPE (msg) == GST_MESSAGE_ERROR); + gst_message_unref (msg); + break; + } + if ((heal_after_reset || heal_after_deep) + && g_atomic_int_get (&buffers_seen) >= baseline + 5) { + break; + } + g_usleep (20 * G_TIME_SPAN_MILLISECOND); + } + + out->reset_calls = g_atomic_int_get (&ladder_reset_calls); + out->deep_calls = g_atomic_int_get (&ladder_deep_calls); + out->reset_step = g_atomic_int_get (&ladder_reset_step); + out->deep_step = g_atomic_int_get (&ladder_deep_step); + out->buffers_after = g_atomic_int_get (&buffers_seen) - baseline; + out->errored = errored; + gst_object_unref (bus); + + gst_element_set_state (pipeline, GST_STATE_NULL); + gst_object_unref (pipeline); + gst_libuvc_h264_src_set_reset_device_hook (NULL); + gst_libuvc_h264_src_set_reset_poll_hook (NULL); + gst_libuvc_h264_src_set_deep_recovery_hook (NULL); +} + +GST_START_TEST (test_deep_recovery_defaults_off) +{ + register_element (); + GstElement *src = gst_element_factory_make ("libuvch264src", NULL); + fail_unless (src != NULL, "could not create the element"); + + gboolean enabled = TRUE; + g_object_get (src, "deep-port-recovery", &enabled, NULL); + fail_if (enabled, + "deep-port-recovery must default to FALSE: the escalation is proven to " + "fire on hardware but NOT proven to recover a device stuck at -71"); + + gst_object_unref (src); +} + +GST_END_TEST; + +GST_START_TEST (test_deep_recovery_not_run_when_disabled) +{ + LadderResult res; + run_ladder_scenario (FALSE, USB_DEEP_RECOVERY_PORT_CYCLED, FALSE, FALSE, + &res); + + fail_unless (res.reset_calls == 1, + "the port reset must still fire exactly once, got %d", res.reset_calls); + fail_unless (res.deep_calls == 0, + "the default-off rung must never write USB sysfs, got %d calls", + res.deep_calls); + fail_unless (res.errored, + "an unrecovered wedge must still surface the disconnect error"); +} + +GST_END_TEST; + +GST_START_TEST (test_deep_recovery_runs_after_the_reset_not_before) +{ + LadderResult res; + run_ladder_scenario (TRUE, USB_DEEP_RECOVERY_PORT_CYCLED, FALSE, FALSE, &res); + + fail_unless (res.reset_calls == 1, + "the reset rung must still fire exactly once, got %d", res.reset_calls); + fail_unless (res.deep_calls == 1, + "the deep rung must fire once when the reset and its reopens both fail, " + "got %d", res.deep_calls); + fail_unless (res.reset_step > 0 && res.deep_step > res.reset_step, + "the deep rung is an ESCALATION: it must run strictly after the port " + "reset (reset at step %d, deep at step %d)", res.reset_step, + res.deep_step); + fail_unless (res.errored, + "a device that stays silent through both rungs must still surface the " + "disconnect error"); +} + +GST_END_TEST; + +GST_START_TEST (test_deep_recovery_skipped_when_the_reset_recovers) +{ + LadderResult res; + run_ladder_scenario (TRUE, USB_DEEP_RECOVERY_PORT_CYCLED, TRUE, FALSE, &res); + + fail_unless (res.reset_calls == 1, + "expected exactly one port reset, got %d", res.reset_calls); + fail_unless (res.deep_calls == 0, + "a wedge the reset already cleared must never escalate, got %d calls", + res.deep_calls); + fail_if (res.errored, + "a recovered wedge must not surface as a disconnect error"); + fail_unless (res.buffers_after >= 5, + "frames must resume after the reset, got %d new buffers", + res.buffers_after); +} + +GST_END_TEST; + +GST_START_TEST (test_deep_recovery_retries_once_and_can_recover) +{ + LadderResult res; + /* The device comes back only once the escalation has run, so a frame after + * this point can have arrived through nothing but the post-escalation pass. */ + run_ladder_scenario (TRUE, USB_DEEP_RECOVERY_PORT_CYCLED, FALSE, TRUE, &res); + + fail_unless (res.deep_calls == 1, + "the deep rung is a ONE-SHOT per silence episode, got %d calls", + res.deep_calls); + fail_if (res.errored, + "a device that returns after the escalation must not surface a " + "disconnect error"); + fail_unless (res.buffers_after >= 5, + "the escalation must be followed by a real settle pass that proves " + "recovery with delivered frames, got %d new buffers", res.buffers_after); +} + +GST_END_TEST; + +GST_START_TEST (test_deep_recovery_refusal_falls_through_honestly) +{ + LadderResult res; + /* The rung declined to act (a hub carrying another device). The element must + * report the disconnect rather than spend a second budget on nothing. */ + run_ladder_scenario (TRUE, USB_DEEP_RECOVERY_REFUSED_SHARED_HUB, FALSE, TRUE, + &res); + + fail_unless (res.deep_calls == 1, + "the rung must be consulted exactly once, got %d", res.deep_calls); + fail_unless (res.errored, + "a refused escalation must fall through to the disconnect error, not " + "silently retry as though it had acted"); +} + +GST_END_TEST; + /* ------------------------------------------------------------------------- */ /* Readiness-based reset policy (Task 14) */ /* */ @@ -1378,6 +1640,36 @@ reconnect_suite (void) tcase_add_test (tc_opt_out, test_wedged_device_auto_port_reset_disabled); suite_add_tcase (s, tc_opt_out); + TCase *tc_deep_default = tcase_create ("deep_recovery_defaults_off"); + tcase_set_timeout (tc_deep_default, 30); + tcase_add_test (tc_deep_default, test_deep_recovery_defaults_off); + suite_add_tcase (s, tc_deep_default); + + TCase *tc_deep_off = tcase_create ("deep_recovery_not_run_when_disabled"); + tcase_set_timeout (tc_deep_off, 90); + tcase_add_test (tc_deep_off, test_deep_recovery_not_run_when_disabled); + suite_add_tcase (s, tc_deep_off); + + TCase *tc_deep_order = tcase_create ("deep_recovery_order"); + tcase_set_timeout (tc_deep_order, 90); + tcase_add_test (tc_deep_order, test_deep_recovery_runs_after_the_reset_not_before); + suite_add_tcase (s, tc_deep_order); + + TCase *tc_deep_skip = tcase_create ("deep_recovery_skipped_on_reset_recovery"); + tcase_set_timeout (tc_deep_skip, 90); + tcase_add_test (tc_deep_skip, test_deep_recovery_skipped_when_the_reset_recovers); + suite_add_tcase (s, tc_deep_skip); + + TCase *tc_deep_retry = tcase_create ("deep_recovery_retry_pass"); + tcase_set_timeout (tc_deep_retry, 90); + tcase_add_test (tc_deep_retry, test_deep_recovery_retries_once_and_can_recover); + suite_add_tcase (s, tc_deep_retry); + + TCase *tc_deep_refuse = tcase_create ("deep_recovery_refusal"); + tcase_set_timeout (tc_deep_refuse, 90); + tcase_add_test (tc_deep_refuse, test_deep_recovery_refusal_falls_through_honestly); + suite_add_tcase (s, tc_deep_refuse); + TCase *tc_props = tcase_create ("reset_policy_property_defaults"); tcase_set_timeout (tc_props, 30); tcase_add_test (tc_props, test_reset_policy_property_defaults); diff --git a/tests/test_usb_port_recovery.c b/tests/test_usb_port_recovery.c new file mode 100644 index 0000000..9d3a325 --- /dev/null +++ b/tests/test_usb_port_recovery.c @@ -0,0 +1,408 @@ +/* Deep USB recovery helper (Task 11). + * + * Every case drives the REAL implementation against a synthetic sysfs tree + * built in a temp dir, so the sysfs layout these rungs depend on is asserted + * rather than assumed. The layout mirrors what was measured on the board: + * `authorized` exists only on a DEVICE directory and `disable` only on a PORT + * directory, and a device points at its port through a `port` symlink. + * + * /bus/usb/devices/9-1 -> symlink to the device dir + * /devices/usb9/9-0:1.0/usb9-port1 + * + * A real board cannot be used here: the suite is hardware-independent, and the + * writes under test are root-only and destructive. What hardware DID establish + * is recorded in task-11-board-proof.md. + */ + +#include + +#include +#include +#include +#include + +#include "usb_port_recovery.h" + +typedef struct +{ + gchar *root; /* synthetic sysfs root */ + gchar *devices_dir; /* /bus/usb/devices */ + gchar *device_dir; /* the real device directory */ + gchar *hub_iface; /* the hub interface dir that owns the ports */ + gchar *port_dir; /* our port */ +} SysfsTree; + +static void +write_file (const gchar * dir, const gchar * name, const gchar * value) +{ + gchar *path = g_build_filename (dir, name, NULL); + fail_unless (g_file_set_contents (path, value, -1, NULL), + "could not write %s", path); + g_free (path); +} + +static gchar * +read_file (const gchar * dir, const gchar * name) +{ + gchar *path = g_build_filename (dir, name, NULL); + gchar *out = NULL; + if (!g_file_get_contents (path, &out, NULL, NULL)) + out = NULL; + g_free (path); + return out; +} + +/* Build the minimum tree the helper walks: one hub, `n_ports` ports, and a + * device on port 1 reachable both as /9-1 and through the port's + * `device` link. */ +static void +sysfs_tree_build (SysfsTree * t, guint n_ports) +{ + GError *err = NULL; + t->root = g_dir_make_tmp ("libuvch264src-sysfs-XXXXXX", &err); + fail_unless (t->root != NULL, "could not make temp sysfs root: %s", + err ? err->message : "?"); + + t->devices_dir = g_build_filename (t->root, "bus", "usb", "devices", NULL); + fail_unless (g_mkdir_with_parents (t->devices_dir, 0755) == 0); + + t->device_dir = g_build_filename (t->root, "devices", "usb9", "9-1", NULL); + fail_unless (g_mkdir_with_parents (t->device_dir, 0755) == 0); + write_file (t->device_dir, "busnum", "9\n"); + write_file (t->device_dir, "devnum", "4\n"); + write_file (t->device_dir, "idVendor", "2ca3\n"); + write_file (t->device_dir, "idProduct", "0023\n"); + write_file (t->device_dir, "authorized", "1\n"); + + t->hub_iface = + g_build_filename (t->root, "devices", "usb9", "9-0:1.0", NULL); + fail_unless (g_mkdir_with_parents (t->hub_iface, 0755) == 0); + + for (guint i = 1; i <= n_ports; i++) { + gchar *name = g_strdup_printf ("usb9-port%u", i); + gchar *dir = g_build_filename (t->hub_iface, name, NULL); + fail_unless (g_mkdir_with_parents (dir, 0755) == 0); + write_file (dir, "disable", "0\n"); + if (i == 1) { + t->port_dir = g_strdup (dir); + gchar *link = g_build_filename (dir, "device", NULL); + fail_unless (symlink (t->device_dir, link) == 0); + g_free (link); + } + g_free (dir); + g_free (name); + } + + gchar *alias = g_build_filename (t->devices_dir, "9-1", NULL); + fail_unless (symlink (t->device_dir, alias) == 0); + g_free (alias); + + gchar *port_link = g_build_filename (t->device_dir, "port", NULL); + fail_unless (symlink (t->port_dir, port_link) == 0); + g_free (port_link); +} + +/* Add a second, unrelated device on `port_num` — the shared-hub shape. */ +static void +sysfs_tree_add_neighbour (SysfsTree * t, guint port_num) +{ + gchar *other = g_build_filename (t->root, "devices", "usb9", "9-2", NULL); + fail_unless (g_mkdir_with_parents (other, 0755) == 0); + write_file (other, "idVendor", "1a40\n"); + write_file (other, "idProduct", "0101\n"); + + gchar *name = g_strdup_printf ("usb9-port%u", port_num); + gchar *dir = g_build_filename (t->hub_iface, name, NULL); + gchar *link = g_build_filename (dir, "device", NULL); + fail_unless (symlink (other, link) == 0); + g_free (link); + g_free (dir); + g_free (name); + g_free (other); +} + +/* Detach the device exactly as a failed re-enumeration does: the device object + * and every path that reaches it disappear; the port directory stays. */ +static void +sysfs_tree_unplug (SysfsTree * t) +{ + gchar *alias = g_build_filename (t->devices_dir, "9-1", NULL); + gchar *occupant = g_build_filename (t->port_dir, "device", NULL); + fail_unless (g_remove (alias) == 0); + fail_unless (g_remove (occupant) == 0); + g_free (alias); + g_free (occupant); + + const gchar *attrs[] = { "busnum", "devnum", "idVendor", "idProduct", + "authorized", "port" + }; + for (guint i = 0; i < G_N_ELEMENTS (attrs); i++) { + gchar *p = g_build_filename (t->device_dir, attrs[i], NULL); + g_remove (p); + g_free (p); + } + fail_unless (g_remove (t->device_dir) == 0); +} + +static void +sysfs_tree_free (SysfsTree * t) +{ + gchar *cmd[] = { (gchar *) "rm", (gchar *) "-rf", t->root, NULL }; + g_spawn_sync (NULL, cmd, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, NULL, NULL, + NULL, NULL); + g_clear_pointer (&t->root, g_free); + g_clear_pointer (&t->devices_dir, g_free); + g_clear_pointer (&t->device_dir, g_free); + g_clear_pointer (&t->hub_iface, g_free); + g_clear_pointer (&t->port_dir, g_free); +} + +/* ------------------------------------------------------------------------- */ +/* Capture */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_capture_resolves_device_and_port) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + UsbRecoveryTarget target = { 0 }; + fail_unless (usb_recovery_target_capture (t.root, 9, 4, &target), + "a device at bus 9 address 4 must be resolvable"); + fail_unless (target.vendor_id == 0x2ca3 && target.product_id == 0x0023, + "capture must record the identity, got %04x:%04x", target.vendor_id, + target.product_id); + fail_unless (target.port_path != NULL + && strstr (target.port_path, "usb9-port1") != NULL, + "capture must resolve the port through the `port` symlink, got %s", + target.port_path ? target.port_path : "(null)"); + + usb_recovery_target_clear (&target); + fail_unless (target.device_path == NULL && target.port_path == NULL); + sysfs_tree_free (&t); +} + +GST_END_TEST; + +GST_START_TEST (test_capture_rejects_a_foreign_address) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + UsbRecoveryTarget target = { 0 }; + fail_if (usb_recovery_target_capture (t.root, 9, 7, &target), + "an address no device holds must not resolve"); + fail_unless (target.port_path == NULL, + "a failed capture must leave nothing to act on"); + + sysfs_tree_free (&t); +} + +GST_END_TEST; + +GST_START_TEST (test_capture_requires_the_port_link) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + gchar *port_link = g_build_filename (t.device_dir, "port", NULL); + fail_unless (g_remove (port_link) == 0); + g_free (port_link); + + UsbRecoveryTarget target = { 0 }; + fail_if (usb_recovery_target_capture (t.root, 9, 4, &target), + "without a port link there is no path that survives the reset, so the " + "capture must fail rather than hand back a device-only target"); + + sysfs_tree_free (&t); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* Rung selection */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_surviving_device_takes_the_device_rung) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + UsbRecoveryTarget target = { 0 }; + fail_unless (usb_recovery_target_capture (t.root, 9, 4, &target)); + + UsbDeepRecoveryOutcome rc = usb_deep_recovery_run (&target, 0); + fail_unless (rc == USB_DEEP_RECOVERY_DEVICE_REPROBED, + "a device that survived must take the cheap device-level rung, got %s", + usb_deep_recovery_outcome_name (rc)); + + gchar *authorized = read_file (t.device_dir, "authorized"); + fail_unless (g_strcmp0 (authorized, "1") == 0, + "the re-probe must leave the device AUTHORIZED, got \"%s\"", + authorized ? authorized : "(null)"); + g_free (authorized); + + gchar *disable = read_file (t.port_dir, "disable"); + fail_unless (g_strcmp0 (disable, "0\n") == 0, + "the port must not be touched while a device rung was available, " + "disable is now \"%s\"", disable ? disable : "(null)"); + g_free (disable); + + usb_recovery_target_clear (&target); + sysfs_tree_free (&t); +} + +GST_END_TEST; + +GST_START_TEST (test_vanished_device_takes_the_port_rung) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + UsbRecoveryTarget target = { 0 }; + fail_unless (usb_recovery_target_capture (t.root, 9, 4, &target)); + /* The post-`error -71` shape: the device object is gone, the port remains. */ + sysfs_tree_unplug (&t); + + UsbDeepRecoveryOutcome rc = usb_deep_recovery_run (&target, 0); + fail_unless (rc == USB_DEEP_RECOVERY_PORT_CYCLED, + "with no device object left the port rung is the only one addressable, " + "got %s", usb_deep_recovery_outcome_name (rc)); + + gchar *disable = read_file (t.port_dir, "disable"); + fail_unless (g_strcmp0 (disable, "0") == 0, + "the cycle must RESTORE the port; a port left disabled cannot even be " + "recovered by a replug. disable is now \"%s\"", + disable ? disable : "(null)"); + g_free (disable); + + usb_recovery_target_clear (&target); + sysfs_tree_free (&t); +} + +GST_END_TEST; + +/* ------------------------------------------------------------------------- */ +/* Target validation */ +/* ------------------------------------------------------------------------- */ + +GST_START_TEST (test_hub_with_another_device_is_refused) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 4); + sysfs_tree_add_neighbour (&t, 3); + + UsbRecoveryTarget target = { 0 }; + fail_unless (usb_recovery_target_capture (t.root, 9, 4, &target)); + sysfs_tree_unplug (&t); + + UsbDeepRecoveryOutcome rc = usb_deep_recovery_run (&target, 0); + fail_unless (rc == USB_DEEP_RECOVERY_REFUSED_SHARED_HUB, + "a hub carrying another device may be switching power in ganged mode, " + "so the cycle must be refused, got %s", + usb_deep_recovery_outcome_name (rc)); + + gchar *disable = read_file (t.port_dir, "disable"); + fail_unless (g_strcmp0 (disable, "0\n") == 0, + "a refused cycle must not have written anything, disable is \"%s\"", + disable ? disable : "(null)"); + g_free (disable); + + usb_recovery_target_clear (&target); + sysfs_tree_free (&t); +} + +GST_END_TEST; + +GST_START_TEST (test_foreign_device_on_the_port_is_refused) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + UsbRecoveryTarget target = { 0 }; + fail_unless (usb_recovery_target_capture (t.root, 9, 4, &target)); + + /* Bus addresses are recycled: a different camera now answers at our path. */ + write_file (t.device_dir, "idVendor", "046d\n"); + write_file (t.device_dir, "idProduct", "0892\n"); + + UsbDeepRecoveryOutcome rc = usb_deep_recovery_run (&target, 0); + fail_unless (rc == USB_DEEP_RECOVERY_REFUSED_IDENTITY, + "a device that is not ours must never be reset by us, got %s", + usb_deep_recovery_outcome_name (rc)); + + gchar *authorized = read_file (t.device_dir, "authorized"); + fail_unless (g_strcmp0 (authorized, "1\n") == 0, + "a refused rung must not have written anything, authorized is \"%s\"", + authorized ? authorized : "(null)"); + g_free (authorized); + + usb_recovery_target_clear (&target); + sysfs_tree_free (&t); +} + +GST_END_TEST; + +GST_START_TEST (test_missing_target_is_reported_not_guessed) +{ + UsbRecoveryTarget empty = { 0 }; + UsbDeepRecoveryOutcome rc = usb_deep_recovery_run (&empty, 0); + fail_unless (rc == USB_DEEP_RECOVERY_NO_TARGET, + "an uncaptured target must be reported, not guessed at, got %s", + usb_deep_recovery_outcome_name (rc)); + + rc = usb_deep_recovery_run (NULL, 0); + fail_unless (rc == USB_DEEP_RECOVERY_NO_TARGET); +} + +GST_END_TEST; + +GST_START_TEST (test_unwritable_attribute_reports_denied) +{ + SysfsTree t = { 0 }; + sysfs_tree_build (&t, 1); + + UsbRecoveryTarget target = { 0 }; + fail_unless (usb_recovery_target_capture (t.root, 9, 4, &target)); + + gchar *authorized = g_build_filename (t.device_dir, "authorized", NULL); + fail_unless (g_chmod (authorized, 0444) == 0); + g_free (authorized); + + UsbDeepRecoveryOutcome rc = usb_deep_recovery_run (&target, 0); + /* Running as root defeats a mode-0444 guard, so accept either the honest + * denial or a completed re-probe — what must never happen is a silent + * success that hides a permission problem behind a generic failure. */ + fail_unless (rc == USB_DEEP_RECOVERY_DENIED + || rc == USB_DEEP_RECOVERY_DEVICE_REPROBED, + "an unwritable attribute must surface as denied (or, as root, succeed), " + "got %s", usb_deep_recovery_outcome_name (rc)); + + usb_recovery_target_clear (&target); + sysfs_tree_free (&t); +} + +GST_END_TEST; + +static Suite * +usb_port_recovery_suite (void) +{ + Suite *s = suite_create ("usb_port_recovery"); + TCase *tc = tcase_create ("general"); + + suite_add_tcase (s, tc); + tcase_set_timeout (tc, 30); + tcase_add_test (tc, test_capture_resolves_device_and_port); + tcase_add_test (tc, test_capture_rejects_a_foreign_address); + tcase_add_test (tc, test_capture_requires_the_port_link); + tcase_add_test (tc, test_surviving_device_takes_the_device_rung); + tcase_add_test (tc, test_vanished_device_takes_the_port_rung); + tcase_add_test (tc, test_hub_with_another_device_is_refused); + tcase_add_test (tc, test_foreign_device_on_the_port_is_refused); + tcase_add_test (tc, test_missing_target_is_reported_not_guessed); + tcase_add_test (tc, test_unwritable_attribute_reports_denied); + + return s; +} + +GST_CHECK_MAIN (usb_port_recovery);