Skip to content
Merged
58 changes: 58 additions & 0 deletions src/ccap_apple_async.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @file ccap_apple_async.h
* @author wysaid (this@wysaid.org)
* @brief Run a callback-based asynchronous request and block until it completes,
* without bouncing the request onto the main dispatch queue.
*
* macOS callback APIs such as `AVCaptureDevice requestAccessForMediaType:` deliver
* their completion on an internal queue, not on the caller's run loop. ccap used to
* dispatch the permission request onto the main queue for non-main-thread callers,
* which deadlocks whenever nothing is servicing that queue -- e.g. a ccap::Provider
* opened from a worker thread in a process that has no CFRunLoop on its main thread
* (a Node.js / Electron addon, a head-less multi-threaded service, ...).
*
* runBlockingAsyncRequest() starts the request on the *calling* thread and blocks on a
* portable condition variable until the supplied continuation is invoked, so it is
* safe to call from any thread regardless of run-loop state.
*
* Covered by tests/test_apple_permission.cpp.
*/

#pragma once

#if defined(__APPLE__)

#include <condition_variable>
#include <functional>
#include <mutex>

namespace ccap
{

/**
* Invoke @p start on the current thread and block until the continuation that
* @p start receives (its `done` argument) is called. @p start may invoke `done` from
* any thread or queue. The request is never dispatched to the main queue, so this
* cannot deadlock when no run loop is servicing it.
*/
inline void runBlockingAsyncRequest(const std::function<void(const std::function<void()>& done)>& start)
{
std::mutex mutex;
std::condition_variable cv;
bool finished = false;

start([&mutex, &cv, &finished]() {
{
std::lock_guard<std::mutex> lock(mutex);
finished = true;
}
cv.notify_one();
});

std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&finished]() { return finished; });
}

} // namespace ccap

#endif // __APPLE__
33 changes: 17 additions & 16 deletions src/ccap_imp_apple.mm
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "ccap_imp_apple.h"
#include "ccap_file_reader_apple.h"

#include "ccap_apple_async.h"
#include "ccap_convert.h"
#include "ccap_convert_frame.h"

Expand All @@ -19,6 +20,7 @@
#import <Foundation/Foundation.h>
#include <cassert>
#include <cmath>
#include <functional>

#if _CCAP_LOG_ENABLED_
#include <deque>
Expand Down Expand Up @@ -255,23 +257,22 @@ - (instancetype)initWithProvider:(ProviderApple*)provider {
- (BOOL)open {
AVAuthorizationStatus authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (authStatus == AVAuthorizationStatusNotDetermined) {
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
void (^requestAccess)(void) = ^(void) {
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
CCAP_NSLOG_I(@"ccap: Camera access %@", granted ? @"granted" : @"denied");
dispatch_semaphore_signal(sema);
}];
};

// Permission must be requested on the main thread
if (![NSThread isMainThread]) {
dispatch_async(dispatch_get_main_queue(), ^{ requestAccess(); });
} else {
requestAccess();
}

CCAP_NSLOG_I(@"ccap: Waiting for camera access permission...");
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
// Request authorization on the calling thread and block until the system's
// completion handler fires. We deliberately do NOT dispatch the request onto the
// main queue: requestAccessForMediaType: may be called from any thread and
// delivers its completion on an internal queue, so bouncing to the main queue
// would deadlock whenever no run loop is servicing it (e.g. a ccap::Provider
// opened from a worker thread in a process without a CFRunLoop). See
// tests/test_apple_permission.cpp.
ccap::runBlockingAsyncRequest([](const std::function<void()>& done) {
std::function<void()> notifyDone = done; // outlive the async completion
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:^(BOOL granted) {
CCAP_NSLOG_I(@"ccap: Camera access %@", granted ? @"granted" : @"denied");
notifyDone();
}];
});
authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
}

Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ add_executable(
test_frame_conversions.cpp
test_boundary_conditions.cpp
test_grab_timeout.cpp
test_apple_permission.cpp # macOS camera-permission deadlock regression (empty TU elsewhere)
)

target_link_libraries(
Expand Down
94 changes: 94 additions & 0 deletions tests/test_apple_permission.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @file test_apple_permission.cpp
* @brief Regression test for the macOS camera-permission request deadlock.
*
* ccap::runBlockingAsyncRequest() (used by ProviderApple::open) must run the
* permission request on the calling thread and must NOT bounce it onto the main
* dispatch queue. Otherwise Provider::open() hangs forever when called from a worker
* thread in a process whose main thread is not running a run loop -- exactly the
* situation a Node.js / Electron addon or any head-less multi-threaded embedder
* creates.
*
* We exercise the real helper with a *simulated* asynchronous request: a short
* countdown that fires the completion from a background thread, just like
* AVCaptureDevice requestAccessForMediaType: delivers its completion off the caller's
* run loop. No camera is required, so this runs deterministically in CI.
*
* On non-Apple platforms this file compiles to an empty translation unit.
*/

#if defined(__APPLE__)

#include <gtest/gtest.h>

#include <chrono>
#include <functional>
#include <future>
#include <thread>

#include "ccap_apple_async.h"

namespace
{

// Stand-in for AVCaptureDevice requestAccessForMediaType:completionHandler:: it fires
// the completion asynchronously from a *background* thread after a short countdown,
// never touching the caller's main run loop.
void simulateAsyncPermissionRequest(const std::function<void()>& done)
{
std::function<void()> completion = done; // must outlive this call
std::thread([completion]() {
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // countdown
completion();
}).detach();
}

// Runs runBlockingAsyncRequest (optionally on a worker thread) and reports whether it
// returned within the timeout. A timeout means it deadlocked.
bool completesWithoutDeadlock(bool onWorkerThread, std::chrono::milliseconds timeout)
{
std::promise<void> donePromise;
std::future<void> doneFuture = donePromise.get_future();

auto body = [&donePromise]() {
ccap::runBlockingAsyncRequest(&simulateAsyncPermissionRequest);
donePromise.set_value();
};

std::thread worker;
if (onWorkerThread) {
worker = std::thread(body);
} else {
body();
}

const bool completed = doneFuture.wait_for(timeout) == std::future_status::ready;
if (worker.joinable()) {
if (completed) {
worker.join();
} else {
worker.detach(); // leave the hung thread; the process exits regardless
}
}
return completed;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

} // namespace

// The regression: open() called off the main thread with no run loop servicing the
// main queue. This deadlocked with the old dispatch-to-main-queue implementation.
TEST(AppleCameraPermission, OffMainThreadWithoutRunLoopDoesNotDeadlock)
{
EXPECT_TRUE(completesWithoutDeadlock(/*onWorkerThread=*/true, std::chrono::seconds(5)))
<< "runBlockingAsyncRequest() deadlocked off the main thread -- the request was "
"likely bounced onto an unserviced main dispatch queue.";
}

// Sanity: the common main-thread path must also complete promptly.
TEST(AppleCameraPermission, MainThreadDoesNotDeadlock)
{
EXPECT_TRUE(completesWithoutDeadlock(/*onWorkerThread=*/false, std::chrono::seconds(5)))
<< "runBlockingAsyncRequest() deadlocked on the main thread.";
}

#endif // __APPLE__
Loading