Skip to content

Commit fc618ea

Browse files
wysaidclaude
andcommitted
fix(macos): avoid camera-permission deadlock when opening off the main thread
ProviderApple::open() requested camera authorization by dispatching the request onto the main dispatch queue for non-main-thread callers, then blocking on a semaphore. When nothing services the main queue -- e.g. a ccap::Provider opened from a worker thread in a process with no CFRunLoop on its main thread (a Node.js/Electron addon, a head-less multi-threaded service) -- the dispatched block never runs, so the permission request is never even issued and open() hangs forever. Main-thread callers and apps with a running run loop were unaffected, which is why this stayed dormant. requestAccessForMediaType: may be called from any thread and delivers its completion on an internal queue, so the main-queue hop is unnecessary. Extract the "start an async request and block until it completes" logic into ccap::runBlockingAsyncRequest() (src/ccap_apple_async.h), which runs the request on the calling thread and waits on a portable condition variable. The blocking "wait until the user decides" behavior is preserved; only the deadlock-prone main-queue dispatch is removed. Add tests/test_apple_permission.cpp: a deterministic regression test that drives the real helper with a simulated async request (a countdown firing the completion from a background thread) and asserts the wait does not deadlock when invoked off the main thread with no run loop. It builds into the existing ccap_convert_test aggregate, so it runs in the macOS CI "Run Full Test Suite" job (./run_tests.sh --functional); it is an empty translation unit elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0d1a0f7 commit fc618ea

4 files changed

Lines changed: 170 additions & 16 deletions

File tree

src/ccap_apple_async.h

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @file ccap_apple_async.h
3+
* @author wysaid (this@wysaid.org)
4+
* @brief Run a callback-based asynchronous request and block until it completes,
5+
* without bouncing the request onto the main dispatch queue.
6+
*
7+
* macOS callback APIs such as `AVCaptureDevice requestAccessForMediaType:` deliver
8+
* their completion on an internal queue, not on the caller's run loop. ccap used to
9+
* dispatch the permission request onto the main queue for non-main-thread callers,
10+
* which deadlocks whenever nothing is servicing that queue -- e.g. a ccap::Provider
11+
* opened from a worker thread in a process that has no CFRunLoop on its main thread
12+
* (a Node.js / Electron addon, a head-less multi-threaded service, ...).
13+
*
14+
* runBlockingAsyncRequest() starts the request on the *calling* thread and blocks on a
15+
* portable condition variable until the supplied continuation is invoked, so it is
16+
* safe to call from any thread regardless of run-loop state.
17+
*
18+
* Covered by tests/test_apple_permission.cpp.
19+
*/
20+
21+
#pragma once
22+
23+
#if defined(__APPLE__)
24+
25+
#include <condition_variable>
26+
#include <functional>
27+
#include <mutex>
28+
29+
namespace ccap
30+
{
31+
32+
/**
33+
* Invoke @p start on the current thread and block until the continuation that
34+
* @p start receives (its `done` argument) is called. @p start may invoke `done` from
35+
* any thread or queue. The request is never dispatched to the main queue, so this
36+
* cannot deadlock when no run loop is servicing it.
37+
*/
38+
inline void runBlockingAsyncRequest(const std::function<void(const std::function<void()>& done)>& start)
39+
{
40+
std::mutex mutex;
41+
std::condition_variable cv;
42+
bool finished = false;
43+
44+
start([&mutex, &cv, &finished]() {
45+
{
46+
std::lock_guard<std::mutex> lock(mutex);
47+
finished = true;
48+
}
49+
cv.notify_one();
50+
});
51+
52+
std::unique_lock<std::mutex> lock(mutex);
53+
cv.wait(lock, [&finished]() { return finished; });
54+
}
55+
56+
} // namespace ccap
57+
58+
#endif // __APPLE__

src/ccap_imp_apple.mm

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "ccap_imp_apple.h"
1212
#include "ccap_file_reader_apple.h"
1313

14+
#include "ccap_apple_async.h"
1415
#include "ccap_convert.h"
1516
#include "ccap_convert_frame.h"
1617

@@ -19,6 +20,7 @@
1920
#import <Foundation/Foundation.h>
2021
#include <cassert>
2122
#include <cmath>
23+
#include <functional>
2224

