Skip to content

macOS: capture RealSense UVC interfaces from UVCAssistant for reliable (multi-)camera streaming#15243

Open
connorsoohoo wants to merge 3 commits into
realsenseai:developmentfrom
connorsoohoo:fix-macos-multicam-uvc-capture
Open

macOS: capture RealSense UVC interfaces from UVCAssistant for reliable (multi-)camera streaming#15243
connorsoohoo wants to merge 3 commits into
realsenseai:developmentfrom
connorsoohoo:fix-macos-multicam-uvc-capture

Conversation

@connorsoohoo

Copy link
Copy Markdown

TL;DR: On macOS, RealSense cameras intermittently fail to stream (RS2_USB_STATUS_ACCESS, "failed to set power state", or zero frames) — most visibly with multiple cameras — because the system's userspace UVCAssistant holds the UVC streaming interfaces. This makes the libusb backend capture the device up front and adds a codesigning step so the capture entitlement can authorize it. macOS-only; other platforms are unchanged.

Summary

  • src/libusb/handle-libusb.h (macOS): explicitly libusb_detach_kernel_driver() before claiming so libusb runs capture-mode re-enumeration up front instead of the lazy auto-detach that races the OS. Made the handle constructor exception-safe — release/close on a failed claim — so a failed attempt no longer leaks an open handle that keeps the device busy. Inner claim retry reverted to BUSY-only (tight-looping on ACCESS just re-triggered device resets).
  • src/libusb/device-libusb.cpp (macOS, #ifdef __APPLE__): retry the whole open/claim with backoff (300–1200 ms) so a contended bring-up self-heals; other platforms keep the original single-attempt behavior.
  • CMake/macos-device-access.entitlements, CMake/macos_codesign.cmake, CMakeLists.txt: ad-hoc codesign every built executable with com.apple.vm.device-access (the entitlement that lets libusb take exclusive ownership of the device). No-op off macOS.

Why

macOS auto-claims every UVC camera with UVCAssistant, a userspace CoreMediaIO system extension that holds the RealSense streaming interfaces exclusively even with no app open (visible in IORegistry as UsbExclusiveOwner = pid …, UVCAssistant). The libusb backend then loses libusb_claim_interface with ACCESS. libusb's auto-detach only evicts kernel drivers, so it races the userspace assistant — failures are intermittent and the failing interface alternates run to run. It is worst with multiple cameras (rs-multicam), where bring-up contends across devices, and a killed run could wedge a device until physically replugged.

Apple Silicon nuance

The deterministic fix is the com.apple.vm.device-access capture entitlement (the mechanism VMs use for USB passthrough): it routes libusb through IOServiceAuthorize and takes the device from UVCAssistant without racing. It is a restricted entitlement — on Apple Silicon with SIP enabled, AMFI ignores a self-signed binary that carries it. The operator must csrutil disable once from Recovery (optionally add the amfi_get_out_of_my_way=1 boot-arg). The binary still signs fine with SIP on; the entitlement simply has no effect until SIP is relaxed. Disabling/removing UVCAssistant itself is not viable — it lives on the sealed, read-only system volume. Without the entitlement (plain sudo), the in-process detach + retry improve reliability but cannot guarantee winning the race.

Test plan

  • macOS (Apple Silicon, SIP disabled): build; confirm the entitlement is embedded (codesign -d --entitlements - build/Release/rs-multicam); run rs-multicam with 2× D400 — both stream; ioreg -p IOUSB -r -n "Intel(R) RealSense(TM) Depth Camera ..." -l | grep UsbExclusiveOwner shows the app, not UVCAssistant.
  • macOS: repeated start/kill/restart of rs-capture no longer requires a physical replug.
  • Linux (RSUSB backend): rs-enumerate-devices / rs-capture behavior unchanged (retry path is #ifdef __APPLE__).
  • Non-macOS builds unaffected — codesign step and explicit detach are no-ops / compiled out.

@sysrsbuild-gh

Copy link
Copy Markdown

Can one of the admins verify this patch?

@sysrsbuild-gh-agentic sysrsbuild-gh-agentic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review — rs-agentic bot

Overall the PR is well-motivated and the problem description is thorough. The core ideas (explicit libusb_detach_kernel_driver before claiming, exception-safe constructor cleanup, outer open/claim retry with backoff) are sound. Several correctness and consistency issues noted below; see inline comments for details.

Missing automated tests: The test plan is entirely manual. No automated tests are provided for:

  • The macOS-specific retry/backoff logic in usb_device_libusb::get_handle
  • The exception-safety of the handle_libusb constructor (cleanup on partial-claim failure)
  • The CMake codesign step (at minimum a macOS CI build job should be added)

The project's test framework lives in unit-tests/; please add at least a build-level CI job for macOS that verifies the codesign step runs without error (codesign -v -d --entitlements - on a produced binary).

_first_interface(interface), _context(context), _handle(nullptr)
{
auto sts = libusb_open(device, &_handle);
if (sts == LIBUSB_ERROR_BUSY || sts == LIBUSB_ERROR_ACCESS)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 BUG [rs-agentic bot] — libusb_open retries on ACCESS contradict the PR's own rationale

The PR description explicitly states: "Inner claim retry reverted to BUSY-only (tight-looping on ACCESS just re-triggered device resets)." Yet this loop retries libusb_open on both LIBUSB_ERROR_BUSY and LIBUSB_ERROR_ACCESS (up to 20 × 50 ms = 1 s). On macOS, every libusb_open attempt on a device held by UVCAssistant triggers a USBDeviceReEnumerate call internally, so hammering it on ACCESS causes exactly the same perpetual-reset problem described for the claim retry. This loop should be restricted to LIBUSB_ERROR_BUSY only, consistent with the reasoning applied to claim_interface_or_throw.

// Suggested fix: restrict to BUSY only
if (sts == LIBUSB_ERROR_BUSY)
{
    auto retry_counter = 20;
    do {
        ...
    } while (sts == LIBUSB_ERROR_BUSY && --retry_counter > 0);
}

// with the com.apple.vm.device-access capture entitlement, or disable the camera
// assistant entirely. This detach maximizes our chances; it is not a guarantee.
{
auto detach_sts = libusb_detach_kernel_driver(_handle, interface->get_number());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ ENHANCEMENT [rs-agentic bot] — libusb_detach_kernel_driver only called for the primary interface

libusb_detach_kernel_driver is invoked only for interface->get_number() (the VideoControl interface, typically interface 0). However, UVCAssistant holds the VideoStreaming interfaces (interfaces 1, 2, …) exclusively — those are the ones that actually appear as UsbExclusiveOwner in IORegistry. Triggering capture-mode re-enumeration on interface 0 alone does not guarantee that the OS releases all streaming interfaces fast enough before the claim below.

Consider also calling detach for each associated interface:

for (auto&& assoc : interface->get_associated_interfaces())
{
    auto d = libusb_detach_kernel_driver(_handle, assoc->get_number());
    if (d != LIBUSB_SUCCESS &&
        d != LIBUSB_ERROR_NOT_SUPPORTED &&
        d != LIBUSB_ERROR_NOT_FOUND)
        LOG_WARNING("failed to capture associated interface " << (int)assoc->get_number() << ": " << d);
}

// will NOT run. Release whatever we managed to claim and close the device
// here; otherwise the leaked open handle keeps the device busy and defeats
// the higher-level open retry in usb_device_libusb::get_handle.
for(auto&& i : interface->get_associated_interfaces())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 BUG [rs-agentic bot] — catch block unconditionally releases ALL associated interfaces, including ones never claimed

If claim_interface_or_throw succeeds for the primary interface but then fails midway through the associated-interface loop (e.g., the 2nd of 4 associated interfaces throws), the catch block still calls libusb_release_interface on all associated interfaces — including those that were never claimed. While libusb_release_interface on an unclaimed interface is benign on most platforms, on macOS it can produce spurious LIBUSB_ERROR_NOT_FOUND error-level log entries that are confusing and could mask real failures.

A more precise cleanup would track which interfaces were successfully claimed:

std::vector<uint8_t> claimed_so_far;
try {
    claim_interface_or_throw(interface->get_number());
    claimed_so_far.push_back(interface->get_number());
    for (auto&& i : interface->get_associated_interfaces()) {
        claim_interface_or_throw(i->get_number());
        claimed_so_far.push_back(i->get_number());
    }
} catch (...) {
    for (auto n : claimed_so_far)
        libusb_release_interface(_handle, n);
    libusb_close(_handle);
    _handle = nullptr;
    throw;
}

<< " after " << max_attempts << " attempts: " << e.what());
return nullptr;
}
const int delay_ms = 300 * attempt; // 300, 600, 900, 1200 ms backoff

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ ENHANCEMENT [rs-agentic bot] — Magic numbers should be named constants

300 * attempt and max_attempts = 5 are raw magic numbers. These should be constexpr named constants to make the intent clear and the values easy to tune:

constexpr int k_macos_open_retry_max       = 5;
constexpr int k_macos_open_retry_base_ms   = 300;  // 300, 600, 900, 1200 ms

Also note: worst-case total blocking time is 300+600+900+1200 = 3 seconds of synchronous sleep in get_handle. If this is ever called from a UI or time-critical thread, it will cause a visible hang. Worth documenting or adding a LOG_WARNING at the call site.

}
catch(const std::exception& e)
{
return nullptr;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 BUG [rs-agentic bot] — Non-Apple path silently swallows exceptions; e is unused

The #else catch block catches std::exception& e but never logs or uses e — it just returns nullptr. This is a pre-existing issue, but this PR restructured the code around it and could have addressed it. Silent failures here make post-crash debugging very difficult: the caller sees nullptr with no indication of why the handle could not be opened.

At minimum, log the exception:

catch(const std::exception& e)
{
    LOG_ERROR("failed to open usb handle for interface " << (int)interface_number << ": " << e.what());
    return nullptr;
}

@@ -0,0 +1,83 @@
# License: Apache 2.0. See LICENSE file in root directory.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ GUIDELINES VIOLATION [rs-agentic bot] — File name uses snake_case instead of kebab-case

Per the project's naming conventions (.github/copilot-instructions.md):

Files: kebab-case (e.g., backend-v4l2.h, device-model.cpp)

This file is named macos_codesign.cmake (snake_case). It should be renamed to macos-codesign.cmake to be consistent with the project conventions. The include(CMake/macos_codesign.cmake) reference in CMakeLists.txt would need to be updated accordingly.

COMMAND codesign --force --sign - --entitlements "${LRS_MACOS_DEVICE_ACCESS_ENTITLEMENTS}" --timestamp=none "$<TARGET_FILE:${_exe}>")
endforeach()

add_custom_target(lrs-codesign-all ALL ${_cmds}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 BUG [rs-agentic bot] — lrs-codesign-all aggregate target does NOT sign binaries on incremental/partial builds

The comment in the function correctly acknowledges this, but it is worth raising as a blocking concern: if a developer rebuilds only one executable (e.g., cmake --build . --target rs-capture), the lrs-codesign-all target is not triggered, and the resulting binary is unsigned. Running it will silently get no capture-entitlement effect on Apple Silicon (SIP disabled), making multi-camera failures appear to be a code regression rather than a missing code-signature.

The lrs_codesign_device_access(target) per-target function is already defined in this same file and attaches a POST_BUILD hook that fires whenever the specific target is rebuilt. Consider using it directly in the relevant CMakeLists.txt files (examples, tools) for each executable target, or at least document the risk prominently for developers:

# In each subdirectory that defines an executable:
lrs_codesign_device_access(rs-multicam)
lrs_codesign_device_access(rs-capture)
# etc.

@Nir-Az
Nir-Az requested a review from ashrafk93 July 9, 2026 14:14
@Nir-Az

Nir-Az commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Hi @connorsoohoo, thanks for the contribution! Please make sure to follow our CONTRIBUTING.md guidelines. This PR already targets development, which is great — please also ensure your changes are based on the latest development branch. Thanks!

@ashrafk93

Copy link
Copy Markdown
Collaborator

@connorsoohoo Thanks for tackling the macOS UVCAssistant contention this is a real, long-standing pain point and the diagnosis in the code comments is excellent. I tested the branch on a full build and hit a blocking issue with the codesigning approach.

Environment: macOS 15.6, Apple Silicon (M-series), SIP enabled, no custom boot-args i.e. a stock, default-secured Mac.

Blocker: every built tool is SIGKILLed at launch. After a full build, lrs-codesign-all stamps all executables with the restricted com.apple.vm.device-access entitlement over an ad-hoc signature. AMFI rejects that combination and kills the process at execve, before any of our code runs:

sudo ./rs-enumerate-devices  →  [1] killed

kernel: AMFI: bailing out because of restricted entitlements.
kernel: Code has restricted entitlements, but the validation of its code signature failed.
kernel: AppleSystemPolicy: Security policy would not allow process
Re-signing the exact same binary without the entitlement (codesign -f -s - <bin>) makes it launch and enumerate/stream a D455 normally  so the entitlement is the sole cause.

Smaller notes:

lrs_codesign_device_access() is defined but never called dead code.
lrs-codesign-all signs executables only, not the .dylibs (harmless for AMFI, but asymmetric).

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.

5 participants