Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions .github/workflows/windows-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ permissions:
jobs:
build-vs2022:
name: Windows Build VS2022 (${{ matrix.config }}-${{ matrix.library_type }})
runs-on: windows-latest
# Pin to the windows-2022 image: windows-latest now resolves to an image that
# ships Visual Studio 2026 (v18) only, where the "Visual Studio 17 2022"
# generator cannot find a VS instance. VS 2026 is covered by the build-vs2026 job.
runs-on: windows-2022

strategy:
matrix:
Expand Down Expand Up @@ -311,16 +314,20 @@ jobs:

# Check CMake version
cmake --version

mkdir -p "build/${{ matrix.config }}-${{ matrix.library_type }}"

# Use New-Item -Force (idempotent): in PowerShell "mkdir -p" maps to New-Item,
# which errors when the directory already exists -- e.g. after a build-cache hit.
New-Item -ItemType Directory -Force -Path "build/${{ matrix.config }}-${{ matrix.library_type }}" | Out-Null
cd "build/${{ matrix.config }}-${{ matrix.library_type }}"

# Use Visual Studio 18 2026 generator with v144 toolset
# Use the Visual Studio 18 2026 generator with its default toolset.
# Forcing -T v144 makes MSBuild fail to resolve VCTargetsPath on the
# windows-2025-vs2026 image, so let CMake pick the toolset that ships with VS 2026.
Write-Host "Attempting to use Visual Studio 18 2026 generator..."
cmake ../.. -G "Visual Studio 18 2026" -A x64 -T v144 -DCCAP_BUILD_TESTS=ON $SHARED_FLAG
cmake ../.. -G "Visual Studio 18 2026" -A x64 -DCCAP_BUILD_TESTS=ON $SHARED_FLAG
if ($LASTEXITCODE -ne 0) {
Write-Host "Visual Studio 18 2026 generator not available, trying alternative with v144 toolset..."
cmake ../.. -A x64 -T v144 -DCCAP_BUILD_TESTS=ON $SHARED_FLAG
Write-Host "Visual Studio 18 2026 generator not available, trying the default generator..."
cmake ../.. -A x64 -DCCAP_BUILD_TESTS=ON $SHARED_FLAG
}

- name: Build
Expand Down Expand Up @@ -391,8 +398,10 @@ jobs:
}
EOF

# Find Visual Studio 2026 compiler
VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio/2026" -name "vcvars64.bat" 2>/dev/null | head -n1)
# Find the Visual Studio compiler. The VS 2026 install lives under a version
# folder ("...\Microsoft Visual Studio\18\..."), not a "2026" year folder, so
# search the whole VS directory rather than hard-coding the name.
VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio" -name "vcvars64.bat" 2>/dev/null | head -n1)
Comment on lines +401 to +404

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pin vcvars64.bat discovery to VS 2026 to avoid false-positive link tests.

At Line 404, find ... | head -n1 may pick a non-2026 installation (for example VS2022) when multiple VS versions are present, so this “VS2026” link test can silently validate the wrong toolchain.

Suggested fix
-        VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio" -name "vcvars64.bat" 2>/dev/null | head -n1)
+        # Prefer VS 2026 (major version 18) explicitly.
+        VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio/18" -name "vcvars64.bat" 2>/dev/null | head -n1)
+        # Fallback only if VS 2026 path is unavailable.
+        if [ -z "$VCVARS_PATH" ]; then
+          VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio" -name "vcvars64.bat" 2>/dev/null | head -n1)
+        fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Find the Visual Studio compiler. The VS 2026 install lives under a version
# folder ("...\Microsoft Visual Studio\18\..."), not a "2026" year folder, so
# search the whole VS directory rather than hard-coding the name.
VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio" -name "vcvars64.bat" 2>/dev/null | head -n1)
# Find the Visual Studio compiler. The VS 2026 install lives under a version
# folder ("...\Microsoft Visual Studio\18\..."), not a "2026" year folder, so
# search the whole VS directory rather than hard-coding the name.
# Prefer VS 2026 (major version 18) explicitly.
VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio/18" -name "vcvars64.bat" 2>/dev/null | head -n1)
# Fallback only if VS 2026 path is unavailable.
if [ -z "$VCVARS_PATH" ]; then
VCVARS_PATH=$(find "/c/Program Files/Microsoft Visual Studio" -name "vcvars64.bat" 2>/dev/null | head -n1)
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/windows-build.yml around lines 401 - 404, The vcvars64.bat
lookup in the Windows build workflow is too broad and can resolve a non-2026
Visual Studio install, causing the VS2026 link test to use the wrong toolchain.
Update the discovery logic in the workflow step that sets VCVARS_PATH so it
explicitly targets the VS 2026 installation under the known Microsoft Visual
Studio 18 path instead of scanning all versions with find | head -n1, and keep
the rest of the link-test setup using that resolved path.


if [ -n "$VCVARS_PATH" ]; then
echo "Using Visual Studio 2026 environment from: $VCVARS_PATH"
Expand Down Expand Up @@ -658,7 +667,8 @@ jobs:
# Test building with file playback disabled (VS2022 Release only)
build-vs2022-no-file-playback:
name: Windows VS2022 Release - No File Playback
runs-on: windows-latest
# See build-vs2022: pin to windows-2022 for the Visual Studio 2022 toolchain.
runs-on: windows-2022

