Skip to content

fix(device): hand the kernel driver back when the claim fails - #10

Merged
andrescera merged 1 commit into
mainfrom
fix/claim-failure-strands-interface
Jul 29, 2026
Merged

fix(device): hand the kernel driver back when the claim fails#10
andrescera merged 1 commit into
mainfrom
fix/claim-failure-strands-interface

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

uvc_claim_if() now hands the kernel driver back when the claim fails, and the reattach
backstop arms at the detach instead of after a successful claim.

Why

Detaching the kernel driver and claiming the interface are two separate kernel calls, and
this function rolled back neither. A detach that lands followed by a claim that fails — a
busy device, a kernel racing the same interface — left the interface bound to nothing:

ret = libusb_detach_kernel_driver(devh->usb_devh, idx);
if (ret == UVC_SUCCESS || ret == LIBUSB_ERROR_NOT_FOUND || ret == LIBUSB_ERROR_NOT_SUPPORTED) {
  if (!( ret = libusb_claim_interface(devh->usb_devh, idx))) {
    devh->claimed |= ( 1 << idx );
    uvc_arm_reattach_guard(devh, idx);      /* only on success */
  }
}                                            /* no else: no re-attach, no arm */

What makes that terminal rather than transient is that nothing ever comes back for it:
uvc_release_if() returns early for any interface absent from devh->claimed, so the driver
stays off for the rest of the process's life. No kill and no crash are involved — a
completely healthy process ends up permanently one interface short. Measured on an RK3588
board as 5-1:1.0=usbfs, 5-1:1.1=NONE persisting 83 seconds with nothing killed. The
window is wider in practice than the code suggests, because the claims are split across two
moments: uvc_open() claims VideoControl, and VideoStreaming is claimed later at stream
negotiation.

This is a different defect from the one #9 fixed. #9 covers a process that dies without
running its cleanup. This one is a process that runs everything and still strands the
interface.

How

Two separable changes:

  1. The repair, in-process. A claim that fails is followed by the
    libusb_attach_kernel_driver() the release path would have made. The caller still receives
    the claim's error — what happened to the driver afterwards is not the caller's business.
    This works in both build variants; with LIBUVC_REATTACH_GUARD=OFF the repair still runs
    and only the bookkeeping below compiles out.

  2. The bookkeeping. An interface is armed before the detach, because it is the detach
    — not the claim — that makes this process the reason the interface is unbound. It is
    disarmed again on every path out that does not end in a claim:

    Outcome devh->claimed attach_kernel_driver armed at return returns
    detach fails hard unset not called no the detach's error
    detach ok, claim ok set not called yes UVC_SUCCESS
    detach ok, claim fails, reattach ok/NOT_FOUND/NOT_SUPPORTED unset once no the claim's error
    detach ok, claim fails, reattach fails hard unset once yes the claim's error

    The disarm predicate is uvc_release_if()'s, unchanged — SUCCESS, NOT_FOUND and
    NOT_SUPPORTED all mean the kernel has the interface back — so there is still exactly one
    definition of that in the codebase.

src/reattach_guard.c and include/libuvc/reattach_guard.h are untouched. #9's arm/disarm
API was already sufficient; only the call sequence in device.c moved. That is deliberate:
no helper-side line was added or reordered, so the forked helper's async-signal-safety is not
reachable from this change, and the review surface is one function.

Three new regression cases, all reproducing the defect before the fix:

  • claim_failure_after_detach_reattaches — the defect itself (attach_calls == 0 pre-fix)
  • failed_reattach_after_failed_claim_stays_armed — the backstop half: if the in-process
    repair also fails, the interface must stay armed
  • detach_failure_leaves_nothing_armed — a guard for the risk arming early adds: an
    interface armed that libuvc then never takes

claiming_an_interface_arms_the_guard keeps all six of its original assertions and gains
three that read the armed mask from inside the --wrapped detach call — the only way to
distinguish "armed before the detach" from "armed after the claim". Nothing it verified was
weakened.

How to verify

# ON (default): 33 -> 36
cmake -S . -B build/regression -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
  -DCMAKE_BUILD_TYPE=Debug -DCMAKE_BUILD_TARGET=Static -DBUILD_SHARED_LIBS=OFF \
  -DBUILD_EXAMPLE=OFF -DBUILD_TEST=OFF -DBUILD_TESTING=ON
cmake --build build/regression --parallel
ctest --test-dir build/regression --show-only=json-v1 | jq -e '.tests | length == 36'
ctest --test-dir build/regression --output-on-failure      # 36/36

# OFF: still exactly the 27 that predate the guard
cmake -S . -B build-noguard <same flags> -DLIBUVC_REATTACH_GUARD=OFF
ctest --test-dir build-noguard --output-on-failure          # 27/27

