Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ jobs:
"libuvc.negotiation.probe_set_error",
"libuvc.transfer.retry_failure",
"libuvc.transfer.retry_success",
"libuvc.transfer.terminal_statuses"
] | sort) and (.tests | length == 19)
"libuvc.transfer.terminal_statuses",
"libuvc.teardown.status_xfer_stops_before_control_release",
"libuvc.teardown.every_claimed_interface_released_control_last",
"libuvc.teardown.no_status_endpoint_unchanged",
"libuvc.teardown.undeliverable_status_xfer_quarantines"
] | sort) and (.tests | length == 23)
' "$result_dir/inventory.json" \
2>&1 | tee "$result_dir/inventory-check.log"

Expand Down
48 changes: 48 additions & 0 deletions CHANGELOG.ceralive.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,54 @@ the upstream history, see `changelog.txt`.

### Fixed

- **`uvc_close()` left the camera's kernel driver detached, so `/dev/videoN`
never came back.** Two independent teardown defects, both reproduced on a
Rockchip RK3588 board with a DJI Osmo Pocket 3:

1. **The VideoControl status interrupt transfer was never stopped.**
`uvc_open_internal()` submits it when the VideoControl interface carries a
status interrupt endpoint (optional in UVC, but present on most cameras),
and `_uvc_status_callback()` re-arms it after every completion.
`uvc_close()` never cancelled or freed it, so it kept re-arming while the
close released that same interface and handed it back to `uvcvideo`. The
resubmission then reached usbfs on an interface the process no longer
claimed:

```
usb 5-1: usbfs: process 104350 (...) did not claim interface 0 before use
```

usbfs re-claims the interface on that path, evicting the driver that had
just been reattached, and the following `libusb_close()` drops the claim
without rebinding anything — leaving the interface with no driver at all.
`uvc_close()` now cancels the transfer and waits, bounded
(`LIBUVC_STATUS_STOP_TIMEOUT_MS`, default 500 ms), before touching any
interface. A new `status_mutex` makes the callback's stopping-check and its
resubmission one critical section with that stop, so a resubmission cannot
slip between them. A drain that times out quarantines the handle exactly as
a wedged stream already does, rather than freeing memory libusb still
references.

2. **Only the VideoControl interface was released.**
`uvc_get_stream_ctrl_format_size()` claims the streaming interface before
it probes and returns `UVC_ERROR_INVALID_MODE` without releasing it, and
`uvc_stream_open_ctrl()`'s failure path frees the stream handle without
releasing it either — so any failed negotiation orphaned that interface
until `libusb_close()` dropped the usbfs fd, which does not rebind a kernel
driver. `uvc_close()` now releases every interface still in `devh->claimed`,
**VideoControl last**: reattaching the driver to VideoControl is what makes
`uvcvideo` probe the whole function, and that probe claims the
VideoStreaming interfaces itself, so releasing control first made it log
`No streaming interface found for terminal N` and register no video node.

Cameras whose VideoControl interface exposes no status endpoint (e.g. the RØDE
HDMI-to-USB-C) never submitted the transfer and are unaffected by (1); their
teardown is byte-identical.

Covered by four new hardware-independent CTest cases
(`libuvc.teardown.*`) that `--wrap` the libusb entry points into an ordered
operation log and drive the real `uvc_close()`.

- **Reject inconsistent UVC descriptor lengths before parser dispatch.** The
VideoControl and VideoStreaming descriptor scanners now return
`UVC_ERROR_INVALID_DEVICE` for a declared length below the three-byte header
Expand Down
17 changes: 16 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,13 @@ if(BUILD_TESTING)
add_executable(uvc_descriptor_assertions tests/descriptor_assertions.c)
add_executable(uvc_negotiation_assertions tests/negotiation_assertions.c)
add_executable(uvc_transfer_assertions tests/transfer_assertions.c)
add_executable(uvc_teardown_assertions tests/teardown_assertions.c)

foreach(test_target
uvc_descriptor_assertions
uvc_negotiation_assertions
uvc_transfer_assertions)
uvc_transfer_assertions
uvc_teardown_assertions)
target_link_libraries(${test_target}
PRIVATE uvc_static LibUSB::LibUSB ${threads})
if(JPEG_FOUND)
Expand All @@ -180,6 +182,8 @@ if(BUILD_TESTING)
PROPERTY LINK_FLAGS " -Wl,--wrap=libusb_control_transfer -Wl,-Map=${CMAKE_CURRENT_BINARY_DIR}/02-link.map")
set_property(TARGET uvc_transfer_assertions APPEND_STRING
PROPERTY LINK_FLAGS " -Wl,--wrap=libusb_submit_transfer -Wl,--wrap=libusb_free_transfer -Wl,--wrap=free")
set_property(TARGET uvc_teardown_assertions APPEND_STRING
PROPERTY LINK_FLAGS " -Wl,--wrap=libusb_submit_transfer -Wl,--wrap=libusb_cancel_transfer -Wl,--wrap=libusb_release_interface -Wl,--wrap=libusb_attach_kernel_driver -Wl,--wrap=libusb_set_interface_alt_setting -Wl,--wrap=libusb_close -Wl,--wrap=libusb_unref_device -Wl,--wrap=nanosleep")

