Skip to content

fix(device): synchronize the status-transfer flags and stop trusting cancel's NOT_FOUND - #8

Merged
andrescera merged 1 commit into
mainfrom
fix/status-xfer-flag-race
Jul 28, 2026
Merged

fix(device): synchronize the status-transfer flags and stop trusting cancel's NOT_FOUND#8
andrescera merged 1 commit into
mainfrom
fix/status-xfer-flag-race

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

Two fixes in the uvc_close() teardown path that PR #7 introduced, plus the test
coverage that proves both. PR #7's actual logic — VideoStreaming interfaces released
before VideoControl, status transfer stopped before its interface is released — is
correct and generic and is not restructured here.

  1. status_xfer_submitted is now genuinely thread-safe. It is written by
    _uvc_status_callback() on the libusb event thread and read by
    uvc_stop_status_xfer() on the closing thread. status_mutex was added for exactly
    that, and its own comment claims "Both flags below are read and written under it"
    but three accesses sat outside it: the callback's terminal-status clear
    (device.c:2211), the closing thread's poll loop, and uvc_free_devh()'s check.
    Every access now goes through the mutex, and both flags drop the misleading
    volatile.

  2. A cancel reporting LIBUSB_ERROR_NOT_FOUND is no longer treated as drained.

Why

On (1)volatile in C supplies neither atomicity nor any happens-before edge; it
is not among the things that establish one (only _Atomic and mutexes are), and the
concurrent accesses are a data race outright. That matters on the shipping target:
RK3588 is aarch64, weakly ordered. The closing thread can observe the flag clear while
the callback's earlier stores are still invisible, then free the transfer and the
handle while the callback is still inside both — reintroducing the same use-after-free
class the has_quarantined_* machinery exists to prevent, one indirection lower.

There was also an ordering bug: uvc_open_internal() set the flag after
libusb_submit_transfer(), so a callback that completed in between had its clear
overwritten with a stale 1. It is now set before the submit, under the mutex.

On (2) — libusb documents NOT_FOUND as "not in progress, already complete, or
already cancelled
"
, and in that last case the completion callback has not run yet.
It also documents that freeing a transfer whose cancellation is still pending is
undefined behaviour. The old code returned success immediately on NOT_FOUND, so the
close skipped its drain, released the interface, freed that transfer, and freed the
devh the pending callback dereferences. PR #7's comment is right that the callback
will not resubmit — but it still dereferences devh, which is the lifetime
question, not the resubmission question. Fixed by deleting the shortcut: the callback
is now the only thing that clears the flag, and the existing bounded wait (500 ms) plus
quarantine is the only exit, so a callback that never arrives degrades to the safe,
already-designed intentional leak. Net less code.

Why a mutex and not _Atomic

pthread.h is already an unconditional include of libuvc_internal.h, so this adds
zero new portability surface — <stdatomic.h> would, since the library still builds for
macOS/Windows (MSVC only got it in VS 17.5) and the project sets no C_STANDARD at all.
An unlock/lock pair is also a full release/acquire, so it orders everything the
callback touched before the free, not just the flag. And the mutex already existed with
this contract written on it — the fix makes the code match its documentation rather than
adding a second parallel mechanism. Cost is nil: the status endpoint fires at bInterval
(8–32 ms) and the stop path runs once per close.