steps:
- name: Checkout repository
Expand Down
61 changes: 61 additions & 0 deletions src/ccap_apple_async.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @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]() {
// Notify while holding the lock: `mutex`/`cv`/`finished` are stack-locals of the
// (possibly different) waiting thread. If we unlocked before notifying, the waiter
// could wake (e.g. spuriously), see finished == true, return, and destroy `cv`
// before notify_one() ran -- a use-after-free. Holding the lock makes the waiter
// block re-acquiring it until notify_one() has completed.
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
98 changes: 98 additions & 0 deletions tests/test_apple_permission.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @file test_apple_permission.cpp
* @brief Contract test for ccap::runBlockingAsyncRequest() -- the helper that
* ProviderApple::open() delegates its camera-permission wait to on macOS.
*
* Background: open()'s permission request used to be dispatched onto the main dispatch
* queue, which deadlocks when nothing services that queue (a worker thread in a
* Node.js / Electron addon, or a head-less service). The fix extracted the
* "start an async request and block until it completes" step into
* runBlockingAsyncRequest(), which runs the request on the calling thread.
*
* Scope: this pins that helper's contract -- a blocking wait whose completion is
* delivered on another thread must finish without deadlocking or missing the signal --
* using a *simulated* async request (a background-thread countdown standing in for
* AVCaptureDevice requestAccessForMediaType:). It deliberately does NOT drive
* open()/AVFoundation end to end: that needs a real camera and TCC state and cannot run
* deterministically in CI. The helper is the unit where the deadlock lived once the
* main-queue hop was removed, so guarding its contract is what is testable here.
*
* 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 <memory>
#include <thread>

#include "ccap_apple_async.h"

namespace
{

// Runs `scenario` (which performs the runBlockingAsyncRequest call under test) on a
// dedicated worker thread and reports whether it finished within `timeout`. Keeping the
// call on a worker thread -- with the watchdog on the calling thread -- means a
// regression that deadlocks fails the test with a clean timeout instead of hanging the
// whole test binary. The completion state lives on the heap and is shared with the
// worker, so a late completion after a timeout/detach can never touch freed state.
bool finishesWithinTimeout(std::function<void()> scenario, std::chrono::milliseconds timeout)
{
auto finished = std::make_shared<std::promise<void>>();
std::future<void> future = finished->get_future();

std::thread worker([scenario = std::move(scenario), finished]() {
scenario();
finished->set_value();
});

const bool ok = future.wait_for(timeout) == std::future_status::ready;
if (ok) {
worker.join();
} else {
worker.detach(); // never block the test process; the heap state keeps detach safe
}
return ok;
}

// Stand-in for AVCaptureDevice requestAccessForMediaType:completionHandler:: fires the
// completion asynchronously from a *background* thread after a short countdown, exactly
// like the real API delivers its completion off the caller's run loop.
void completeAsynchronously(const std::function<void()>& done)
{
auto completion = std::make_shared<std::function<void()>>(done);
std::thread([completion]() {
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // countdown
(*completion)();
}).detach();
}

} // namespace

// Regression: the permission wait must not deadlock when run off the main thread with
// no run loop servicing the main queue (e.g. a ccap::Provider opened from a Node.js
// addon worker thread). This hung with the old dispatch-to-main-queue implementation.
TEST(AppleCameraPermission, AsyncCompletionOffMainThreadDoesNotDeadlock)
{
EXPECT_TRUE(finishesWithinTimeout([] { ccap::runBlockingAsyncRequest(&completeAsynchronously); },
std::chrono::seconds(5)))
<< "runBlockingAsyncRequest() deadlocked -- the request was likely bounced onto "
"an unserviced main dispatch queue.";
}

// The completion may also fire synchronously (e.g. authorization already determined);
// the blocking wait must still observe the signal rather than miss it.
TEST(AppleCameraPermission, SynchronousCompletionDoesNotDeadlock)
{
EXPECT_TRUE(finishesWithinTimeout(
[] { ccap::runBlockingAsyncRequest([](const std::function<void()>& done) { done(); }); },
std::chrono::seconds(5)))
<< "runBlockingAsyncRequest() missed a synchronous completion.";
}

#endif // __APPLE__
12 changes: 9 additions & 3 deletions tests/test_file_playback.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -716,11 +716,17 @@ TEST_F(FilePlaybackTest, GetCurrentTimeProgression) {
EXPECT_GT(time2, time1) << "CurrentTime should increase as frames are grabbed";

double frameRate = provider.get(ccap::PropertyName::FrameRate);
ASSERT_GT(frameRate, 0.0) << "FrameRate must be positive; otherwise the bound below is vacuous (inf/NaN)";
double expectedTimeDelta = 5.0 / frameRate;

// Allow some tolerance for timing variations
EXPECT_NEAR(time2 - time1, expectedTimeDelta, expectedTimeDelta * 0.5)
<< "Time progression should roughly match frame rate";
// CurrentTime reports the wall-clock playback position, not a frame counter, so
// grabbing buffered frames faster or slower than real-time (as happens on shared CI
// runners) makes (time2 - time1) deviate from 5 / frameRate in both directions. The
// reliable invariant is forward progress, asserted above; keep only a generous upper
// bound here to catch gross regressions without flaking on timing. The deterministic
// frame-count progression is covered by GetCurrentFrameIndexProgression below.
EXPECT_LT(time2 - time1, expectedTimeDelta * 5.0)
<< "Time progression should stay within a sane multiple of the frame-rate span";

provider.stop();
provider.close();
Expand Down
Loading