# TSan job's filter
cmake -S . -B build/tsan <same flags> -DLIBUVC_SANITIZE=thread
ctest --test-dir build/tsan --output-on-failure -R 'libuvc\.(teardown|race|reattach)\.'  # 17/17

To see the defect, check out this branch, revert src/device.c only, rebuild, and run
-R 'libuvc\.reattach\.': cases 32, 33 and 34 go red at guard_existed_at_detach[0] == 1
and attach_calls == 1.

detach_failure_leaves_nothing_armed is green both before and after by design — it guards the
fix, it does not reproduce the bug. It was proved load-bearing by mutation: delete only the
disarm on the detach-failure branch and it fails at
uvc_reattach_guard_armed_mask(...) == 0.

Also verified: -Wall -Wextra clean on both changed files, and the
LIBUVC_AUTO_DETACH_KERNEL_DRIVER=OFF shared build still builds.

Risks

  • This retimes an already-shipped guard (fix(device): reattach the kernel driver from outside the dying process #9). The arm moves earlier and gains two disarm
    sites. The compensating cover is that claiming_an_interface_arms_the_guard now asserts the
    timing directly rather than only the end state, so a future regression in either direction
    is caught.
  • A guard is now forked for a claim that goes on to fail. Arming is what creates the guard,
    so a uvc_claim_if() that fails at the detach forks a helper it then disarms. Cost: one
    process that wakes to an empty armed set and exits. uvc_free_devh() destroys it as before.
  • libusb_attach_kernel_driver() on a VideoStreaming interface binds nothing immediately,
    because uvcvideo claims streaming interfaces as part of the VideoControl probe rather than
    probing them directly. The CONNECT still succeeds and the interface still leaves this
    process's responsibility — the state becomes indistinguishable from "libuvc never touched
    it" — but this does not make /dev/videoN reappear on the spot for a VS-only failure.
    That is unchanged from the existing teardown ordering contract (README invariant 2).
  • The accepted detach→claim kill-timing residual is NOT closed by this and is not claimed
    closed.
    Arming before the detach does mean an interface is now armed during that window as
    a side effect, but that consequence has not been re-measured on hardware (it scored 4/4
    WEDGED pre-fix), so the residual stays open and tracked exactly as it was.
  • No hardware was used. The --wrap harness forces the exact failure — a claim failing
    after a successful detach, in a live, unkilled process — and observes the repair directly. A
    board run could only re-observe a transient the harness produces on demand.

Docs updated in the same change: README (inventory 33→36, reattach group 5→8, teardown
contract invariant 5 rewritten for the new arm point plus the claim-failure reattach as a
contract term), CHANGELOG.ceralive.md, and the CI exact-inventory jq assertion.

uvc_claim_if() detaches the kernel driver and claims the interface in two
separate kernel calls, and rolled back neither. A detach that lands followed by
a claim that fails -- a busy device, a kernel racing the same interface -- left
the interface bound to nothing, in a process that is alive and well. Nothing
came back for it either: uvc_release_if() returns early for any interface absent
from devh->claimed, so the driver stayed off for the rest of the process's life.
Measured on an RK3588 board as 5-1:1.0=usbfs, 5-1:1.1=NONE persisting 83 seconds
with nothing killed. The claims are split across two moments -- VideoControl at
uvc_open(), VideoStreaming later at stream negotiation -- which widens the window
in practice.

uvc_claim_if() now undoes its own detach with the libusb_attach_kernel_driver()
the release path would have made. The caller still receives the claim's error;
what happened to the driver afterwards is not the caller's business.

The reattach backstop's arming moved with it. An interface is armed BEFORE the
detach rather than after a successful claim, because it is the detach, not the
claim, that makes this process the reason the interface is unbound. It is
disarmed again on every path out that does not end in a claim: a detach that
failed took nothing, and a claim that failed is disarmed only once the driver is
genuinely back -- a hard reattach failure stays armed so the helper repairs it
from outside. That predicate is uvc_release_if()'s, unchanged, so there is still
exactly one definition of "the kernel has this interface back".

reattach_guard.c is untouched: todo 5's arm/disarm API was already sufficient and
only its call sequence moved, so the forked helper's async-signal-safety is not
reachable from this change. With LIBUVC_REATTACH_GUARD=OFF the reattach still
runs and only the bookkeeping compiles out, so the rollback build gets the repair
without the backstop.

Three regression cases, all red first:

  claim_failure_after_detach_reattaches           the defect itself
  failed_reattach_after_failed_claim_stays_armed  the backstop half
  detach_failure_leaves_nothing_armed             the risk arming early adds

claiming_an_interface_arms_the_guard keeps every assertion it had and gains three
that read the armed mask from inside the detach call, which is the only way to
tell "armed before the detach" from "armed after the claim".
@andrescera
andrescera merged commit 4868e57 into main Jul 29, 2026
3 checks passed
@andrescera
andrescera deleted the fix/claim-failure-strands-interface branch July 29, 2026 14:53
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