foreach(case_name
h264
Expand Down Expand Up @@ -208,6 +212,17 @@ if(BUILD_TESTING)
add_test(NAME libuvc.transfer.${case_name}
COMMAND uvc_transfer_assertions --case ${case_name})
endforeach()
foreach(case_name
status_xfer_stops_before_control_release
every_claimed_interface_released_control_last
no_status_endpoint_unchanged
undeliverable_status_xfer_quarantines)
add_test(NAME libuvc.teardown.${case_name}
COMMAND uvc_teardown_assertions --case ${case_name})
endforeach()
set_tests_properties(
libuvc.teardown.undeliverable_status_xfer_quarantines
PROPERTIES TIMEOUT 5)
endif()

if(BUILD_EXAMPLE)
Expand Down
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,39 @@ Linux CTest suite. Configure, build, inspect, and run its static build with:
| jq -e '.tests | length == 19'
ctest --test-dir build/regression --output-on-failure

The 19 cases are grouped as descriptor (11: `h264`, `h265`,
The 23 cases are grouped as descriptor (11: `h264`, `h265`,
`truncated_format`, `truncated_frame`, `degenerate_h26x`,
`scanner_vc_header_short`, `scanner_vc_oversized`, `scanner_vc_zero`,
`scanner_vs_header_short`, `scanner_vs_oversized`, `scanner_vs_zero`),
negotiation (5: `h264`, `h265`, `near_match`, `probe_set_error`,
`probe_get_error`), and transfer (3: `terminal_statuses`, `retry_success`,
`retry_failure`). CI runs this suite without camera hardware on Ubuntu 22.04
`probe_get_error`), transfer (3: `terminal_statuses`, `retry_success`,
`retry_failure`), and teardown (4: `status_xfer_stops_before_control_release`,
`every_claimed_interface_released_control_last`,
`no_status_endpoint_unchanged`, `undeliverable_status_xfer_quarantines`).
CI runs this suite without camera hardware on Ubuntu 22.04
and Ubuntu 24.04. See
`docs/evidence/uvc-camera-compat-stability.md` for its exact scope.

Adjust the `jq` length assertion above to `23` when running it.

### Device teardown contract

`uvc_close()` owns the whole USB teardown and must keep two invariants that are
not visible from the call site — both are regression-locked by the
`libuvc.teardown.*` cases:

1. **The VideoControl status interrupt transfer is stopped before any interface
is released.** It re-arms itself from `_uvc_status_callback()`, so a
resubmission that lands after the release makes usbfs re-claim the interface
from the kernel driver it was just handed back to; the final `libusb_close()`
then leaves it bound to nothing and the camera's `/dev/videoN` never returns.
`status_mutex` keeps the callback's stopping-check and its resubmission atomic
against that stop.
2. **Every interface in `devh->claimed` is released, VideoControl LAST.** A
failed negotiation can leave the streaming interface claimed, and reattaching
the driver to VideoControl is what triggers `uvcvideo`'s probe — a probe that
claims the streaming interfaces itself, so it must run after they are free.

## Developing with libuvc

The documentation for `libuvc` can currently be found at https://libuvc.github.io/.
Expand Down
39 changes: 39 additions & 0 deletions include/libuvc/libuvc_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <string.h>
#include <pthread.h>
#include <signal.h>
#include <time.h>
#include <libusb.h>
#include "utlist.h"

Expand Down Expand Up @@ -252,6 +253,19 @@ typedef struct uvc_device_info {
#define LIBUVC_STREAM_STOP_TIMEOUT_ATTEMPTS 5
#endif

/* Bound uvc_close()'s wait for the VideoControl status interrupt transfer to come
* back after it is cancelled, mirroring the stream-stop bound above: a wedged
* event thread must not hang the caller. The endpoint's bInterval is typically
* 8-32 ms, so a cancellation normally lands within one interval; the default is
* an order of magnitude above that.
*/
#ifndef LIBUVC_STATUS_STOP_TIMEOUT_MS
#define LIBUVC_STATUS_STOP_TIMEOUT_MS 500
#endif
#ifndef LIBUVC_STATUS_STOP_POLL_MS
#define LIBUVC_STATUS_STOP_POLL_MS 2
#endif

#define LIBUVC_XFER_META_BUF_SIZE ( 4 * 1024 )

struct uvc_stream_handle {
Expand Down Expand Up @@ -331,6 +345,31 @@ struct uvc_device_handle {
* handle (uvc_free_devh) or tear down anything it owns; it quarantines the
* device handle too, mirroring the stream-handle leak. Never cleared. */
uint8_t has_quarantined_stream;
/** Serializes _uvc_status_callback()'s decide-and-resubmit against
* uvc_stop_status_xfer()'s stop-and-cancel. Both flags below are read and
* written under it. Without the mutex the two interleave: the callback can
* read status_xfer_stopping as 0, lose the CPU, and issue its resubmission
* after uvc_stop_status_xfer() has already returned and uvc_close() has
* released the VideoControl interface -- the exact URB-after-release the stop
* exists to prevent. Initialized in uvc_open_internal(), destroyed in
* uvc_free_devh(). */
pthread_mutex_t status_mutex;
/** Non-zero while `status_xfer` is submitted to libusb -- set when
* uvc_open_internal() submits it, cleared by _uvc_status_callback() on the
* libusb event thread once the transfer is no longer resubmitted. Polled by
* uvc_stop_status_xfer() on the closing thread, hence `volatile`. */
volatile uint8_t status_xfer_submitted;
/** Set by uvc_stop_status_xfer() BEFORE it cancels `status_xfer`: tells
* _uvc_status_callback() to stop resubmitting. Without it the callback keeps
* re-arming the interrupt URB on the VideoControl interface AFTER uvc_close()
* released that interface and reattached the kernel driver -- usbfs then logs
* "did not claim interface N before use" and steals the interface back, so the
* final libusb_close() leaves it bound to no driver at all. Never cleared. */
volatile uint8_t status_xfer_stopping;
/** Set when uvc_stop_status_xfer()'s bounded wait expired with `status_xfer`
* still owned by libusb. Quarantines the handle for the same reason
* has_quarantined_stream does: a late callback dereferences devh. */
uint8_t has_quarantined_status_xfer;
};

/** Context within which we communicate with devices */
Expand Down
Loading