Skip to content

fix(device): reattach the kernel driver from outside the dying process - #9

Merged
andrescera merged 1 commit into
mainfrom
fix/reattach-guard-survives-uncatchable-exit
Jul 29, 2026
Merged

fix(device): reattach the kernel driver from outside the dying process#9
andrescera merged 1 commit into
mainfrom
fix/reattach-guard-survives-uncatchable-exit

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

libuvc detaches the kernel uvcvideo driver when it claims a UVC interface, and hands it
back only from uvc_close(). Any exit that skips that teardown — SIGKILL, SIGSEGV, the
systemd watchdog's SIGABRT — leaves both the VideoControl and VideoStreaming interfaces at
driver = NONE permanently, with no self-recovery. The camera is gone until someone
manually writes to /sys/bus/usb/drivers/uvcvideo/bind.

This moves the reattach out of the process that has to die for the bug to happen.
uvc_claim_if() double-forks a small helper and keeps the write end of a pipe. The helper
blocks on read(); the wakeup is the EOF the kernel delivers when the last write end closes,
and the kernel closes a dying process's descriptors unconditionally, whatever killed it. On
wake the helper reopens the device by usbfs path, verifies the device descriptor still
matches (bus addresses get reused), and issues the USBDEVFS_CONNECT that
libusb_attach_kernel_driver() would have issued.

The load-bearing property: the code that repairs the binding is not in the process that
died, so the manner of death is irrelevant.

The quarantine paths in uvc_close() deliberately leave the guard armed and undestroyed
rather than releasing. Releasing there cannot work — with a quarantined status transfer,
handing back VideoControl lets the next URB resubmission re-claim through usbfs and evict
the driver again (the defect #7 fixed); with a quarantined stream, releasing VideoControl
alone makes uvcvideo probe while usbfs still holds the streaming interfaces, and it
registers no video node at all. Leaving the guard armed turns a quarantine into a leak
bounded by the process lifetime instead of a wedged camera.

Gated by LIBUVC_REATTACH_GUARD (default ON, Linux only); every entry point tolerates a
NULL guard, so a platform or build without it behaves exactly as before.

Why

Root-caused, not guessed. The trigger reproduced 10/10 across three unrelated holder
processes — including a bare gst-launch-1.0 libuvch264src with no engine involved — while
the counterfactual passed: SIGTERM on the identical held state recovered 3/3, ~94 normal
claim/release cycles produced zero wedges, and a v4l2-path camera under the identical
SIGKILL never wedges because it never had a detach to undo.

This is not a theoretical hardening. The systemd watchdog already fires SIGABRT on
frames-not-advancing, so the documented watchdog → wedge → restart loop is this defect,
self-triggering, with no human in the loop — and systemd reports the unit active while the
camera is missing.

A purely userspace fix in the close path is provably insufficient here: SIGKILL executes no
user code at all.

How to verify

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_BUILD_TARGET=Static \
  -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=ON
cmake --build build --parallel && ctest --test-dir build --output-on-failure
  • 33/33 regression cases, up from 27. Six are new: five drive a real SIGKILL against
    a forked victim process rather than simulating one, and
    libuvc.teardown.quarantined_handle_stays_armed fails if anyone "tidies up" the quarantine
    branch by disarming it.
  • -DLIBUVC_REATTACH_GUARD=OFF keeps all 27 pre-existing cases green, and CI asserts the
    inventory is exactly 27 with no libuvc.reattach.*, so a rollback is a real rollback and
    not a quietly smaller gate.
  • ThreadSanitizer job covers the teardown, race and reattach cases, including the new forking
    ones.

Verified on real RK3588 hardware with a DJI Osmo Pocket 3 before this PR was opened. Both
previously-wedging cells were re-run ×5 each, killing only once the holder was observed
holding the interface (driver=usbfs and an open fd on the device's usbfs node — a
blind-timer kill can pass by accident and has):

cell before after
bare gst-launch-1.0 libuvch264src, SIGKILLed 3/3 wedged 5/5 recovered
cerastream SIGKILLed mid-stream 5/5 wedged 5/5 recovered

Median reattach 34 ms, and no manual rebind was needed before any attempt. The kernel
journal independently corroborates it: a usb 5-1: Found UVC 1.00 device re-probe lands
6–19 ms after every one of the ten kills, with nobody issuing a bind command.

Risks

  • A residual window remains and is not closed by this PR. The guard is armed after
    libusb_claim_interface() returns, so a kill landing in the detach→claim gap (tens of
    milliseconds, once per claim) still wedges — reproduced 3/3 on hardware, where the
    helper-process probe correctly found no helper. That is a narrower remnant of the same
    window, not a regression: the behaviour there is exactly what it is on main today.
    Closing it means arming before the detach and disarming on claim failure, which is a
    separate, deliberate change.
  • The helper is a forked process, so it must touch only async-signal-safe calls — hence the
    hand-rolled digit formatting instead of snprintf, and poll() instead of nanosleep()
    (the teardown suite --wraps nanosleep, and a forked helper must not fall into a test's
    synthetic event thread).
  • It deliberately does not inherit the usbfs fd. Inheriting would be immune to bus-address
    reuse but strictly worse on failure: a stuck helper would pin the claim and create an
    unrecoverable wedge. Reopen-by-path plus an identity check means a dead helper leaves
    today's behaviour and never something worse.
  • The EBUSY retry loop is required, not polish: the helper wakes during the dying
    process's exit_files(), and until the usbfs fd is among the closed ones the kernel answers
    EBUSY. A single attempt loses that race silently.

A holder killed with SIGKILL/SIGSEGV/SIGABRT runs no user code, so uvc_close()
never runs and both UVC interfaces stay driver=NONE indefinitely. Fixed by
forking a helper on first interface claim that wakes on the pipe EOF the
kernel delivers unconditionally when the claiming process dies, and reissues
the USBDEVFS_CONNECT that process would have made. Quarantine paths leave the
guard armed instead of releasing (which would recreate the eviction defect or
race uvcvideo's probe), turning a quarantine into a process-lifetime-bounded
leak rather than a wedged camera.

Gated by LIBUVC_REATTACH_GUARD (default ON, Linux only). 6 new regression
cases (5 real SIGKILL against a forked victim); CI runs the OFF variant too
so a rollback stays a real rollback.

Board-Evidence-SHA256: 2dd90033fbd672b74a1f277890a216e3d152e9558d2857422f43d86dd40c51c0
@andrescera
andrescera merged commit b3be8d9 into main Jul 29, 2026
3 checks passed
@andrescera
andrescera deleted the fix/reattach-guard-survives-uncatchable-exit branch July 29, 2026 00:22
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