Why it cannot deadlock

  • Lock order is uniform and one-way: status_mutex first, libusb entry points under it,
    never the reverse. Callback: status_mutexlibusb_submit_transfer. Stop:
    status_mutexlibusb_cancel_transfer. libusb cannot take status_mutex.
  • libusb invokes transfer callbacks from its event-handling thread with no internal
    transfer lock held
    , and documents cancellation as asynchronous — so there is no
    synchronous callback from inside libusb_cancel_transfer() to self-deadlock a
    non-recursive mutex, and no path back into status_mutex from inside libusb. Taking a
    mutex in the callback is the documented requirement ("your callback functions MUST be
    thread-safe"), and PR fix(device): release every claimed interface and stop the status transfer on close #7 already did it on the resubmit path.
  • The bounded drain must not hold the mutex across its nanosleep — that would block
    the very callback it waits for. It locks per iteration; this is commented in-code so a
    future "simplification" cannot reintroduce it.
  • The newly-locked terminal branch makes no libusb call while holding the mutex, so it
    adds no new edge to the lock graph.

has_quarantined_status_xfer / has_quarantined_stream were audited and are
closing-thread-only. status_xfer_stopping was already correctly locked on both sides.

How to verify

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 --output-on-failure          # 27/27

cmake -S . -B build/tsan  <same flags>  -DLIBUVC_SANITIZE=thread
cmake --build build/tsan --parallel
ctest --test-dir build/tsan --output-on-failure -R 'libuvc\.(teardown|race)\.'

Both directions were captured. Stashing src/device.c + libuvc_internal.h back to
f3eda76 while keeping the new tests, under TSan: 39 ThreadSanitizer warnings and 2
failing cases —

  • data race at device.c:2211/2234/2235 (_uvc_status_callback) and
    1837/1838/1840/1842 (uvc_free_devh)
  • heap-use-after-free at device.c:2233/2234/2236 in _uvc_status_callback
  • destroy of a locked mutex at device.c:1840 in uvc_free_devh — the close
    destroying status_mutex and freeing devh while the callback is inside it

libuvc.teardown.cancel_not_found_still_drains also fails with no sanitizer at all.
With the fix restored: 27/27 green, 0 TSan reports. Both shared-library CI variants
(auto-detach ON and OFF) build clean, and gcc -Wall -Wextra on src/device.c reports
the same 16 pre-existing -Wunused-parameter warnings before and after — no new ones.

New tests

  • libuvc.race.close_races_status_callback — drives the real uvc_close() against a
    real libusb event thread over 200 iterations. It asserts no submission lands at or
    after the first interface release, and that the close never had to quarantine (which is
    what proves the drain still completes — holding the mutex across its sleep would turn
    every close into a timeout). The harness mirrors production's lock order exactly so it
    cannot invent an inversion the real code does not have.
  • libuvc.teardown.cancel_not_found_still_drains — a NOT_FOUND cancel with the
    completion still pending must land the callback before the first USB operation of
    the teardown, not somewhere in the middle of it.
  • libuvc.teardown.sparse_interfaces_control_released_last (VC=3, VS={1,5,7}, with a
    status endpoint) and libuvc.teardown.high_index_interfaces_released (VC=2,
    VS={9,17,24}, none) — closes the coverage gap where every case used VideoControl at
    interface 0 with at most one VideoStreaming interface, a shape a loop that merely
    special-cased index 0 would also satisfy. Synthetic indices through one parameterized
    helper, deliberately not any real device's descriptor layout. No production change
    was needed
    — PR fix(device): release every claimed interface and stop the status transfer on close #7's release logic was already generic, which is now proven.

No existing test was changed, skipped, or weakened.

Risks

  • Behaviour change on the NOT_FOUND path. A close that previously returned
    immediately now waits for the completion callback. In the common case (NOT_FOUND
    because the callback already ran) the flag is already clear and the drain exits with
    zero added latency. The worst case is the existing 500 ms
    LIBUVC_STATUS_STOP_TIMEOUT_MS bound followed by the existing quarantine — the
    already-designed safe outcome, not a new failure mode.
  • volatile removal is deliberate, not cosmetic. It only remains correct because
    every access is now under the mutex; adding an unlocked read back would be a silent
    regression. The header documents this and the race case would catch it under TSan.
  • New CI job. thread-sanitizer is scoped with -R 'libuvc\.(teardown|race)\.' on
    purpose: the descriptor/negotiation/transfer suites --wrap free(), which a
    sanitized build replaces, so including them would report a mismatched allocator rather
    than anything about this code. LIBUVC_SANITIZE defaults to off; normal builds are
    unaffected.
  • Not yet run on hardware. The RK3588 + DJI Osmo Pocket 3 repro that motivated PR fix(device): release every claimed interface and stop the status transfer on close #7
    should still behave identically — the observable teardown sequence is unchanged on
    every path that was already correct.

…cancel's NOT_FOUND

PR #7's teardown fix is correct in shape but was not actually synchronized. Two
defects, both in the uvc_close() path it added.

status_xfer_submitted is written by _uvc_status_callback() on the libusb event
thread and read by uvc_stop_status_xfer() on the closing thread. status_mutex was
added for exactly that, and its own comment claims both flags are read and written
under it, but three accesses sat outside it: the callback's terminal-status clear,
the closing thread's poll loop, and uvc_free_devh()'s check. volatile supplies
neither atomicity nor a happens-before edge, so on the weakly-ordered aarch64 this
fork ships on, the close can observe the flag clear while the callback's earlier
stores are still invisible and free both the transfer and the handle out from under
it. Every access now goes through status_mutex and both flags lose the misleading
volatile. The bounded drain takes and drops the mutex per iteration rather than
spanning its sleep, which would block the callback it waits for. The flag is also
set before libusb_submit_transfer() rather than after, so a callback completing
between the two statements can no longer have its clear overwritten with a stale 1.

Separately, a cancel returning LIBUSB_ERROR_NOT_FOUND was treated as drained.
libusb documents that code as "not in progress, already complete, or already
cancelled", and in the last case the callback has not run yet - so the close
released the interface, freed a transfer whose cancellation was still pending
(undefined behaviour by libusb's own contract) and freed the devh that the pending
callback dereferences. The callback is now the only thing that clears the flag, and
the existing bounded wait plus quarantine is the only exit.

No deadlock is introduced: the lock order is uniform and one-way, status_mutex
first and libusb entry points under it, and libusb invokes callbacks from its event
thread with no internal transfer lock held and documents cancellation as
asynchronous, so nothing re-enters status_mutex from inside libusb.

A new libuvc.race case drives the real uvc_close() against a real event thread over
repeated iterations, and LIBUVC_SANITIZE plus a CI job fail on any ThreadSanitizer
report. On the pre-fix code TSan reports data races in _uvc_status_callback() and
uvc_free_devh(), a heap-use-after-free in _uvc_status_callback(), and a
destroy-of-a-locked-mutex in uvc_free_devh().

Also closes a coverage gap: every teardown case used VideoControl at interface 0
with at most one VideoStreaming interface, which a loop that merely special-cased
index 0 would satisfy. Two cases now drive a nonzero VideoControl index with three
scattered VideoStreaming interfaces. No production change was needed for them.
@andrescera
andrescera merged commit 4a18081 into main Jul 28, 2026
3 checks passed
@andrescera
andrescera deleted the fix/status-xfer-flag-race branch July 28, 2026 04:35
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