2325
#if _CCAP_LOG_ENABLED_
2426
#include <deque>
@@ -255,23 +257,22 @@ - (instancetype)initWithProvider:(ProviderApple*)provider {
255257
- (BOOL)open {
256258
AVAuthorizationStatus authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
257259
if (authStatus == AVAuthorizationStatusNotDetermined) {
258-
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
259-
void (^requestAccess)(void) = ^(void) {
260-
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
261-
CCAP_NSLOG_I(@"ccap: Camera access %@", granted ? @"granted" : @"denied");
262-
dispatch_semaphore_signal(sema);
263-
}];
264-
};
265-
266-
// Permission must be requested on the main thread
267-
if (![NSThread isMainThread]) {
268-
dispatch_async(dispatch_get_main_queue(), ^{ requestAccess(); });
269-
} else {
270-
requestAccess();
271-
}
272-
273260
CCAP_NSLOG_I(@"ccap: Waiting for camera access permission...");
274-
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
261+
// Request authorization on the calling thread and block until the system's
262+
// completion handler fires. We deliberately do NOT dispatch the request onto the
263+
// main queue: requestAccessForMediaType: may be called from any thread and
264+
// delivers its completion on an internal queue, so bouncing to the main queue
265+
// would deadlock whenever no run loop is servicing it (e.g. a ccap::Provider
266+
// opened from a worker thread in a process without a CFRunLoop). See
267+
// tests/test_apple_permission.cpp.
268+
ccap::runBlockingAsyncRequest([](const std::function<void()>& done) {
269+
std::function<void()> notifyDone = done; // outlive the async completion
270+
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo
271+
completionHandler:^(BOOL granted) {
272+
CCAP_NSLOG_I(@"ccap: Camera access %@", granted ? @"granted" : @"denied");
273+
notifyDone();
274+
}];
275+
});
275276
authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
276277
}
277278

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ add_executable(
203203
test_frame_conversions.cpp
204204
test_boundary_conditions.cpp
205205
test_grab_timeout.cpp
206+
test_apple_permission.cpp # macOS camera-permission deadlock regression (empty TU elsewhere)
206207
)
207208

208209
target_link_libraries(

tests/test_apple_permission.cpp

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @file test_apple_permission.cpp
3+
* @brief Regression test for the macOS camera-permission request deadlock.
4+
*
5+
* ccap::runBlockingAsyncRequest() (used by ProviderApple::open) must run the
6+
* permission request on the calling thread and must NOT bounce it onto the main
7+
* dispatch queue. Otherwise Provider::open() hangs forever when called from a worker
8+
* thread in a process whose main thread is not running a run loop -- exactly the
9+
* situation a Node.js / Electron addon or any head-less multi-threaded embedder
10+
* creates.
11+
*
12+
* We exercise the real helper with a *simulated* asynchronous request: a short
13+
* countdown that fires the completion from a background thread, just like
14+
* AVCaptureDevice requestAccessForMediaType: delivers its completion off the caller's
15+
* run loop. No camera is required, so this runs deterministically in CI.
16+
*
17+
* On non-Apple platforms this file compiles to an empty translation unit.
18+
*/
19+
20+
#if defined(__APPLE__)
21+
22+
#include <gtest/gtest.h>
23+
24+
#include <chrono>
25+
#include <functional>
26+
#include <future>
27+
#include <thread>
28+
29+
#include "ccap_apple_async.h"
30+
31+
namespace
32+
{
33+
34+
// Stand-in for AVCaptureDevice requestAccessForMediaType:completionHandler:: it fires
35+
// the completion asynchronously from a *background* thread after a short countdown,
36+
// never touching the caller's main run loop.
37+
void simulateAsyncPermissionRequest(const std::function<void()>& done)
38+
{
39+
std::function<void()> completion = done; // must outlive this call
40+
std::thread([completion]() {
41+
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // countdown
42+
completion();
43+
}).detach();
44+
}
45+
46+
// Runs runBlockingAsyncRequest (optionally on a worker thread) and reports whether it
47+
// returned within the timeout. A timeout means it deadlocked.
48+
bool completesWithoutDeadlock(bool onWorkerThread, std::chrono::milliseconds timeout)
49+
{
50+
std::promise<void> donePromise;
51+
std::future<void> doneFuture = donePromise.get_future();
52+
53+
auto body = [&donePromise]() {
54+
ccap::runBlockingAsyncRequest(&simulateAsyncPermissionRequest);
55+
donePromise.set_value();
56+
};
57+
58+
std::thread worker;
59+
if (onWorkerThread) {
60+
worker = std::thread(body);
61+
} else {
62+
body();
63+
}
64+
65+
const bool completed = doneFuture.wait_for(timeout) == std::future_status::ready;
66+
if (worker.joinable()) {
67+
if (completed) {
68+
worker.join();
69+
} else {
70+
worker.detach(); // leave the hung thread; the process exits regardless
71+
}
72+
}
73+
return completed;
74+
}
75+
76+
} // namespace
77+
78+
// The regression: open() called off the main thread with no run loop servicing the
79+
// main queue. This deadlocked with the old dispatch-to-main-queue implementation.
80+
TEST(AppleCameraPermission, OffMainThreadWithoutRunLoopDoesNotDeadlock)
81+
{
82+
EXPECT_TRUE(completesWithoutDeadlock(/*onWorkerThread=*/true, std::chrono::seconds(5)))
83+
<< "runBlockingAsyncRequest() deadlocked off the main thread -- the request was "
84+
"likely bounced onto an unserviced main dispatch queue.";
85+
}
86+
87+
// Sanity: the common main-thread path must also complete promptly.
88+
TEST(AppleCameraPermission, MainThreadDoesNotDeadlock)
89+
{
90+
EXPECT_TRUE(completesWithoutDeadlock(/*onWorkerThread=*/false, std::chrono::seconds(5)))
91+
<< "runBlockingAsyncRequest() deadlocked on the main thread.";
92+
}
93+
94+
#endif // __APPLE__

0 commit comments

Comments
 (0)