Repository navigation
[bug fix] driver_vive: fix double-cleanup reentrancy bug in handle_transfer disconnect - #376
Closed
rocketmark wants to merge 1 commit into
Closed
rocketmark wants to merge 1 commit into
rocketmark wants to merge 1 commit into
Conversation
…connect path survive_close_usb_device() cancels every interface's in-flight transfer, including the one whose own handle_transfer() is still on the call stack -- it doesn't skip the interface that originated the disconnect (reachable via the consecutive-timeout/RF-device path, or via repeated transfer errors). If libusb's cancellation of that interface's own transfer invokes its completion callback synchronously, handle_transfer() reenters itself: the shutdown cleanup (active_transfers--, libusb_release_interface) runs once in the reentrant inner call, then runs again when the outer frame resumes. active_transfers underflows (0 - 1 wraps to SIZE_MAX on this unsigned counter) instead of landing on 0, and libusb_release_interface is called twice for the same interface. Guard the shutdown cleanup with a check for iface->transfer == 0 (set by the first pass, before it's freed) so a reentrant second pass for an already-cleaned-up interface is a no-op instead of corrupting active_transfers and double-releasing the interface. Includes a regression test (driver_vive_reentrancy_test.c) that drives the real handle_transfer/AttachInterface code against a fake libusb (mock_libusb.c) whose libusb_cancel_transfer synchronously re-invokes the transfer's own callback -- the worst-case reentrant ordering a real libusb backend could produce -- reproducing the bug deterministically without hardware. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
Author
|
Closing in favor of #372, which fixes this same reentrancy (survive_close_usb_device() canceling the interface whose own callback is still executing) more thoroughly — it defers survive_close_usb_device() entirely out of the libusb callback via the poll loop, which also covers survive_config_cancel()'s reentrant call that this PR didn't address. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
survive_close_usb_device() cancels every interface's in-flight transfer, including the one whose own handle_transfer() is still on the call stack — it doesn't skip the interface that originated the disconnect. If libusb's cancellation of that interface's own transfer invokes its completion callback synchronously, handle_transfer() reenters itself, and the shutdown cleanup runs twice for one interface.
Running that cleanup twice corrupts shared state instead of crashing cleanly, which is worse: the bug can sit silent until the device's transfer accounting is already wrong.
Demonstration
Trigger: a USB transfer error path (repeated STALLs, or 3+ consecutive timeouts on an RF device) calls survive_disconnect_device() → survive_close_usb_device(), which cancels every interface's transfer — including its own. If libusb's cancellation of that self-transfer invokes the callback synchronously, handle_transfer() reenters itself mid-cleanup:
BEFORE fix:
active_transfers = 2 (two interfaces: IMU, Buttons)
Buttons' error path calls survive_disconnect_device(Buttons)
cancel loop reaches IMU's transfer: reentrant cleanup, active_transfers-- → 1
cancel loop reaches Buttons' own transfer: reentrant cleanup (inner pass)
active_transfers-- → 0
libusb_release_interface() called (1st time) for Buttons
iface->transfer = 0
outer pass resumes, redoes the same cleanup for Buttons:
libusb_release_interface() called (2nd time) for the same interface
active_transfers-- → underflows: 18446744073709551615 (SIZE_MAX)
Result: corrupted transfer count, Buttons' USB interface released twice
AFTER fix:
outer pass resumes, iface->transfer == 0 (already cleaned up by the inner pass)
guard returns immediately, no redundant work
Result: active_transfers == 0, libusb_release_interface() called exactly once
Impact
Any tracked device with multiple USB interfaces (IMU, Lightcap, Buttons — the normal case) is exposed whenever this disconnect path runs. It's reachable via the existing consecutive-timeout/RF-device path or via repeated transfer errors — not an exotic trigger, just a timing-dependent one (it depends on whether libusb's cancellation callback fires synchronously or is deferred). When it fires, active_transfers is corrupted for the rest of that device's lifetime and a USB interface claim is released twice.
Change
One file, src/driver_vive.libusb.h. The shared shutdown: cleanup block in handle_transfer() is guarded with a check for iface->transfer == 0 — set by the first pass through this cleanup, before the transfer is freed. A reentrant second pass for an already-cleaned-up interface now returns immediately instead of redoing the decrement and release. Includes a regression test (driver_vive_reentrancy_test.c) that drives the real, unmodified handle_transfer/AttachInterface code against a fake libusb (mock_libusb.c) whose libusb_cancel_transfer synchronously re-invokes the target transfer's callback, reproducing the bug deterministically without hardware.
Found via
Found while auditing handle_transfer()'s disconnect path, prompted by reviewing the call sites that reach survive_disconnect_device() from inside this function. Built a deterministic repro to check whether that path was reentrancy-safe; it wasn't — reproduced with the exact numbers above, included here as a regression test.