diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1582f67..387e960 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" diff --git a/CHANGELOG.ceralive.md b/CHANGELOG.ceralive.md index 30cc2d4..9bf6410 100644 --- a/CHANGELOG.ceralive.md +++ b/CHANGELOG.ceralive.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e8b3d9..3a301a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) @@ -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 @@ -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) diff --git a/README.md b/README.md index e06c93c..1e6d435 100644 --- a/README.md +++ b/README.md @@ -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/. diff --git a/include/libuvc/libuvc_internal.h b/include/libuvc/libuvc_internal.h index ff0cebf..110bfdf 100644 --- a/include/libuvc/libuvc_internal.h +++ b/include/libuvc/libuvc_internal.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "utlist.h" @@ -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 { @@ -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 */ diff --git a/src/device.c b/src/device.c index 1abc378..3da64e0 100644 --- a/src/device.c +++ b/src/device.c @@ -361,6 +361,7 @@ static uvc_error_t uvc_open_internal( internal_devh = calloc(1, sizeof(*internal_devh)); internal_devh->dev = dev; internal_devh->usb_devh = usb_devh; + pthread_mutex_init(&internal_devh->status_mutex, NULL); ret = uvc_get_device_info(internal_devh, &(internal_devh->info)); @@ -398,6 +399,10 @@ static uvc_error_t uvc_open_internal( "uvc: device has a status interrupt endpoint, but unable to read from it\n"); goto fail; } + /* libusb now owns the transfer, and _uvc_status_callback() re-arms it after + * every completion. uvc_close() MUST stop it before releasing the + * VideoControl interface it rides on -- see uvc_stop_status_xfer(). */ + internal_devh->status_xfer_submitted = 1; } if (dev->ctx->own_usb_ctx && dev->ctx->open_devices == NULL @@ -1827,14 +1832,114 @@ void uvc_free_devh(uvc_device_handle_t *devh) { if (devh->info) uvc_free_device_info(devh->info); - if (devh->status_xfer) + /* libusb forbids freeing a transfer it still owns. status_xfer_submitted is + * only still set on the quarantine path, which never reaches here. */ + if (devh->status_xfer && !devh->status_xfer_submitted) libusb_free_transfer(devh->status_xfer); + pthread_mutex_destroy(&devh->status_mutex); + free(devh); UVC_EXIT_VOID(); } +/** @internal + * @brief Stop the VideoControl status interrupt transfer and wait, bounded, for + * libusb to hand it back. + * + * The status endpoint lives on the VideoControl interface, and + * _uvc_status_callback() re-arms the URB after every completion -- including a + * timeout -- for the whole life of the handle. uvc_close() used to release that + * interface (and, via libusb_attach_kernel_driver(), hand it back to uvcvideo) + * with the URB still in flight, so the next resubmission reached usbfs on an + * interface the process no longer claimed. The kernel logs + * + * usbfs: process N (...) did not claim interface 0 before use + * + * and re-claims the interface for usbfs, evicting the driver that had just been + * reattached. The subsequent libusb_close() then drops that claim WITHOUT + * rebinding anything, leaving the interface with no driver at all: the camera's + * /dev/videoN never returns and only a USB unbind/bind recovers it. + * + * Only devices whose VideoControl interface carries the (optional) status + * interrupt endpoint were affected, which is why it reproduced on one camera and + * not another on the same board. + * + * @return 1 when the transfer is confirmed no longer submitted, 0 on timeout. + */ +static int uvc_stop_status_xfer(uvc_device_handle_t *devh) { + struct timespec poll; + int waited_ms; + int cancelled; + + if (!devh->status_xfer) + return 1; + + /* Taking the lock is what makes this safe: if the callback is mid-flight it + * either has not reached its stopping check (and will see the flag set here) + * or is already inside its submit (and this blocks until that submit is + * recorded, so the cancel below applies to it). Either way no submission can + * outlive this call. */ + pthread_mutex_lock(&devh->status_mutex); + devh->status_xfer_stopping = 1; + if (!devh->status_xfer_submitted) { + pthread_mutex_unlock(&devh->status_mutex); + return 1; + } + cancelled = libusb_cancel_transfer(devh->status_xfer) == LIBUSB_SUCCESS; + pthread_mutex_unlock(&devh->status_mutex); + + if (!cancelled) { + /* NOT_FOUND means libusb has already reaped it; the callback either ran + * under the lock above or will now observe status_xfer_stopping. */ + devh->status_xfer_submitted = 0; + return 1; + } + + poll.tv_sec = 0; + poll.tv_nsec = (long) LIBUVC_STATUS_STOP_POLL_MS * 1000000L; + for (waited_ms = 0; + devh->status_xfer_submitted && waited_ms < LIBUVC_STATUS_STOP_TIMEOUT_MS; + waited_ms += LIBUVC_STATUS_STOP_POLL_MS) { + nanosleep(&poll, NULL); + } + + return devh->status_xfer_submitted ? 0 : 1; +} + +/** @internal + * @brief Release every interface this handle still claims, VideoControl LAST. + * + * uvc_close() used to release only the VideoControl interface, so a streaming + * interface claimed by a negotiation that never reached uvc_stream_close() stayed + * claimed until libusb_close() dropped the usbfs fd -- and dropping the fd does + * NOT rebind a kernel driver, so the interface was left driverless. + * 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. + * + * The order is load-bearing. Reattaching the driver to the VideoControl + * interface is what makes uvcvideo probe the whole UVC function, and that probe + * claims the VideoStreaming interfaces itself. Releasing control first makes the + * probe run while the streaming interfaces are still held by usbfs, so it logs + * "No streaming interface found for terminal N" and registers no video node. + */ +static void uvc_release_claimed_ifs(uvc_device_handle_t *devh) { + const int ctrl_idx = devh->info ? (int) devh->info->ctrl_if.bInterfaceNumber : -1; + const int max_idx = (int) (8 * sizeof(devh->claimed)); + int idx; + + for (idx = 0; idx < max_idx; idx++) { + if (idx != ctrl_idx && (devh->claimed & (1u << idx))) + uvc_release_if(devh, idx); + } + + if (ctrl_idx >= 0) + uvc_release_if(devh, ctrl_idx); +} + /** @brief Close a device * * @ingroup device @@ -1850,6 +1955,12 @@ void uvc_close(uvc_device_handle_t *devh) { if (devh->streams) uvc_stop_streaming(devh); + /* The status interrupt transfer rides the VideoControl interface's endpoint and + * re-arms itself from _uvc_status_callback(), so it MUST be stopped before that + * interface is released below -- see uvc_stop_status_xfer(). */ + if (!uvc_stop_status_xfer(devh)) + devh->has_quarantined_status_xfer = 1; + /* devh-lifetime hotfix (round 2): if uvc_stop_streaming() -> uvc_stream_close() * had to quarantine any of this device's streams (a dead/unplugged device * whose cancelled transfers never completed within uvc_stream_stop()'s bounded @@ -1869,12 +1980,15 @@ void uvc_close(uvc_device_handle_t *devh) { * devh only via transfer->user_data -> strmh -> devh, never by walking * open_devices, so the unlink is safe and keeps uvc_exit()/a later uvc_close() * from re-visiting (double-freeing, or mis-detecting the "last device") this - * quarantined handle. */ - if (devh->has_quarantined_stream) { - UVC_DEBUG("device handle %p has a quarantined stream (stream stop timed out " - "with transfers still in flight); quarantining the device handle " + * quarantined handle. The same reasoning covers has_quarantined_status_xfer: + * a status transfer libusb still owns has user_data -> THIS handle, so freeing + * or closing here would be the same use-after-free one indirection shorter. */ + if (devh->has_quarantined_stream || devh->has_quarantined_status_xfer) { + UVC_DEBUG("device handle %p has a quarantined %s (bounded stop wait timed out " + "with a transfer still in flight); quarantining the device handle " "(intentional bounded leak) to avoid a use-after-free on a late " - "transfer completion that dereferences devh", (void *) devh); + "transfer completion that dereferences devh", (void *) devh, + devh->has_quarantined_stream ? "stream" : "status transfer"); DL_DELETE(ctx->open_devices, devh); /* context-lifetime hotfix (round 3): this quarantine path returns WITHOUT * killing/joining the event handler thread (doing so could hang on a wedged @@ -1888,7 +2002,7 @@ void uvc_close(uvc_device_handle_t *devh) { return; } - uvc_release_if(devh, devh->info->ctrl_if.bInterfaceNumber); + uvc_release_claimed_ifs(devh); /* If we are managing the libusb context and this is the last open device, * then we need to cancel the handler thread. When we call libusb_close, @@ -2087,12 +2201,14 @@ void LIBUSB_CALL _uvc_status_callback(struct libusb_transfer *transfer) { UVC_ENTER(); uvc_device_handle_t *devh = (uvc_device_handle_t *) transfer->user_data; + int ret; switch (transfer->status) { case LIBUSB_TRANSFER_ERROR: case LIBUSB_TRANSFER_CANCELLED: case LIBUSB_TRANSFER_NO_DEVICE: UVC_DEBUG("not processing/resubmitting, status = %d", transfer->status); + devh->status_xfer_submitted = 0; UVC_EXIT_VOID(); return; case LIBUSB_TRANSFER_COMPLETED: @@ -2105,11 +2221,28 @@ void LIBUSB_CALL _uvc_status_callback(struct libusb_transfer *transfer) { break; } -#ifdef UVC_DEBUGGING - uvc_error_t ret = -#endif - libusb_submit_transfer(transfer); + /* Teardown started: do NOT re-arm. Resubmitting here races uvc_close()'s + * uvc_release_if() on the VideoControl interface this endpoint belongs to, and + * a URB that lands after the release makes usbfs re-claim the interface out + * from under the kernel driver it was just handed back to. + * + * The check and the submit MUST be one critical section with + * uvc_stop_status_xfer(): testing the flag and then submitting outside the lock + * lets the stop slip in between, so the resubmission still lands after the + * release it was supposed to precede. */ + pthread_mutex_lock(&devh->status_mutex); + if (devh->status_xfer_stopping) { + devh->status_xfer_submitted = 0; + pthread_mutex_unlock(&devh->status_mutex); + UVC_EXIT_VOID(); + return; + } + + ret = libusb_submit_transfer(transfer); UVC_DEBUG("libusb_submit_transfer() = %d", ret); + if (ret != LIBUSB_SUCCESS) + devh->status_xfer_submitted = 0; + pthread_mutex_unlock(&devh->status_mutex); UVC_EXIT_VOID(); } diff --git a/tests/teardown_assertions.c b/tests/teardown_assertions.c new file mode 100644 index 0000000..d2b0eac --- /dev/null +++ b/tests/teardown_assertions.c @@ -0,0 +1,330 @@ +/* USB teardown ordering assertions for uvc_close(). + * + * The kernel-visible defects these lock down are invisible to a pure-logic test: + * both are about the ORDER of libusb calls a close emits, and about which calls + * are emitted at all. So every relevant libusb entry point is `--wrap`ped into an + * ordered operation log, the REAL uvc_close() is driven over a synthetic handle, + * and the log is asserted. + * + * The libusb event thread is modelled WITHOUT a thread: the wraps for the USB + * calls uvc_close() makes -- plus nanosleep(), which is where its bounded drain + * waits -- deliver one status-transfer callback each. That is exactly the + * interleaving a real event thread produces at those points, deterministically, + * and with no window in which a background thread could touch the handle after + * uvc_close() frees it. + * + * libusb_cancel_transfer() deliberately does NOT deliver: production calls it + * holding devh->status_mutex, and the callback takes the same (non-recursive) + * mutex. + */ +#include "libuvc/libuvc.h" +#include "libuvc/libuvc_internal.h" + +#include +#include +#include + +void LIBUSB_CALL _uvc_status_callback(struct libusb_transfer *transfer); + +#define CHECK(expression) do { \ + if (!(expression)) { \ + fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ + __FILE__, __LINE__, #expression); \ + dump_ops(); \ + return EXIT_FAILURE; \ + } \ +} while (0) + +typedef enum { + OP_SUBMIT, + OP_CANCEL, + OP_SETALT, + OP_RELEASE, + OP_ATTACH, + OP_CLOSE +} op_kind; + +typedef struct { + op_kind kind; + int iface; +} op_t; + +#define MAX_OPS 64 + +static op_t ops[MAX_OPS]; +static int op_count; +static int urb_in_flight; +static int cancel_requested; +static int deliver_callbacks; + +static const char *op_name(op_kind kind) { + switch (kind) { + case OP_SUBMIT: return "submit"; + case OP_CANCEL: return "cancel"; + case OP_SETALT: return "set_alt"; + case OP_RELEASE: return "release_if"; + case OP_ATTACH: return "attach_driver"; + case OP_CLOSE: return "close"; + } + return "?"; +} + +static void dump_ops(void) { + int i; + fprintf(stderr, " observed teardown sequence (%d ops):\n", op_count); + for (i = 0; i < op_count; i++) + fprintf(stderr, " %2d %-14s iface=%d\n", i, op_name(ops[i].kind), ops[i].iface); +} + +static void record(op_kind kind, int iface) { + if (op_count < MAX_OPS) { + ops[op_count].kind = kind; + ops[op_count].iface = iface; + } + op_count++; +} + +static int index_of_first(op_kind kind, int iface) { + int i; + for (i = 0; i < op_count && i < MAX_OPS; i++) + if (ops[i].kind == kind && ops[i].iface == iface) + return i; + return -1; +} + +static int index_of_last(op_kind kind) { + int i, found = -1; + for (i = 0; i < op_count && i < MAX_OPS; i++) + if (ops[i].kind == kind) + found = i; + return found; +} + +static int count_of(op_kind kind) { + int i, n = 0; + for (i = 0; i < op_count && i < MAX_OPS; i++) + if (ops[i].kind == kind) + n++; + return n; +} + +static struct libusb_transfer *status_xfer; + +/* Stand in for one libusb event-thread iteration: hand the URB back and run the + * real callback, which re-arms it unless uvc_close() told it to stop. */ +static void pump_event_thread(void) { + if (!deliver_callbacks || !urb_in_flight || status_xfer == NULL) + return; + urb_in_flight = 0; + status_xfer->status = cancel_requested ? LIBUSB_TRANSFER_CANCELLED + : LIBUSB_TRANSFER_TIMED_OUT; + _uvc_status_callback(status_xfer); +} + +int __wrap_libusb_submit_transfer(struct libusb_transfer *transfer) { + (void) transfer; + record(OP_SUBMIT, -1); + urb_in_flight = 1; + return LIBUSB_SUCCESS; +} + +int __wrap_libusb_cancel_transfer(struct libusb_transfer *transfer) { + (void) transfer; + record(OP_CANCEL, -1); + if (!urb_in_flight) + return LIBUSB_ERROR_NOT_FOUND; + cancel_requested = 1; + return LIBUSB_SUCCESS; +} + +int __wrap_nanosleep(const struct timespec *req, struct timespec *rem) { + (void) req; + (void) rem; + pump_event_thread(); + return 0; +} + +int __wrap_libusb_set_interface_alt_setting(libusb_device_handle *devh, + int interface_number, + int alternate_setting) { + (void) devh; + (void) alternate_setting; + record(OP_SETALT, interface_number); + pump_event_thread(); + return LIBUSB_SUCCESS; +} + +int __wrap_libusb_release_interface(libusb_device_handle *devh, + int interface_number) { + (void) devh; + record(OP_RELEASE, interface_number); + pump_event_thread(); + return LIBUSB_SUCCESS; +} + +int __wrap_libusb_attach_kernel_driver(libusb_device_handle *devh, + int interface_number) { + (void) devh; + record(OP_ATTACH, interface_number); + pump_event_thread(); + return LIBUSB_SUCCESS; +} + +void __wrap_libusb_close(libusb_device_handle *devh) { + (void) devh; + record(OP_CLOSE, -1); +} + +void __wrap_libusb_unref_device(libusb_device *dev) { + (void) dev; +} + +static uvc_context_t ctx; +static uvc_device_t dev; + +/* Build the handle uvc_open_internal() would have produced: `claimed` carries the + * interfaces libuvc holds, and a non-zero control endpoint means the status + * interrupt transfer was submitted and is self-re-arming. */ +static uvc_device_handle_t *make_handle(uint32_t claimed, uint8_t ctrl_endpoint) { + uvc_device_handle_t *devh = calloc(1, sizeof(*devh)); + uvc_device_info_t *info = calloc(1, sizeof(*info)); + + if (devh == NULL || info == NULL) { + free(devh); + free(info); + return NULL; + } + + memset(&ctx, 0, sizeof(ctx)); + memset(&dev, 0, sizeof(dev)); + dev.ctx = &ctx; + /* Two refs so uvc_unref_device() never free()s this static. */ + dev.ref = 2; + /* Not the context owner: uvc_close() then takes the branch that closes only + * this handle instead of killing and joining an event-handler thread. */ + ctx.own_usb_ctx = 0; + + info->ctrl_if.bInterfaceNumber = 0; + info->ctrl_if.bEndpointAddress = ctrl_endpoint; + + devh->dev = &dev; + devh->info = info; + devh->claimed = claimed; + + op_count = 0; + urb_in_flight = 0; + cancel_requested = 0; + deliver_callbacks = 1; + status_xfer = NULL; + + if (ctrl_endpoint) { + status_xfer = libusb_alloc_transfer(0); + if (status_xfer == NULL) { + free(info); + free(devh); + return NULL; + } + status_xfer->user_data = devh; + devh->status_xfer = status_xfer; + devh->status_xfer_submitted = 1; + urb_in_flight = 1; + } + + DL_APPEND(ctx.open_devices, devh); + return devh; +} + +/* A camera whose VideoControl interface carries a status interrupt endpoint. The + * URB must stop before the interface it rides on is released: a submission that + * lands after libusb_attach_kernel_driver() makes usbfs re-claim the interface, + * evicting the driver, and the following libusb_close() then leaves it bound to + * nothing at all. */ +static int check_status_xfer_stops_before_control_release(void) { + uvc_device_handle_t *devh = make_handle(1u << 0, 0x81); + int last_submit, release_ctrl; + + CHECK(devh != NULL); + uvc_close(devh); + + CHECK(count_of(OP_CANCEL) >= 1); + release_ctrl = index_of_first(OP_RELEASE, 0); + CHECK(release_ctrl >= 0); + last_submit = index_of_last(OP_SUBMIT); + CHECK(last_submit < release_ctrl); + CHECK(index_of_first(OP_ATTACH, 0) > release_ctrl); + CHECK(index_of_last(OP_CLOSE) > index_of_first(OP_ATTACH, 0)); + CHECK(index_of_last(OP_SUBMIT) < index_of_last(OP_CLOSE)); + return EXIT_SUCCESS; +} + +/* A negotiation that claimed the streaming interface but never reached + * uvc_stream_close() must still have it released, BEFORE the control interface: + * reattaching the driver to control is what makes uvcvideo probe the function, + * and that probe claims the streaming interfaces itself. */ +static int check_every_claimed_interface_is_released_control_last(void) { + uvc_device_handle_t *devh = make_handle((1u << 0) | (1u << 1), 0); + int release_stream, release_ctrl; + + CHECK(devh != NULL); + uvc_close(devh); + + release_stream = index_of_first(OP_RELEASE, 1); + release_ctrl = index_of_first(OP_RELEASE, 0); + CHECK(release_stream >= 0); + CHECK(release_ctrl >= 0); + CHECK(release_stream < release_ctrl); + CHECK(index_of_first(OP_ATTACH, 1) >= 0); + CHECK(index_of_first(OP_ATTACH, 0) > release_ctrl); + CHECK(count_of(OP_RELEASE) == 2); + return EXIT_SUCCESS; +} + +/* Negative control: a VideoControl interface with no status endpoint (the RØDE + * HDMI-to-USB-C shape) never submitted a URB, so nothing about its teardown may + * change. Passes before and after the fix. */ +static int check_handle_without_status_endpoint_is_unchanged(void) { + uvc_device_handle_t *devh = make_handle(1u << 0, 0); + + CHECK(devh != NULL); + uvc_close(devh); + + CHECK(count_of(OP_SUBMIT) == 0); + CHECK(count_of(OP_CANCEL) == 0); + CHECK(count_of(OP_RELEASE) == 1); + CHECK(index_of_first(OP_RELEASE, 0) >= 0); + CHECK(index_of_first(OP_ATTACH, 0) > index_of_first(OP_RELEASE, 0)); + CHECK(index_of_last(OP_CLOSE) == op_count - 1); + return EXIT_SUCCESS; +} + +/* A wedged event thread must not hang the close, and must not let it free a + * handle libusb still references through the transfer's user_data. */ +static int check_undeliverable_status_xfer_quarantines_the_handle(void) { + uvc_device_handle_t *devh = make_handle(1u << 0, 0x81); + + CHECK(devh != NULL); + deliver_callbacks = 0; + uvc_close(devh); + + CHECK(count_of(OP_CANCEL) == 1); + CHECK(count_of(OP_RELEASE) == 0); + CHECK(count_of(OP_CLOSE) == 0); + CHECK(devh->has_quarantined_status_xfer == 1); + CHECK(ctx.has_quarantined_device == 1); + CHECK(ctx.open_devices == NULL); + return EXIT_SUCCESS; +} + +int main(int argc, char **argv) { + CHECK(argc == 3 && strcmp(argv[1], "--case") == 0); + if (strcmp(argv[2], "status_xfer_stops_before_control_release") == 0) + return check_status_xfer_stops_before_control_release(); + if (strcmp(argv[2], "every_claimed_interface_released_control_last") == 0) + return check_every_claimed_interface_is_released_control_last(); + if (strcmp(argv[2], "no_status_endpoint_unchanged") == 0) + return check_handle_without_status_endpoint_is_unchanged(); + if (strcmp(argv[2], "undeliverable_status_xfer_quarantines") == 0) + return check_undeliverable_status_xfer_quarantines_the_handle(); + fprintf(stderr, "unknown case: %s\n", argv[2]); + return EXIT_FAILURE; +}