From c79da0b2573ea4493d50fca6c2f47342577b3a9b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:20:20 +0900 Subject: [PATCH 01/11] ci(verify): bring the real-capture oracle up on hosted Windows --- .../workflows/verify-recording-windows.yml | 80 ++++++ Cargo.lock | 4 + crates/verify-stimulus/Cargo.toml | 5 + crates/verify-stimulus/src/main.rs | 79 ++++-- crates/verify/Cargo.toml | 7 + crates/verify/src/lib.rs | 3 + crates/verify/src/sched_windows.rs | 231 +++++++++++++++++ crates/verify/src/stimulus.rs | 25 +- crates/verify/src/video_code.rs | 24 ++ crates/verify/tests/recording.rs | 237 ++++++++++++++---- 10 files changed, 617 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/verify-recording-windows.yml create mode 100644 crates/verify/src/sched_windows.rs diff --git a/.github/workflows/verify-recording-windows.yml b/.github/workflows/verify-recording-windows.yml new file mode 100644 index 00000000000..9c155f5c9c6 --- /dev/null +++ b/.github/workflows/verify-recording-windows.yml @@ -0,0 +1,80 @@ +name: Verify recording (Windows, bring-up) + +# Bring-up of the real-capture oracle (crates/verify/tests/recording.rs) on +# GitHub-hosted Windows: its jobs run in the runner's interactive console +# session, where Windows Graphics Capture records the desktop. Blacksmith +# Windows VMs run jobs in session 0, which has no desktop to capture +# (probe runs 38997642473, 38997747111). + +on: + push: + branches: [ci/windows-real-capture] + workflow_dispatch: + inputs: + tier: + description: "fast or full" + default: "fast" + case_filter: + description: "Case globs, comma-separated (empty runs all)" + default: "" + +concurrency: + group: verify-recording-windows-${{ github.ref_name }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + real-capture: + runs-on: windows-2022 + timeout-minutes: 90 + env: + CAP_VERIFY_CAPTURE: display + CAP_VERIFY_REQUIRE_CAPTURE: "1" + CAP_VERIFY_TIER: ${{ inputs.tier || 'fast' }} + CAP_VERIFY_CASE: ${{ inputs.case_filter || '' }} + CAP_VERIFY_SCRATCH: ${{ github.workspace }}/verify-scratch + CAP_VERIFY_REPORT_DIR: ${{ github.workspace }}/verify-report + CAP_VERIFY_DUMP: ${{ github.workspace }}/verify-dump + CAP_VERIFY_HOST: github-hosted + # No audio endpoints on hosted Windows: nothing plays or loops back + # system audio. + CAP_VERIFY_STIMULUS_AUDIO: "0" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/engine-verify-setup + with: + target: x86_64-pc-windows-msvc + tier: fast + + - name: Build + shell: bash + run: | + start=$(date +%s) + cargo build --locked --profile ci-verify -p cap-verify-stimulus + cargo test --locked --profile ci-verify -p cap-verify --test recording --no-run + echo "build seconds: $(( $(date +%s) - start ))" + + - name: Real-capture recording oracle + shell: bash + run: cargo test --locked --profile ci-verify -p cap-verify --test recording -- --nocapture + + - name: Summary + if: always() + shell: bash + run: python scripts/verify/summary.py --title "real capture windows-2022" "$CAP_VERIFY_REPORT_DIR" >> "$GITHUB_STEP_SUMMARY" || true + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: verify-recording-windows-2022 + path: | + ${{ github.workspace }}/verify-report + ${{ github.workspace }}/verify-dump + ${{ github.workspace }}/verify-scratch + if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index 2e6a9faee11..38bfa138dac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2157,16 +2157,19 @@ version = "0.1.0" dependencies = [ "anyhow", "cap-audio", + "cap-camera-windows", "cap-editor", "cap-enc-avfoundation", "cap-export", "cap-media-info", + "cap-mediafoundation-utils", "cap-project", "cap-recording", "cap-rendering", "cap-timestamp", "cap-verify-expect", "cidre", + "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", "ffmpeg-next", "flume", "futures", @@ -2178,6 +2181,7 @@ dependencies = [ "tempfile", "tokio", "tracing-subscriber", + "windows 0.60.0", "workspace-hack", ] diff --git a/crates/verify-stimulus/Cargo.toml b/crates/verify-stimulus/Cargo.toml index 81905b4741d..8706b394249 100644 --- a/crates/verify-stimulus/Cargo.toml +++ b/crates/verify-stimulus/Cargo.toml @@ -26,3 +26,8 @@ objc2-foundation = "0.3.1" scap-screencapturekit = { path = "../scap-screencapturekit" } softbuffer = "0.4" winit = "0.30" + +[target.'cfg(windows)'.dependencies] +cpal = { workspace = true } +softbuffer = "0.4" +winit = "0.30" diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 528d9692fdf..84f5e033360 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -1,12 +1,12 @@ //! `cap-verify-stimulus `: see `cap_verify::stimulus`. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", windows)))] fn main() { - eprintln!("cap-verify-stimulus only runs on macOS"); + eprintln!("cap-verify-stimulus only runs on macOS and Windows"); std::process::exit(2); } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] fn main() { if let Err(e) = app::run() { println!("error {e}"); @@ -14,7 +14,7 @@ fn main() { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] mod app { use std::{ fs::File, @@ -36,6 +36,7 @@ mod app { video_code::{self, FrameMarker, PixelOrder, StreamKind}, }; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + #[cfg(target_os = "macos")] use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; use winit::{ application::ApplicationHandler, @@ -81,11 +82,16 @@ mod app { }); let sentinel = Sentinel::start(log_path.with_file_name(SENTINEL_FILE)); - use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; - let event_loop = EventLoop::::with_user_event() - .with_activation_policy(ActivationPolicy::Regular) - .with_activate_ignoring_other_apps(true) - .build()?; + #[cfg(target_os = "macos")] + let event_loop = { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + EventLoop::::with_user_event() + .with_activation_policy(ActivationPolicy::Regular) + .with_activate_ignoring_other_apps(true) + .build()? + }; + #[cfg(windows)] + let event_loop = EventLoop::::with_user_event().build()?; let proxy = event_loop.create_proxy(); spawn_stdin_reader(proxy); @@ -124,15 +130,21 @@ mod app { let observer = start_observer(app.log.clone()); let (qos, realtime) = raise_priority(app.spec.fps); app.log.write(&StimulusEvent::Priority { qos, realtime }); - let process_info = NSProcessInfo::processInfo(); - let activity = unsafe { - process_info.beginActivityWithOptions_reason( - NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical, - &NSString::from_str("Present recording verification markers on schedule"), - ) + #[cfg(target_os = "macos")] + let result = { + let process_info = NSProcessInfo::processInfo(); + let activity = unsafe { + process_info.beginActivityWithOptions_reason( + NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical, + &NSString::from_str("Present recording verification markers on schedule"), + ) + }; + let result = event_loop.run_app(&mut app); + unsafe { process_info.endActivity(&activity) }; + result }; + #[cfg(windows)] let result = event_loop.run_app(&mut app); - unsafe { process_info.endActivity(&activity) }; result?; drop(observer); drop(audio); @@ -190,10 +202,17 @@ mod app { .thread .join() .map_err(|_| anyhow::anyhow!("sentinel panicked"))?; - let info = cidre::mach::TimeBaseInfo::new(); + // Host ticks to nanoseconds: ticks * numer / denom. + #[cfg(target_os = "macos")] + let timebase = { + let info = cidre::mach::TimeBaseInfo::new(); + [u64::from(info.numer), u64::from(info.denom)] + }; + #[cfg(windows)] + let timebase = [1_000_000_000, sched::qpc_frequency()]; let mut out = serde_json::json!({ "period_secs": SENTINEL_PERIOD_SECS, - "timebase": [info.numer, info.denom], + "timebase": timebase, "first": wakes.first(), "realtime": realtime, "end": end, @@ -230,6 +249,18 @@ mod app { /// while pictures are being presented is the window server or capture /// service stalling (the input never reached the screen or the capture /// service), not the recorder. + #[cfg(windows)] + fn start_observer(log: Arc) -> Observer { + log.write(&StimulusEvent::ObserverUnavailable { + reason: "no independent capture observer on Windows".into(), + }); + Observer { + stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), + thread: None, + } + } + + #[cfg(target_os = "macos")] fn start_observer(log: Arc) -> Observer { use cidre::{ns, sc}; let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -694,6 +725,7 @@ mod app { epoch: Arc, log: Arc, ) -> Result { + #[cfg(target_os = "macos")] use cpal::host::coreaudio::StreamInstantExt; let host = cpal::default_host(); @@ -760,7 +792,16 @@ mod app { .build_output_stream( &config.config(), move |data: &mut [f32], info: &cpal::OutputCallbackInfo| { - state.fill(data, info.timestamp().callback.as_host_time()); + #[cfg(target_os = "macos")] + let callback_host = info.timestamp().callback.as_host_time(); + // cpal's WASAPI instants have no host-clock accessor; the + // callback runs at the start of the buffer it fills. + #[cfg(windows)] + let callback_host = { + let _ = info; + host_now() + }; + state.fill(data, callback_host); }, |e| eprintln!("stimulus audio error: {e}"), None, diff --git a/crates/verify/Cargo.toml b/crates/verify/Cargo.toml index 4392a72d173..453ddfbc8d0 100644 --- a/crates/verify/Cargo.toml +++ b/crates/verify/Cargo.toml @@ -40,3 +40,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(target_os = "macos")'.dev-dependencies] scap-targets = { path = "../scap-targets" } + +[target.'cfg(windows)'.dev-dependencies] +scap-targets = { path = "../scap-targets" } +cap-camera-windows = { path = "../camera-windows" } +cap-mediafoundation-utils = { path = "../mediafoundation-utils" } +cpal = { workspace = true } +windows = { workspace = true, features = ["Win32_Media_MediaFoundation"] } diff --git a/crates/verify/src/lib.rs b/crates/verify/src/lib.rs index 2460603f81e..7a31487d045 100644 --- a/crates/verify/src/lib.rs +++ b/crates/verify/src/lib.rs @@ -15,6 +15,9 @@ pub mod pipeline; pub mod report; #[cfg(target_os = "macos")] pub mod sched; +#[cfg(windows)] +#[path = "sched_windows.rs"] +pub mod sched; pub mod sentinel; pub mod stimulus; pub mod video_code; diff --git a/crates/verify/src/sched_windows.rs b/crates/verify/src/sched_windows.rs new file mode 100644 index 00000000000..f7cb874804a --- /dev/null +++ b/crates/verify/src/sched_windows.rs @@ -0,0 +1,231 @@ +//! Real-time scheduling for the verification suite's input sources, Windows. +//! +//! The same interface as the macOS module. Windows has no time-constraint +//! policy: an input thread asks for `THREAD_PRIORITY_TIME_CRITICAL` (the top +//! of its process's priority class, where audio and capture callbacks run) +//! and a 1 ms system timer resolution, and the priority is read back. Host +//! ticks are QueryPerformanceCounter ticks, the clock Cap stamps Windows +//! capture with (`Timestamp::PerformanceCounter`). + +use serde::{Deserialize, Serialize}; + +/// The requested wake period and work budget, in milliseconds. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Constraint { + pub period_ms: f64, + pub computation_ms: f64, + pub constraint_ms: f64, + pub preemptible: bool, +} + +/// A thread's scheduling state: `GetThreadPriority` in every priority field +/// and the thread's CPU time. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThreadState { + pub policy: i32, + pub base_pri: i32, + pub cur_pri: i32, + pub max_pri: i32, + pub cpu_us: u64, +} + +/// `THREAD_PRIORITY_TIME_CRITICAL`. +pub const REALTIME_PRIORITY: i32 = 15; + +/// The outcome of [`request_realtime`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Realtime { + pub requested: Constraint, + /// `timeBeginPeriod(1)` (`TIMERR_NOERROR` is 0). + pub qos_status: i32, + /// 0 when `SetThreadPriority` succeeded, else `GetLastError`. + pub policy_status: i32, + pub readback_status: i32, + pub readback_default: bool, + pub granted: Option, + pub state: Option, +} + +impl Realtime { + /// `Ok` when the thread runs at the time-critical priority. + pub fn obtained(&self) -> Result<(), String> { + if self.policy_status != 0 { + return Err(format!( + "SetThreadPriority(TIME_CRITICAL) failed: {}", + self.policy_status + )); + } + state_is_realtime(self.state) + } +} + +/// `Ok` when the thread state is at the time-critical priority. +pub fn state_is_realtime(state: Option) -> Result<(), String> { + match state { + Some(state) if state.base_pri >= REALTIME_PRIORITY => Ok(()), + Some(state) => Err(format!( + "thread priority {} is below time-critical ({REALTIME_PRIORITY})", + state.base_pri + )), + None => Err("GetThreadPriority failed".into()), + } +} + +const THREAD_QUERY_LIMITED_INFORMATION: u32 = 0x0800; +const THREAD_PRIORITY_ERROR_RETURN: i32 = 0x7fff_ffff; +const CREATE_WAITABLE_TIMER_HIGH_RESOLUTION: u32 = 0x0000_0002; +const TIMER_ALL_ACCESS: u32 = 0x001F_0003; +const INFINITE: u32 = 0xFFFF_FFFF; + +unsafe extern "system" { + fn GetCurrentThread() -> isize; + fn GetCurrentThreadId() -> u32; + fn GetLastError() -> u32; + fn SetThreadPriority(thread: isize, priority: i32) -> i32; + fn GetThreadPriority(thread: isize) -> i32; + fn OpenThread(access: u32, inherit: i32, id: u32) -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetThreadTimes( + thread: isize, + creation: *mut u64, + exit: *mut u64, + kernel: *mut u64, + user: *mut u64, + ) -> i32; + fn QueryPerformanceCounter(count: *mut i64) -> i32; + fn QueryPerformanceFrequency(frequency: *mut i64) -> i32; + fn CreateWaitableTimerExW( + attributes: *const core::ffi::c_void, + name: *const u16, + flags: u32, + access: u32, + ) -> isize; + fn SetWaitableTimer( + timer: isize, + due: *const i64, + period: i32, + completion: *const core::ffi::c_void, + argument: *const core::ffi::c_void, + resume: i32, + ) -> i32; + fn WaitForSingleObject(handle: isize, millis: u32) -> u32; + fn SwitchToThread() -> i32; +} + +#[link(name = "winmm")] +unsafe extern "system" { + fn timeBeginPeriod(period: u32) -> u32; +} + +/// The calling thread's id. +pub fn current_thread() -> u32 { + unsafe { GetCurrentThreadId() } +} + +/// QueryPerformanceCounter ticks. +pub fn qpc_now() -> u64 { + let mut count = 0i64; + unsafe { QueryPerformanceCounter(&mut count) }; + count as u64 +} + +/// QueryPerformanceCounter ticks per second. +pub fn qpc_frequency() -> u64 { + static FREQUENCY: std::sync::OnceLock = std::sync::OnceLock::new(); + *FREQUENCY.get_or_init(|| { + let mut frequency = 0i64; + unsafe { QueryPerformanceFrequency(&mut frequency) }; + frequency.max(1) as u64 + }) +} + +/// Asks for the time-critical priority and a 1 ms timer resolution, then +/// reads the priority back. +pub fn request_realtime(period_secs: f64, computation_secs: f64) -> Realtime { + let requested = Constraint { + period_ms: period_secs * 1e3, + computation_ms: computation_secs * 1e3, + constraint_ms: period_secs * 1e3, + preemptible: true, + }; + let qos_status = unsafe { timeBeginPeriod(1) } as i32; + let policy_status = if unsafe { SetThreadPriority(GetCurrentThread(), REALTIME_PRIORITY) } != 0 + { + 0 + } else { + unsafe { GetLastError() as i32 } + }; + let state = thread_state(current_thread()); + let granted = state + .filter(|state| state.base_pri >= REALTIME_PRIORITY) + .map(|_| requested); + Realtime { + requested, + qos_status, + policy_status, + readback_status: if state.is_some() { 0 } else { -1 }, + readback_default: granted.is_none(), + granted, + state, + } +} + +/// The scheduling state of the thread with id `thread`. +pub fn thread_state(thread: u32) -> Option { + let handle = unsafe { OpenThread(THREAD_QUERY_LIMITED_INFORMATION, 0, thread) }; + if handle == 0 { + return None; + } + let priority = unsafe { GetThreadPriority(handle) }; + let (mut creation, mut exit, mut kernel, mut user) = (0u64, 0u64, 0u64, 0u64); + let times = unsafe { GetThreadTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }; + unsafe { CloseHandle(handle) }; + (priority != THREAD_PRIORITY_ERROR_RETURN).then_some(ThreadState { + policy: 0, + base_pri: priority, + cur_pri: priority, + max_pri: REALTIME_PRIORITY, + // FILETIME counts 100 ns intervals. + cpu_us: if times != 0 { (kernel + user) / 10 } else { 0 }, + }) +} + +thread_local! { + static TIMER: isize = unsafe { + CreateWaitableTimerExW( + std::ptr::null(), + std::ptr::null(), + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + TIMER_ALL_ACCESS, + ) + }; +} + +/// How long before the deadline the timer wait ends and spinning starts. +const SPIN_SECS: f64 = 0.000_25; + +/// Blocks until the host clock reaches `deadline` (QueryPerformanceCounter +/// ticks): a high-resolution waitable timer for most of the wait, then a +/// yielding spin for the last quarter millisecond. +pub fn wait_until(deadline: u64) { + let frequency = qpc_frequency() as f64; + let remaining = |now: u64| (deadline as f64 - now as f64) / frequency; + let left = remaining(qpc_now()); + if left > SPIN_SECS { + TIMER.with(|&timer| { + if timer != 0 { + // Negative: relative, in 100 ns intervals. + let due = -(((left - SPIN_SECS) * 1e7) as i64).max(1); + unsafe { + if SetWaitableTimer(timer, &due, 0, std::ptr::null(), std::ptr::null(), 0) != 0 + { + WaitForSingleObject(timer, INFINITE); + } + } + } + }); + } + while remaining(qpc_now()) > 0.0 { + unsafe { SwitchToThread() }; + } +} diff --git a/crates/verify/src/stimulus.rs b/crates/verify/src/stimulus.rs index 0820d75b234..d43e7eef6d9 100644 --- a/crates/verify/src/stimulus.rs +++ b/crates/verify/src/stimulus.rs @@ -130,7 +130,7 @@ pub const SENTINEL_PERIOD_SECS: f64 = 0.002; /// Every wake of the stimulus's real-time sentinel thread (host ticks) and /// the scheduling it ran under. -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] #[derive(Clone, Debug)] pub struct SentinelLog { pub realtime: crate::sched::Realtime, @@ -138,7 +138,7 @@ pub struct SentinelLog { pub wakes: Vec, } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] impl SentinelLog { pub fn read(path: &std::path::Path) -> anyhow::Result { #[derive(serde::Deserialize)] @@ -190,10 +190,27 @@ pub fn host_add_secs(a: u64, secs: f64) -> u64 { (a as f64 + secs * 1e9 * f64::from(info.denom) / f64::from(info.numer)).round() as u64 } -#[cfg(target_os = "macos")] +/// QueryPerformanceCounter ticks, the clock Windows capture is stamped with. +#[cfg(windows)] +pub fn host_now() -> u64 { + crate::sched::qpc_now() +} + +/// Seconds between two host times (`a - b`). +#[cfg(windows)] +pub fn host_secs(a: u64, b: u64) -> f64 { + (a as f64 - b as f64) / crate::sched::qpc_frequency() as f64 +} + +#[cfg(windows)] +pub fn host_add_secs(a: u64, secs: f64) -> u64 { + (a as f64 + secs * crate::sched::qpc_frequency() as f64).round() as u64 +} + +#[cfg(any(target_os = "macos", windows))] pub use process::StimulusProcess; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", windows))] mod process { use std::{ io::{BufRead, BufReader, Write}, diff --git a/crates/verify/src/video_code.rs b/crates/verify/src/video_code.rs index 399b8ad8dc0..f0e5733fd28 100644 --- a/crates/verify/src/video_code.rs +++ b/crates/verify/src/video_code.rs @@ -267,6 +267,30 @@ pub fn rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (f64, f64, f64) { ) } +/// Tightly packed limited-range NV12 (BT.601) of an RGBA picture: the Y +/// plane, then interleaved Cb/Cr sampled at each 2x2 block's top-left pixel. +pub fn rgba_to_nv12(rgba: &[u8], width: usize, height: usize) -> Vec { + let ycc = |x: usize, y: usize| { + let i = (y * width + x) * 4; + rgb_to_ycbcr(rgba[i], rgba[i + 1], rgba[i + 2]) + }; + let mut out = Vec::with_capacity(width * height * 3 / 2); + for y in 0..height { + for x in 0..width { + let (l, _, _) = ycc(x, y); + out.push((16.0 + l * 219.0 / 255.0).round() as u8); + } + } + for y in 0..height / 2 { + for x in 0..width / 2 { + let (_, cb, cr) = ycc(x * 2, y * 2); + out.push((128.0 + cb * 224.0 / 255.0).round() as u8); + out.push((128.0 + cr * 224.0 / 255.0).round() as u8); + } + } + out +} + /// Read access to a decoded picture in luma + centred chroma. pub trait PixelSource { fn width(&self) -> usize; diff --git a/crates/verify/tests/recording.rs b/crates/verify/tests/recording.rs index 33c4a3a163b..9547a70305f 100644 --- a/crates/verify/tests/recording.rs +++ b/crates/verify/tests/recording.rs @@ -14,7 +14,7 @@ //! `off` skips. Without Screen Recording permission the cases skip unless //! `CAP_VERIFY_REQUIRE_CAPTURE=1`. -#![cfg(target_os = "macos")] +#![cfg(any(target_os = "macos", windows))] use std::{ collections::BTreeMap, @@ -29,7 +29,7 @@ use std::{ use cap_media_info::{RawVideoFormat, VideoInfo}; use cap_project::{InstantRecordingMeta, RecordingMeta, RecordingMetaInner, StudioRecordingMeta}; use cap_recording::{ - NativeCameraFrame, RecordingDefaults, SendableShareableContent, StudioQuality, + NativeCameraFrame, RecordingDefaults, StudioQuality, feeds::{ Admission, camera::synthetic::SyntheticCamera, microphone::synthetic::SyntheticMicrophone, }, @@ -38,10 +38,11 @@ use cap_recording::{ screen_capture::ScreenCaptureTarget, studio_recording, }; -use cap_timestamp::{MachAbsoluteTimestamp, Timestamp}; +#[cfg(target_os = "macos")] +use cap_verify::fixture_avf; use cap_verify::{ audio_code::{self, AudioSource, MarkerPlacement}, - fixture_avf, media, + media, report::{self, Case, Tier}, sched, stimulus::{ @@ -51,6 +52,89 @@ use cap_verify::{ video_code::{self, FrameMarker, Readout, StreamKind}, }; +use platform::{Shareable, host_timestamp}; + +/// What differs between the macOS and Windows hosts: the capture permission +/// check, the host clock the sources are stamped with and thread priority. +#[cfg(target_os = "macos")] +mod platform { + use cap_timestamp::{MachAbsoluteTimestamp, Timestamp}; + + pub type Shareable = cap_recording::SendableShareableContent; + + /// ScreenCaptureKit's shareable content, which also checks the Screen + /// Recording permission. + pub async fn shareable() -> anyhow::Result { + cidre::sc::ShareableContent::current() + .await + .map(Shareable::from) + .map_err(|e| { + anyhow::anyhow!("ScreenCaptureKit unavailable ({e:?}); grant Screen Recording") + }) + } + + pub fn host_timestamp(ticks: u64) -> Timestamp { + Timestamp::MachAbsoluteTime(MachAbsoluteTimestamp::new(ticks)) + } + + /// Host ticks to nanoseconds: ticks * numer / denom. + pub fn timebase() -> [u64; 2] { + let info = cidre::mach::TimeBaseInfo::new(); + [u64::from(info.numer), u64::from(info.denom)] + } + + pub fn raise_thread_qos() { + const QOS_CLASS_USER_INITIATED: u32 = 0x19; + unsafe extern "C" { + fn pthread_set_qos_class_self_np(qos: u32, relative_priority: i32) -> i32; + } + unsafe { pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) }; + } + + /// Every host has an output device for the system audio stimulus. + pub fn system_audio_output() -> bool { + true + } +} + +#[cfg(windows)] +mod platform { + use cap_timestamp::{PerformanceCounterTimestamp, Timestamp}; + + pub type Shareable = (); + + /// Windows Graphics Capture needs no permission; recording itself fails + /// when the session has no desktop. + pub async fn shareable() -> anyhow::Result { + Ok(()) + } + + pub fn host_timestamp(ticks: u64) -> Timestamp { + Timestamp::PerformanceCounter(PerformanceCounterTimestamp::new(ticks as i64)) + } + + /// Host ticks to nanoseconds: ticks * numer / denom. + pub fn timebase() -> [u64; 2] { + [1_000_000_000, cap_verify::sched::qpc_frequency()] + } + + pub fn raise_thread_qos() { + const THREAD_PRIORITY_ABOVE_NORMAL: i32 = 1; + unsafe extern "system" { + fn GetCurrentThread() -> isize; + fn SetThreadPriority(thread: isize, priority: i32) -> i32; + } + unsafe { SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL) }; + } + + /// Hosted Windows runners run the audio service with no endpoints: there + /// is nothing to play the system audio stimulus on or to loop back. + pub fn system_audio_output() -> bool { + use cpal::traits::HostTrait; + cpal::default_host().default_output_device().is_some() + } +} + static ATTEMPT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); const MIC_RATE: u32 = 48_000; @@ -342,13 +426,8 @@ fn env_flag(name: &str) -> bool { /// high priority; at utility priority a loaded runner delays them and their /// lateness shows up as recording timing errors. Run the runtime at the /// priority the product itself asks for its recording threads. -#[cfg(target_os = "macos")] fn raise_thread_qos() { - const QOS_CLASS_USER_INITIATED: u32 = 0x19; - unsafe extern "C" { - fn pthread_set_qos_class_self_np(qos: u32, relative_priority: i32) -> i32; - } - unsafe { pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) }; + platform::raise_thread_qos(); } fn runtime() -> tokio::runtime::Runtime { @@ -380,10 +459,10 @@ fn real_capture() { return; }; let rt = runtime(); - let shareable = match rt.block_on(cidre::sc::ShareableContent::current()) { - Ok(content) => SendableShareableContent::from(content), + let shareable = match rt.block_on(platform::shareable()) { + Ok(content) => content, Err(e) => { - let message = format!("ScreenCaptureKit unavailable ({e:?}); grant Screen Recording"); + let message = format!("{e:#}"); if env_flag("CAP_VERIFY_REQUIRE_CAPTURE") { panic!("{message}"); } @@ -653,10 +732,7 @@ fn start_feeds( } } trace.handoff(n as u32, delivered, wake, pri, || { - mic.try_push( - chunk, - Timestamp::MachAbsoluteTime(MachAbsoluteTimestamp::new(capture)), - ) + mic.try_push(chunk, host_timestamp(capture)) }); } trace.end = sched::thread_state(thread); @@ -929,7 +1005,6 @@ fn persist_sources( .map(|d| PathBuf::from(d).join("sources")) .unwrap_or_else(|| fallback.to_path_buf()); let path = dir.join(format!("{stem}.json")); - let info = cidre::mach::TimeBaseInfo::new(); let sources: Vec = rec .sources .iter() @@ -960,7 +1035,7 @@ fn persist_sources( .collect(); let value = serde_json::json!({ "attempt": stem, - "timebase": [info.numer, info.denom], + "timebase": platform::timebase(), "reference": reference, "window_secs": [rec.started, rec.stopped], "columns": ["index", "due", "wake", "built", "pushed", "admission", "pri"], @@ -974,16 +1049,43 @@ fn persist_sources( path } +#[cfg(target_os = "macos")] fn camera_frame(slot: u32, capture: u64) -> Option { let (w, h) = CAMERA_SIZE; let rgba = video_code::marker_rgba(FrameMarker::new(StreamKind::Camera, 0, slot), w, h); let sample_buf = camera_sample(&rgba, w, h, capture).ok()?; Some(NativeCameraFrame { sample_buf, - timestamp: Timestamp::MachAbsoluteTime(MachAbsoluteTimestamp::new(capture)), + timestamp: host_timestamp(capture), }) } +/// An NV12 frame in a Media Foundation memory buffer, as the Windows camera +/// feed hands native frames to the encoder. +#[cfg(windows)] +fn camera_frame(slot: u32, capture: u64) -> Option { + use cap_mediafoundation_utils::IMFMediaBufferExt; + use windows::Win32::Media::MediaFoundation::MFCreateMemoryBuffer; + + let (w, h) = CAMERA_SIZE; + let rgba = video_code::marker_rgba(FrameMarker::new(StreamKind::Camera, 0, slot), w, h); + let nv12 = video_code::rgba_to_nv12(&rgba, w, h); + let buffer = unsafe { MFCreateMemoryBuffer(nv12.len() as u32) }.ok()?; + buffer.lock_for_write().ok()?.copy_from_slice(&nv12); + unsafe { buffer.SetCurrentLength(nv12.len() as u32) }.ok()?; + #[allow(clippy::arc_with_non_send_sync)] + let buffer = Arc::new(std::sync::Mutex::new(buffer)); + Some(NativeCameraFrame { + buffer, + pixel_format: cap_camera_windows::PixelFormat::NV12, + width: w as u32, + height: h as u32, + is_bottom_up: false, + timestamp: host_timestamp(capture), + }) +} + +#[cfg(target_os = "macos")] fn camera_sample( rgba: &[u8], w: usize, @@ -1014,21 +1116,14 @@ enum Handle { /// reach the recording. async fn capture_setup( capture: CaptureMode, -) -> anyhow::Result<( - ScreenCaptureTarget, - Vec, - SendableShareableContent, -)> { +) -> anyhow::Result<(ScreenCaptureTarget, Vec, Shareable)> { let stimulus_window = find_window(WINDOW_TITLE); let excluded = scap_targets::Window::list() .into_iter() .map(|w| w.id()) .filter(|id| Some(id) != stimulus_window.as_ref()) .collect(); - let shareable = cidre::sc::ShareableContent::current() - .await - .map_err(|e| anyhow::anyhow!("shareable content: {e:?}"))? - .into(); + let shareable = platform::shareable().await?; let target = match capture { CaptureMode::Display => ScreenCaptureTarget::Display { id: scap_targets::Display::primary().id(), @@ -1052,19 +1147,50 @@ fn studio_builder( system_audio: bool, ) -> (studio_recording::ActorBuilder, u32) { let defaults = RecordingDefaults::default(); - let builder = defaults - .apply_to_studio_builder( - studio_recording::Actor::builder(project, target).with_system_audio(system_audio), - camera, - None, - ) - .with_excluded_windows(excluded) + let builder = defaults.apply_to_studio_builder( + studio_recording::Actor::builder(project, target).with_system_audio(system_audio), + camera, + None, + ); + // Windows Graphics Capture records the display without per-window + // exclusion; the stimulus covers it full screen. + #[cfg(target_os = "macos")] + let builder = builder.with_excluded_windows(excluded); + #[cfg(windows)] + let _ = excluded; + let builder = builder .with_keyboard_capture(false) .with_fragmented(fragmented) .with_quality(StudioQuality::Balanced); (builder, defaults.studio_max_fps(camera, None)) } +async fn build_studio( + builder: studio_recording::ActorBuilder, + shareable: &Shareable, +) -> anyhow::Result { + #[cfg(target_os = "macos")] + return builder.build(Some(shareable.clone())).await; + #[cfg(windows)] + { + let () = *shareable; + builder.build().await + } +} + +async fn build_instant( + builder: instant_recording::ActorBuilder, + shareable: &Shareable, +) -> anyhow::Result { + #[cfg(target_os = "macos")] + return builder.build(Some(shareable.clone())).await; + #[cfg(windows)] + { + let () = *shareable; + builder.build().await + } +} + fn stimulus_binary() -> PathBuf { std::env::var_os("CAP_VERIFY_STIMULUS_BIN") .map(PathBuf::from) @@ -1076,7 +1202,7 @@ fn stimulus_binary() -> PathBuf { .parent() .and_then(|deps| deps.parent()) .expect("target profile directory") - .join("cap-verify-stimulus"); + .join(format!("cap-verify-stimulus{}", std::env::consts::EXE_SUFFIX)); assert!( path.exists(), "{} not found: build it first with `cargo build -p cap-verify-stimulus` (same profile), or set CAP_VERIFY_STIMULUS_BIN", @@ -1118,7 +1244,7 @@ struct Recorded { async fn run_case( spec: &Spec, capture: CaptureMode, - shareable: &SendableShareableContent, + shareable: &Shareable, case: &mut Case, ) -> anyhow::Result<()> { let scratch = scratch_root().join(format!( @@ -1919,7 +2045,7 @@ fn scratch_root() -> PathBuf { async fn record( spec: &Spec, capture: CaptureMode, - _preflight: &SendableShareableContent, + _preflight: &Shareable, scratch: &Path, reference: u64, case: &mut Case, @@ -1929,7 +2055,9 @@ async fn record( seconds: spec.seconds, holds: spec.holds.clone(), busy: true, - audio: spec.system_audio && stimulus_plays_system_audio(), + audio: spec.system_audio + && stimulus_plays_system_audio() + && platform::system_audio_output(), warmup: match capture { CaptureMode::Display => 2.0, CaptureMode::Window => 2.0, @@ -1972,6 +2100,10 @@ async fn record( let project = scratch.join("recording.cap"); let defaults = RecordingDefaults::default(); + let system_audio = spec.system_audio && platform::system_audio_output(); + if spec.system_audio && !system_audio { + case.note("no audio output device: recorded without system audio"); + } let gate = spec.gated.then(cap_recording::RecordingStartGate::new); let (handle, max_fps) = match spec.mode { Mode::Studio { fragmented, .. } => { @@ -1981,7 +2113,7 @@ async fn record( excluded, fragmented, camera, - spec.system_audio, + system_audio, ); let mut builder = builder; if spec.mic { @@ -1994,15 +2126,16 @@ async fn record( builder = builder.with_camera_feed(lock.clone()); } ( - Handle::Studio(builder.build(Some(shareable.clone())).await?), + Handle::Studio(build_studio(builder, &shareable).await?), max_fps, ) } Mode::Instant => { let builder = instant_recording::Actor::builder(project.clone(), target) - .with_system_audio(spec.system_audio) - .with_max_output_size(defaults.instant_mode_max_resolution) - .with_excluded_windows(excluded); + .with_system_audio(system_audio) + .with_max_output_size(defaults.instant_mode_max_resolution); + #[cfg(target_os = "macos")] + let builder = builder.with_excluded_windows(excluded); let builder = if spec.mic { builder.with_mic_feed(mic_lock.clone()) } else { @@ -2013,22 +2146,16 @@ async fn record( None => builder, }; ( - Handle::Instant(builder.build(Some(shareable.clone())).await?), + Handle::Instant(build_instant(builder, &shareable).await?), cap_recording::DEFAULT_INSTANT_MODE_FPS, ) } Mode::StudioCrashed => { - let (builder, max_fps) = studio_builder( - project.clone(), - target, - excluded, - true, - false, - spec.system_audio, - ); + let (builder, max_fps) = + studio_builder(project.clone(), target, excluded, true, false, system_audio); let builder = builder.with_mic_feed(mic_lock.clone()); ( - Handle::Studio(builder.build(Some(shareable.clone())).await?), + Handle::Studio(build_studio(builder, &shareable).await?), max_fps, ) } From 3699d10fa5d84383364ccd47393f0ecf1d30a37e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:47:41 +0900 Subject: [PATCH 02/11] test(verify): synthetic microphone registers its stream health; platform priorities in the harness tests --- .github/workflows/verify-recording-windows.yml | 12 ++++++++++++ crates/recording/src/feeds/microphone.rs | 6 ++++++ crates/verify/tests/recording.rs | 12 ++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/verify-recording-windows.yml b/.github/workflows/verify-recording-windows.yml index 9c155f5c9c6..c9ac766d359 100644 --- a/.github/workflows/verify-recording-windows.yml +++ b/.github/workflows/verify-recording-windows.yml @@ -47,6 +47,7 @@ jobs: persist-credentials: false - name: Setup + id: setup uses: ./.github/actions/engine-verify-setup with: target: x86_64-pc-windows-msvc @@ -60,6 +61,17 @@ jobs: cargo test --locked --profile ci-verify -p cap-verify --test recording --no-run echo "build seconds: $(( $(date +%s) - start ))" + - name: Save build cache + if: steps.setup.outputs.build-cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ci-verify/ + key: ${{ steps.setup.outputs.build-cache-key }} + - name: Real-capture recording oracle shell: bash run: cargo test --locked --profile ci-verify -p cap-verify --test recording -- --nocapture diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index df542c01fbf..9c955e5b24a 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -3137,6 +3137,12 @@ pub mod synthetic { done_tx, }), }); + // A recording subscription attaches to the input's stream + // health, which opening a real input registers. + #[cfg(any(target_os = "linux", windows))] + let _ = feed + .stream_health + .insert(STREAM_ID, Arc::new(StreamHealth::new(STREAM_ID))); Self { feed: MicrophoneFeed::spawn(feed), sample_rate, diff --git a/crates/verify/tests/recording.rs b/crates/verify/tests/recording.rs index 9547a70305f..9f430425ac9 100644 --- a/crates/verify/tests/recording.rs +++ b/crates/verify/tests/recording.rs @@ -1752,8 +1752,8 @@ fn test_trace(name: &'static str, handoffs: Vec) -> SourceTrace { }; let state = sched::ThreadState { policy: 2, - base_pri: 97, - cur_pri: 97, + base_pri: sched::REALTIME_PRIORITY, + cur_pri: sched::REALTIME_PRIORITY, max_pri: 63, cpu_us: 0, }; @@ -1801,7 +1801,7 @@ fn simulated_handoffs( built, pushed, admission: admission(slot), - pri: 97, + pri: sched::REALTIME_PRIORITY, } }) .collect() @@ -1883,12 +1883,16 @@ fn source_lateness_is_attributed_to_the_source_or_to_cap() { ); // A source below the real-time band is a harness failure. let mut demoted = overslept.clone(); - demoted.handoffs[100].pri = 31; + demoted.handoffs[100].pri = sched::REALTIME_PRIORITY - 1; assert!(source_scheduling(&demoted, reference, window).is_err()); assert!(source_scheduling(&overslept, reference, window).is_ok()); } #[test] +#[cfg_attr( + not(target_os = "macos"), + ignore = "its expected class is a macOS expectation (recording/instant/screen-mic-system sync-screen-mic)" +)] fn sentinel_freezes_excuse_only_failures_localized_inside_them() { let reference = host_now(); // A sentinel log with a 300 ms gap at 9.9 s and a 60 ms gap at 5 s. From 276749a2b930d24d1243a318861b822ecb782912 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:17:21 +0900 Subject: [PATCH 03/11] test(verify): flip-model stimulus presentation and crash snapshots on Windows, Windows camera pacing xfails --- Cargo.lock | 1 + crates/verify-stimulus/Cargo.toml | 8 +- crates/verify-stimulus/src/main.rs | 190 +++++++++++++++++++++++++---- crates/verify/expectations.toml | 28 ++++- crates/verify/tests/recording.rs | 41 ++++++- 5 files changed, 237 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38bfa138dac..64f83487158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2207,6 +2207,7 @@ dependencies = [ "scap-screencapturekit", "serde_json", "softbuffer", + "windows 0.60.0", "winit", "workspace-hack", ] diff --git a/crates/verify-stimulus/Cargo.toml b/crates/verify-stimulus/Cargo.toml index 8706b394249..54b13281c66 100644 --- a/crates/verify-stimulus/Cargo.toml +++ b/crates/verify-stimulus/Cargo.toml @@ -29,5 +29,11 @@ winit = "0.30" [target.'cfg(windows)'.dependencies] cpal = { workspace = true } -softbuffer = "0.4" winit = "0.30" +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", +] } diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 84f5e033360..5e332962bbf 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -19,7 +19,6 @@ mod app { use std::{ fs::File, io::{BufRead, Write}, - num::NonZeroU32, path::PathBuf, sync::{ Arc, Mutex, @@ -340,7 +339,7 @@ mod app { epoch: Arc, go_at: Option<(Instant, u64)>, window: Option>, - surface: Option, Arc>>, + surface: Option, size: (u32, u32), shown: Option, ready: bool, @@ -415,21 +414,10 @@ mod app { if w == 0 || h == 0 { return; } - if surface - .resize(NonZeroU32::new(w).unwrap(), NonZeroU32::new(h).unwrap()) - .is_err() - { - return; - } - let Ok(mut buffer) = surface.buffer_mut() else { - return; - }; - let (w, h) = (w as usize, h as usize); - let band = if self.spec.busy { h / 8 } else { 0 }; - { - let bytes: &mut [u8] = unsafe { - std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::(), w * h * 4) - }; + let busy = self.spec.busy; + let presented = canvas::present(surface, w, h, |bytes| { + let (w, h) = (w as usize, h as usize); + let band = if busy { h / 8 } else { 0 }; let stride = w * 4; let marker = FrameMarker::new(StreamKind::Screen, 0, index); video_code::draw_marker( @@ -450,8 +438,8 @@ mod app { (salt << 1) | 1, ); } - } - if buffer.present().is_ok() { + }); + if presented { let host = host_now(); if self.shown != Some(index) { self.log.write(&StimulusEvent::Frame { @@ -468,6 +456,166 @@ mod app { } } + /// Where the stimulus draws BGRA pictures and presents them. + #[cfg(target_os = "macos")] + mod canvas { + use super::*; + use std::num::NonZeroU32; + + pub type Canvas = softbuffer::Surface, Arc>; + + pub fn new(window: &Arc) -> Result { + softbuffer::Context::new(window.clone()) + .and_then(|context| softbuffer::Surface::new(&context, window.clone())) + .map_err(|e| e.to_string()) + } + + pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { + if canvas + .resize(NonZeroU32::new(w).unwrap(), NonZeroU32::new(h).unwrap()) + .is_err() + { + return false; + } + let Ok(mut buffer) = canvas.buffer_mut() else { + return false; + }; + let bytes: &mut [u8] = unsafe { + std::slice::from_raw_parts_mut( + buffer.as_mut_ptr().cast::(), + w as usize * h as usize * 4, + ) + }; + paint(bytes); + buffer.present().is_ok() + } + } + + /// A flip-model DXGI swap chain. softbuffer presents on Windows with a + /// GDI blit that the compositor (and so Windows Graphics Capture) can + /// sample half done: recordings showed markers whose top rows were the + /// new picture and bottom rows the old one (run 39000141528). A flip + /// present hands the compositor whole pictures. + #[cfg(windows)] + mod canvas { + use super::*; + use windows::Win32::{ + Foundation::{HMODULE, HWND}, + Graphics::{ + Direct3D::D3D_DRIVER_TYPE_HARDWARE, + Direct3D11::{ + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, + ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, + }, + Dxgi::{ + Common::{ + DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_UNKNOWN, + DXGI_SAMPLE_DESC, + }, + CreateDXGIFactory2, DXGI_CREATE_FACTORY_FLAGS, DXGI_PRESENT, DXGI_SCALING_NONE, + DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_CHAIN_FLAG, DXGI_SWAP_EFFECT_FLIP_DISCARD, + DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIFactory2, IDXGISwapChain1, + }, + }, + }; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + pub struct Canvas { + context: ID3D11DeviceContext, + swap_chain: IDXGISwapChain1, + size: (u32, u32), + pixels: Vec, + } + + pub fn new(window: &Arc) -> Result { + let hwnd = match window.window_handle().map_err(|e| e.to_string())?.as_raw() { + RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut core::ffi::c_void), + other => return Err(format!("not a Win32 window: {other:?}")), + }; + let size = window.inner_size(); + let size = (size.width.max(1), size.height.max(1)); + let mut device: Option = None; + let mut context: Option = None; + unsafe { + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE::default(), + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + } + .map_err(|e| format!("D3D11CreateDevice: {e}"))?; + let device = device.ok_or("no D3D11 device")?; + let context = context.ok_or("no D3D11 context")?; + let factory: IDXGIFactory2 = + unsafe { CreateDXGIFactory2(DXGI_CREATE_FACTORY_FLAGS(0)) } + .map_err(|e| format!("CreateDXGIFactory2: {e}"))?; + let desc = DXGI_SWAP_CHAIN_DESC1 { + Width: size.0, + Height: size.1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount: 2, + Scaling: DXGI_SCALING_NONE, + SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD, + AlphaMode: DXGI_ALPHA_MODE_IGNORE, + ..Default::default() + }; + let swap_chain = + unsafe { factory.CreateSwapChainForHwnd(&device, hwnd, &desc, None, None) } + .map_err(|e| format!("CreateSwapChainForHwnd: {e}"))?; + Ok(Canvas { + context, + swap_chain, + size, + pixels: Vec::new(), + }) + } + + pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { + if canvas.size != (w, h) { + let resized = unsafe { + canvas.swap_chain.ResizeBuffers( + 0, + w, + h, + DXGI_FORMAT_UNKNOWN, + DXGI_SWAP_CHAIN_FLAG(0), + ) + }; + if resized.is_err() { + return false; + } + canvas.size = (w, h); + } + canvas.pixels.resize(w as usize * h as usize * 4, 0); + paint(&mut canvas.pixels); + let Ok(back) = (unsafe { canvas.swap_chain.GetBuffer::(0) }) else { + return false; + }; + unsafe { + canvas.context.UpdateSubresource( + &back, + 0, + None, + canvas.pixels.as_ptr().cast(), + w * 4, + 0, + ); + } + unsafe { canvas.swap_chain.Present(0, DXGI_PRESENT(0)) }.is_ok() + } + } + /// Achromatic 16 px blocks whose levels change every frame. fn noise_band(data: &mut [u8], w: usize, h: usize, index: u32, salt: u64) { let stride = w * 4; @@ -516,9 +664,7 @@ mod app { }; let size = window.inner_size(); self.size = (size.width, size.height); - let surface = softbuffer::Context::new(window.clone()) - .and_then(|context| softbuffer::Surface::new(&context, window.clone())); - match surface { + match canvas::new(&window) { Ok(s) => self.surface = Some(s), Err(e) => { self.error = Some(format!("surface: {e}")); diff --git a/crates/verify/expectations.toml b/crates/verify/expectations.toml index f59ca5d42b9..468c37aa4e1 100644 --- a/crates/verify/expectations.toml +++ b/crates/verify/expectations.toml @@ -320,9 +320,33 @@ link = "" [[xfail]] case = "recording/instant/late-mic" class = "sync-screen-mic" -platforms = ["macos"] +platforms = ["macos", "windows"] bound = { count = 1, offset-ms = [900, 1100] } -reason = "NEW: a microphone whose first sample arrives 1.0 s after the recording starts becomes audio zero, so all audio plays early by its start-up delay minus the screen's (screen late against the mic by 962 to 1040 ms on runners, every run). Bound: the mic's 1.0 s delay within 100 ms" +reason = "NEW: a microphone whose first sample arrives 1.0 s after the recording starts becomes audio zero, so all audio plays early by its start-up delay minus the screen's (screen late against the mic by 962 to 1040 ms on macOS and Windows runners, every run). Bound: the mic's 1.0 s delay within 100 ms" +link = "" + +[[xfail]] +case = "recording/studio-camera/*" +class = "timestamps" +platforms = ["windows"] +bound = { count = 2 } +reason = "NEW: Windows camera encoder frame pacing (WindowsCameraMuxer, crates/recording/src/output_pipeline/win.rs): when no frame arrives within one frame interval it re-encodes the last frame stamped one interval later, the slot the late real frame is stamped for, so the real frame lands on the same timestamp and the muxer moves it by one 90 kHz tick (run 39000141528: 16 duplicated frames, pts 0.266667 then 0.266678). Also in cap-v0.6.0" +link = "" + +[[xfail]] +case = "recording/studio-camera/*" +class = "sync-camera-mic" +platforms = ["windows"] +bound = { count = 1, camera-mic-ms = [-70, 0] } +reason = "NEW: same Windows camera frame pacing: the paced duplicates shift the camera track's frames against their capture times (camera vs mic -38.3 ms, run 39000141528). Bound: two frame periods early" +link = "" + +[[xfail]] +case = "recording/studio-camera/*" +class = "camera-clock" +platforms = ["windows"] +bound = { count = 1, camera-mic-ms = [-70, 0] } +reason = "NEW: same Windows camera frame pacing, past the drift anchor (camera vs mic -38.3 ms, run 39000141528). Bound: two frame periods early" link = "" [[xfail]] diff --git a/crates/verify/tests/recording.rs b/crates/verify/tests/recording.rs index 9f430425ac9..a75ff22d89b 100644 --- a/crates/verify/tests/recording.rs +++ b/crates/verify/tests/recording.rs @@ -2184,12 +2184,7 @@ async fn record( let crashed = if spec.mode == Mode::StudioCrashed { let snapshot = scratch.join("crash-snapshot.cap"); let snapshot_started = host_now(); - let status = std::process::Command::new("cp") - .arg("-cR") - .arg(&project) - .arg(&snapshot) - .status()?; - anyhow::ensure!(status.success(), "snapshot of the live recording failed"); + snapshot_live_project(&project, &snapshot)?; case.note(format!( "crash snapshot at {:.3}s, cloned in {:.3}s", host_secs(snapshot_started, reference), @@ -2301,6 +2296,40 @@ async fn record( /// pipeline still in flight. const CRASH_LOSS_BUDGET: f64 = 3.0; +/// Copies the live project as a crash would leave it on disk: APFS clones on +/// macOS; a file-by-file copy on Windows, where the recorder's files are open +/// for shared reading. +fn snapshot_live_project(project: &Path, snapshot: &Path) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("cp") + .arg("-cR") + .arg(project) + .arg(snapshot) + .status()?; + anyhow::ensure!(status.success(), "snapshot of the live recording failed"); + Ok(()) + } + #[cfg(windows)] + { + fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) + } + copy_dir(project, snapshot) + .map_err(|e| anyhow::anyhow!("snapshot of the live recording failed: {e}")) + } +} + async fn finalize_instant( recording: &mut instant_recording::CompletedRecording, ) -> anyhow::Result<()> { From 39ca3fd38a7bc51c1ca99c1f35c6b79e814437c6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:32:38 +0900 Subject: [PATCH 04/11] test(verify): stimulus swap chain from the device's DXGI factory --- crates/verify-stimulus/src/main.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 5e332962bbf..21ecd966f3e 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -512,12 +512,13 @@ mod app { DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_UNKNOWN, DXGI_SAMPLE_DESC, }, - CreateDXGIFactory2, DXGI_CREATE_FACTORY_FLAGS, DXGI_PRESENT, DXGI_SCALING_NONE, - DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_CHAIN_FLAG, DXGI_SWAP_EFFECT_FLIP_DISCARD, - DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIFactory2, IDXGISwapChain1, + DXGI_PRESENT, DXGI_SCALING_NONE, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_CHAIN_FLAG, + DXGI_SWAP_EFFECT_FLIP_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, + IDXGIFactory2, IDXGISwapChain1, }, }, }; + use windows::core::Interface; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; pub struct Canvas { @@ -552,9 +553,14 @@ mod app { .map_err(|e| format!("D3D11CreateDevice: {e}"))?; let device = device.ok_or("no D3D11 device")?; let context = context.ok_or("no D3D11 context")?; - let factory: IDXGIFactory2 = - unsafe { CreateDXGIFactory2(DXGI_CREATE_FACTORY_FLAGS(0)) } - .map_err(|e| format!("CreateDXGIFactory2: {e}"))?; + // The swap chain must come from the factory that made the device. + let factory: IDXGIFactory2 = unsafe { + device + .cast::() + .and_then(|dxgi| dxgi.GetAdapter()) + .and_then(|adapter| adapter.GetParent()) + } + .map_err(|e| format!("device factory: {e}"))?; let desc = DXGI_SWAP_CHAIN_DESC1 { Width: size.0, Height: size.1, From cca5cc800416aecb98b4aa6549365dfbc61b6d3e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:38:55 +0900 Subject: [PATCH 05/11] test(verify): fall back from flip to blit swap chains in the Windows stimulus, log each failure --- crates/verify-stimulus/src/main.rs | 60 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 21ecd966f3e..2aa98261789 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -512,8 +512,9 @@ mod app { DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_UNKNOWN, DXGI_SAMPLE_DESC, }, - DXGI_PRESENT, DXGI_SCALING_NONE, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_CHAIN_FLAG, - DXGI_SWAP_EFFECT_FLIP_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, + DXGI_PRESENT, DXGI_SCALING_NONE, DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1, + DXGI_SWAP_CHAIN_FLAG, DXGI_SWAP_EFFECT_DISCARD, DXGI_SWAP_EFFECT_FLIP_DISCARD, + DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, IDXGIFactory2, IDXGISwapChain1, }, }, @@ -561,24 +562,43 @@ mod app { .and_then(|adapter| adapter.GetParent()) } .map_err(|e| format!("device factory: {e}"))?; - let desc = DXGI_SWAP_CHAIN_DESC1 { - Width: size.0, - Height: size.1, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, - BufferCount: 2, - Scaling: DXGI_SCALING_NONE, - SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD, - AlphaMode: DXGI_ALPHA_MODE_IGNORE, - ..Default::default() - }; - let swap_chain = - unsafe { factory.CreateSwapChainForHwnd(&device, hwnd, &desc, None, None) } - .map_err(|e| format!("CreateSwapChainForHwnd: {e}"))?; + // Flip model first; the blit model is the fallback where the + // display driver has no flip support. Both present through D3D, + // which the compositor reads only once the copy has completed. + let attempts = [ + (DXGI_SWAP_EFFECT_FLIP_DISCARD, 2, DXGI_SCALING_NONE), + (DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, 2, DXGI_SCALING_STRETCH), + (DXGI_SWAP_EFFECT_DISCARD, 1, DXGI_SCALING_STRETCH), + ]; + let mut errors = Vec::new(); + let mut created = None; + for (effect, buffers, scaling) in attempts { + let desc = DXGI_SWAP_CHAIN_DESC1 { + Width: size.0, + Height: size.1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount: buffers, + Scaling: scaling, + SwapEffect: effect, + AlphaMode: DXGI_ALPHA_MODE_IGNORE, + ..Default::default() + }; + match unsafe { factory.CreateSwapChainForHwnd(&device, hwnd, &desc, None, None) } { + Ok(swap_chain) => { + eprintln!("stimulus swap chain: {effect:?}, {buffers} buffers"); + created = Some(swap_chain); + break; + } + Err(e) => errors.push(format!("{effect:?}: {e}")), + } + } + let swap_chain = created + .ok_or_else(|| format!("CreateSwapChainForHwnd: {}", errors.join("; ")))?; Ok(Canvas { context, swap_chain, From 917a2cd2dd4589a96a15539f72f0bdf016208983 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:44:36 +0900 Subject: [PATCH 06/11] ci(verify): stimulus smoke step in the Windows bring-up --- .github/workflows/verify-recording-windows.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/verify-recording-windows.yml b/.github/workflows/verify-recording-windows.yml index c9ac766d359..9e9051aec00 100644 --- a/.github/workflows/verify-recording-windows.yml +++ b/.github/workflows/verify-recording-windows.yml @@ -72,6 +72,15 @@ jobs: target/ci-verify/ key: ${{ steps.setup.outputs.build-cache-key }} + - name: Stimulus smoke + continue-on-error: true + shell: bash + run: | + spec='{"fps":15.0,"seconds":2.0,"holds":[],"busy":true,"audio":false,"warmup":1.0,"audio_period":0.5,"audio_origin":0,"window":null}' + (sleep 5; echo go; sleep 4; echo quit) | target/ci-verify/cap-verify-stimulus.exe "$spec" "$RUNNER_TEMP/stimulus.jsonl" + echo "exit status $?" + head -c 3000 "$RUNNER_TEMP/stimulus.jsonl" || true + - name: Real-capture recording oracle shell: bash run: cargo test --locked --profile ci-verify -p cap-verify --test recording -- --nocapture From 21c00deccf9c6bb8fc6894e1c30b21657852622a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:49:36 +0900 Subject: [PATCH 07/11] test(verify): Windows stimulus blits right after a composition pass instead of a swap chain --- Cargo.lock | 1 - crates/verify-stimulus/Cargo.toml | 8 +- crates/verify-stimulus/src/main.rs | 174 ++++------------------------- 3 files changed, 22 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64f83487158..38bfa138dac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2207,7 +2207,6 @@ dependencies = [ "scap-screencapturekit", "serde_json", "softbuffer", - "windows 0.60.0", "winit", "workspace-hack", ] diff --git a/crates/verify-stimulus/Cargo.toml b/crates/verify-stimulus/Cargo.toml index 54b13281c66..8706b394249 100644 --- a/crates/verify-stimulus/Cargo.toml +++ b/crates/verify-stimulus/Cargo.toml @@ -29,11 +29,5 @@ winit = "0.30" [target.'cfg(windows)'.dependencies] cpal = { workspace = true } +softbuffer = "0.4" winit = "0.30" -windows = { workspace = true, features = [ - "Win32_Foundation", - "Win32_Graphics_Direct3D", - "Win32_Graphics_Direct3D11", - "Win32_Graphics_Dxgi", - "Win32_Graphics_Dxgi_Common", -] } diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 2aa98261789..56744648f2e 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -456,8 +456,16 @@ mod app { } } - /// Where the stimulus draws BGRA pictures and presents them. - #[cfg(target_os = "macos")] + /// Where the stimulus draws BGRA pictures and presents them. softbuffer + /// presents on Windows with a GDI blit into the window's redirection + /// surface, which the compositor (and so Windows Graphics Capture) can + /// sample half done: recordings showed markers whose top rows were the new + /// picture and bottom rows the old one (run 39000141528). On Windows the + /// blit starts right after a composition pass (`DwmFlush`), so it is done + /// long before the next one. A DXGI swap chain was not an option on hosted + /// runners: their Hyper-V display rejected a flip-model swap chain + /// (DXGI_ERROR_INVALID_CALL, run 39002964457) and creating the other + /// swap effects crashed the stimulus. mod canvas { use super::*; use std::num::NonZeroU32; @@ -470,6 +478,15 @@ mod app { .map_err(|e| e.to_string()) } + #[cfg(windows)] + fn after_composition() { + #[link(name = "dwmapi")] + unsafe extern "system" { + fn DwmFlush() -> i32; + } + unsafe { DwmFlush() }; + } + pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { if canvas .resize(NonZeroU32::new(w).unwrap(), NonZeroU32::new(h).unwrap()) @@ -487,161 +504,12 @@ mod app { ) }; paint(bytes); + #[cfg(windows)] + after_composition(); buffer.present().is_ok() } } - /// A flip-model DXGI swap chain. softbuffer presents on Windows with a - /// GDI blit that the compositor (and so Windows Graphics Capture) can - /// sample half done: recordings showed markers whose top rows were the - /// new picture and bottom rows the old one (run 39000141528). A flip - /// present hands the compositor whole pictures. - #[cfg(windows)] - mod canvas { - use super::*; - use windows::Win32::{ - Foundation::{HMODULE, HWND}, - Graphics::{ - Direct3D::D3D_DRIVER_TYPE_HARDWARE, - Direct3D11::{ - D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, - ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, - }, - Dxgi::{ - Common::{ - DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_UNKNOWN, - DXGI_SAMPLE_DESC, - }, - DXGI_PRESENT, DXGI_SCALING_NONE, DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1, - DXGI_SWAP_CHAIN_FLAG, DXGI_SWAP_EFFECT_DISCARD, DXGI_SWAP_EFFECT_FLIP_DISCARD, - DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, - IDXGIFactory2, IDXGISwapChain1, - }, - }, - }; - use windows::core::Interface; - use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; - - pub struct Canvas { - context: ID3D11DeviceContext, - swap_chain: IDXGISwapChain1, - size: (u32, u32), - pixels: Vec, - } - - pub fn new(window: &Arc) -> Result { - let hwnd = match window.window_handle().map_err(|e| e.to_string())?.as_raw() { - RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut core::ffi::c_void), - other => return Err(format!("not a Win32 window: {other:?}")), - }; - let size = window.inner_size(); - let size = (size.width.max(1), size.height.max(1)); - let mut device: Option = None; - let mut context: Option = None; - unsafe { - D3D11CreateDevice( - None, - D3D_DRIVER_TYPE_HARDWARE, - HMODULE::default(), - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - None, - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - } - .map_err(|e| format!("D3D11CreateDevice: {e}"))?; - let device = device.ok_or("no D3D11 device")?; - let context = context.ok_or("no D3D11 context")?; - // The swap chain must come from the factory that made the device. - let factory: IDXGIFactory2 = unsafe { - device - .cast::() - .and_then(|dxgi| dxgi.GetAdapter()) - .and_then(|adapter| adapter.GetParent()) - } - .map_err(|e| format!("device factory: {e}"))?; - // Flip model first; the blit model is the fallback where the - // display driver has no flip support. Both present through D3D, - // which the compositor reads only once the copy has completed. - let attempts = [ - (DXGI_SWAP_EFFECT_FLIP_DISCARD, 2, DXGI_SCALING_NONE), - (DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, 2, DXGI_SCALING_STRETCH), - (DXGI_SWAP_EFFECT_DISCARD, 1, DXGI_SCALING_STRETCH), - ]; - let mut errors = Vec::new(); - let mut created = None; - for (effect, buffers, scaling) in attempts { - let desc = DXGI_SWAP_CHAIN_DESC1 { - Width: size.0, - Height: size.1, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, - BufferCount: buffers, - Scaling: scaling, - SwapEffect: effect, - AlphaMode: DXGI_ALPHA_MODE_IGNORE, - ..Default::default() - }; - match unsafe { factory.CreateSwapChainForHwnd(&device, hwnd, &desc, None, None) } { - Ok(swap_chain) => { - eprintln!("stimulus swap chain: {effect:?}, {buffers} buffers"); - created = Some(swap_chain); - break; - } - Err(e) => errors.push(format!("{effect:?}: {e}")), - } - } - let swap_chain = created - .ok_or_else(|| format!("CreateSwapChainForHwnd: {}", errors.join("; ")))?; - Ok(Canvas { - context, - swap_chain, - size, - pixels: Vec::new(), - }) - } - - pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { - if canvas.size != (w, h) { - let resized = unsafe { - canvas.swap_chain.ResizeBuffers( - 0, - w, - h, - DXGI_FORMAT_UNKNOWN, - DXGI_SWAP_CHAIN_FLAG(0), - ) - }; - if resized.is_err() { - return false; - } - canvas.size = (w, h); - } - canvas.pixels.resize(w as usize * h as usize * 4, 0); - paint(&mut canvas.pixels); - let Ok(back) = (unsafe { canvas.swap_chain.GetBuffer::(0) }) else { - return false; - }; - unsafe { - canvas.context.UpdateSubresource( - &back, - 0, - None, - canvas.pixels.as_ptr().cast(), - w * 4, - 0, - ); - } - unsafe { canvas.swap_chain.Present(0, DXGI_PRESENT(0)) }.is_ok() - } - } - /// Achromatic 16 px blocks whose levels change every frame. fn noise_band(data: &mut [u8], w: usize, h: usize, index: u32, salt: u64) { let stride = w * 4; From a3ff9bad91b2ddf1bd5105cc502f8e01f2aeba05 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:59:24 +0900 Subject: [PATCH 08/11] test(verify): layered-window stimulus on Windows; Windows camera sync entries flaky --- Cargo.lock | 1 + crates/verify-stimulus/Cargo.toml | 6 +- crates/verify-stimulus/src/main.rs | 137 ++++++++++++++++++++++++----- crates/verify/expectations.toml | 6 +- 4 files changed, 126 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38bfa138dac..64f83487158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2207,6 +2207,7 @@ dependencies = [ "scap-screencapturekit", "serde_json", "softbuffer", + "windows 0.60.0", "winit", "workspace-hack", ] diff --git a/crates/verify-stimulus/Cargo.toml b/crates/verify-stimulus/Cargo.toml index 8706b394249..cf52f7921f1 100644 --- a/crates/verify-stimulus/Cargo.toml +++ b/crates/verify-stimulus/Cargo.toml @@ -29,5 +29,9 @@ winit = "0.30" [target.'cfg(windows)'.dependencies] cpal = { workspace = true } -softbuffer = "0.4" winit = "0.30" +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 56744648f2e..8faf665aac1 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -456,16 +456,8 @@ mod app { } } - /// Where the stimulus draws BGRA pictures and presents them. softbuffer - /// presents on Windows with a GDI blit into the window's redirection - /// surface, which the compositor (and so Windows Graphics Capture) can - /// sample half done: recordings showed markers whose top rows were the new - /// picture and bottom rows the old one (run 39000141528). On Windows the - /// blit starts right after a composition pass (`DwmFlush`), so it is done - /// long before the next one. A DXGI swap chain was not an option on hosted - /// runners: their Hyper-V display rejected a flip-model swap chain - /// (DXGI_ERROR_INVALID_CALL, run 39002964457) and creating the other - /// swap effects crashed the stimulus. + /// Where the stimulus draws BGRA pictures and presents them. + #[cfg(target_os = "macos")] mod canvas { use super::*; use std::num::NonZeroU32; @@ -478,15 +470,6 @@ mod app { .map_err(|e| e.to_string()) } - #[cfg(windows)] - fn after_composition() { - #[link(name = "dwmapi")] - unsafe extern "system" { - fn DwmFlush() -> i32; - } - unsafe { DwmFlush() }; - } - pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { if canvas .resize(NonZeroU32::new(w).unwrap(), NonZeroU32::new(h).unwrap()) @@ -504,12 +487,124 @@ mod app { ) }; paint(bytes); - #[cfg(windows)] - after_composition(); buffer.present().is_ok() } } + /// A layered window updated with `UpdateLayeredWindow`, which hands the + /// compositor a whole picture. A GDI blit into the window's redirection + /// surface (softbuffer) could be composited, and so captured by Windows + /// Graphics Capture, half done: recordings showed markers whose top rows + /// were the new picture and bottom rows the old one (run 39000141528), + /// also when the blit started right after a composition pass (run + /// 39004015045). Hosted runners' Hyper-V display rejected a flip-model + /// DXGI swap chain (run 39002964457). + #[cfg(windows)] + mod canvas { + use super::*; + use windows::Win32::{ + Foundation::{COLORREF, HWND, POINT, SIZE}, + Graphics::Gdi::{ + BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleDC, CreateDIBSection, + DIB_RGB_COLORS, DeleteObject, GetDC, HBITMAP, HDC, SelectObject, + }, + UI::WindowsAndMessaging::{ + GWL_EXSTYLE, GetWindowLongPtrW, SetWindowLongPtrW, ULW_OPAQUE, UpdateLayeredWindow, + WS_EX_LAYERED, + }, + }; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + pub struct Canvas { + hwnd: HWND, + screen: HDC, + memory: HDC, + bitmap: Option, + bits: *mut u8, + size: (u32, u32), + } + + pub fn new(window: &Arc) -> Result { + let hwnd = match window.window_handle().map_err(|e| e.to_string())?.as_raw() { + RawWindowHandle::Win32(handle) => HWND(handle.hwnd.get() as *mut core::ffi::c_void), + other => return Err(format!("not a Win32 window: {other:?}")), + }; + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED.0 as isize); + let screen = GetDC(None); + let memory = CreateCompatibleDC(Some(screen)); + Ok(Canvas { + hwnd, + screen, + memory, + bitmap: None, + bits: std::ptr::null_mut(), + size: (0, 0), + }) + } + } + + pub fn present(canvas: &mut Canvas, w: u32, h: u32, paint: impl FnOnce(&mut [u8])) -> bool { + if canvas.size != (w, h) || canvas.bitmap.is_none() { + let info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: w as i32, + biHeight: -(h as i32), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits = std::ptr::null_mut(); + let Ok(bitmap) = (unsafe { + CreateDIBSection( + Some(canvas.memory), + &info, + DIB_RGB_COLORS, + &mut bits, + None, + 0, + ) + }) else { + return false; + }; + unsafe { + SelectObject(canvas.memory, bitmap.into()); + if let Some(old) = canvas.bitmap.replace(bitmap) { + let _ = DeleteObject(old.into()); + } + } + canvas.bits = bits.cast(); + canvas.size = (w, h); + } + let len = w as usize * h as usize * 4; + paint(unsafe { std::slice::from_raw_parts_mut(canvas.bits, len) }); + let size = SIZE { + cx: w as i32, + cy: h as i32, + }; + let origin = POINT { x: 0, y: 0 }; + unsafe { + UpdateLayeredWindow( + canvas.hwnd, + Some(canvas.screen), + None, + Some(&size), + Some(canvas.memory), + Some(&origin), + COLORREF(0), + None, + ULW_OPAQUE, + ) + } + .is_ok() + } + } + /// Achromatic 16 px blocks whose levels change every frame. fn noise_band(data: &mut [u8], w: usize, h: usize, index: u32, salt: u64) { let stride = w * 4; diff --git a/crates/verify/expectations.toml b/crates/verify/expectations.toml index 468c37aa4e1..6582efd26f2 100644 --- a/crates/verify/expectations.toml +++ b/crates/verify/expectations.toml @@ -337,16 +337,18 @@ link = "" case = "recording/studio-camera/*" class = "sync-camera-mic" platforms = ["windows"] +flaky = true bound = { count = 1, camera-mic-ms = [-70, 0] } -reason = "NEW: same Windows camera frame pacing: the paced duplicates shift the camera track's frames against their capture times (camera vs mic -38.3 ms, run 39000141528). Bound: two frame periods early" +reason = "NEW: same Windows camera frame pacing: the paced duplicates shift the camera track's frames against their capture times (camera vs mic -38.3 ms in run 39000141528, -18.4 ms, inside the limit, in run 39004015045). Bound: two frame periods early" link = "" [[xfail]] case = "recording/studio-camera/*" class = "camera-clock" platforms = ["windows"] +flaky = true bound = { count = 1, camera-mic-ms = [-70, 0] } -reason = "NEW: same Windows camera frame pacing, past the drift anchor (camera vs mic -38.3 ms, run 39000141528). Bound: two frame periods early" +reason = "NEW: same Windows camera frame pacing, past the drift anchor (camera vs mic -38.3 ms in run 39000141528, -18.4 ms in run 39004015045). Bound: two frame periods early" link = "" [[xfail]] From 30dc9b9b8138d6d67c3a4fa54afb3f68f8c82177 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:16:07 +0900 Subject: [PATCH 09/11] test(verify): copy crash snapshots without cp -c on Windows --- crates/verify/tests/recording.rs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/verify/tests/recording.rs b/crates/verify/tests/recording.rs index a75ff22d89b..8aade6cd4b0 100644 --- a/crates/verify/tests/recording.rs +++ b/crates/verify/tests/recording.rs @@ -2184,7 +2184,8 @@ async fn record( let crashed = if spec.mode == Mode::StudioCrashed { let snapshot = scratch.join("crash-snapshot.cap"); let snapshot_started = host_now(); - snapshot_live_project(&project, &snapshot)?; + copy_project(&project, &snapshot) + .map_err(|e| anyhow::anyhow!("snapshot of the live recording failed: {e:#}"))?; case.note(format!( "crash snapshot at {:.3}s, cloned in {:.3}s", host_secs(snapshot_started, reference), @@ -2250,15 +2251,9 @@ async fn record( let (project_path, loss_budget) = match crashed { Some(snapshot) => { let recovery_copy = scratch.join("crashed.cap"); - let status = std::process::Command::new("cp") - .arg("-cR") - .arg(&snapshot) - .arg(&recovery_copy) - .status()?; - anyhow::ensure!( - status.success(), - "copy of the preserved crash snapshot failed" - ); + copy_project(&snapshot, &recovery_copy).map_err(|e| { + anyhow::anyhow!("copy of the preserved crash snapshot failed: {e:#}") + })?; let started = std::time::Instant::now(); let recovered = tokio::task::spawn_blocking(move || { let incomplete = RecoveryManager::inspect_recording(&recovery_copy) @@ -2296,18 +2291,18 @@ async fn record( /// pipeline still in flight. const CRASH_LOSS_BUDGET: f64 = 3.0; -/// Copies the live project as a crash would leave it on disk: APFS clones on -/// macOS; a file-by-file copy on Windows, where the recorder's files are open -/// for shared reading. -fn snapshot_live_project(project: &Path, snapshot: &Path) -> anyhow::Result<()> { +/// Copies a project directory: APFS clones on macOS; a file-by-file copy on +/// Windows, where a live recorder's files are open for shared reading. Taken +/// of a live project, the copy is what a crash leaves on disk. +fn copy_project(from: &Path, to: &Path) -> anyhow::Result<()> { #[cfg(target_os = "macos")] { let status = std::process::Command::new("cp") .arg("-cR") - .arg(project) - .arg(snapshot) + .arg(from) + .arg(to) .status()?; - anyhow::ensure!(status.success(), "snapshot of the live recording failed"); + anyhow::ensure!(status.success(), "cp -cR failed"); Ok(()) } #[cfg(windows)] @@ -2325,8 +2320,7 @@ fn snapshot_live_project(project: &Path, snapshot: &Path) -> anyhow::Result<()> } Ok(()) } - copy_dir(project, snapshot) - .map_err(|e| anyhow::anyhow!("snapshot of the live recording failed: {e}")) + copy_dir(from, to).map_err(Into::into) } } From af4d265a027131bc0980ecb73d67b8c66bc5d3d1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:24:23 +0900 Subject: [PATCH 10/11] ci(verify): real capture on GitHub-hosted Windows in Verify recording --- .../workflows/verify-recording-windows.yml | 101 ------------------ .github/workflows/verify-recording.yml | 89 ++++++++++++++- 2 files changed, 84 insertions(+), 106 deletions(-) delete mode 100644 .github/workflows/verify-recording-windows.yml diff --git a/.github/workflows/verify-recording-windows.yml b/.github/workflows/verify-recording-windows.yml deleted file mode 100644 index 9e9051aec00..00000000000 --- a/.github/workflows/verify-recording-windows.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Verify recording (Windows, bring-up) - -# Bring-up of the real-capture oracle (crates/verify/tests/recording.rs) on -# GitHub-hosted Windows: its jobs run in the runner's interactive console -# session, where Windows Graphics Capture records the desktop. Blacksmith -# Windows VMs run jobs in session 0, which has no desktop to capture -# (probe runs 38997642473, 38997747111). - -on: - push: - branches: [ci/windows-real-capture] - workflow_dispatch: - inputs: - tier: - description: "fast or full" - default: "fast" - case_filter: - description: "Case globs, comma-separated (empty runs all)" - default: "" - -concurrency: - group: verify-recording-windows-${{ github.ref_name }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - real-capture: - runs-on: windows-2022 - timeout-minutes: 90 - env: - CAP_VERIFY_CAPTURE: display - CAP_VERIFY_REQUIRE_CAPTURE: "1" - CAP_VERIFY_TIER: ${{ inputs.tier || 'fast' }} - CAP_VERIFY_CASE: ${{ inputs.case_filter || '' }} - CAP_VERIFY_SCRATCH: ${{ github.workspace }}/verify-scratch - CAP_VERIFY_REPORT_DIR: ${{ github.workspace }}/verify-report - CAP_VERIFY_DUMP: ${{ github.workspace }}/verify-dump - CAP_VERIFY_HOST: github-hosted - # No audio endpoints on hosted Windows: nothing plays or loops back - # system audio. - CAP_VERIFY_STIMULUS_AUDIO: "0" - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup - id: setup - uses: ./.github/actions/engine-verify-setup - with: - target: x86_64-pc-windows-msvc - tier: fast - - - name: Build - shell: bash - run: | - start=$(date +%s) - cargo build --locked --profile ci-verify -p cap-verify-stimulus - cargo test --locked --profile ci-verify -p cap-verify --test recording --no-run - echo "build seconds: $(( $(date +%s) - start ))" - - - name: Save build cache - if: steps.setup.outputs.build-cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ci-verify/ - key: ${{ steps.setup.outputs.build-cache-key }} - - - name: Stimulus smoke - continue-on-error: true - shell: bash - run: | - spec='{"fps":15.0,"seconds":2.0,"holds":[],"busy":true,"audio":false,"warmup":1.0,"audio_period":0.5,"audio_origin":0,"window":null}' - (sleep 5; echo go; sleep 4; echo quit) | target/ci-verify/cap-verify-stimulus.exe "$spec" "$RUNNER_TEMP/stimulus.jsonl" - echo "exit status $?" - head -c 3000 "$RUNNER_TEMP/stimulus.jsonl" || true - - - name: Real-capture recording oracle - shell: bash - run: cargo test --locked --profile ci-verify -p cap-verify --test recording -- --nocapture - - - name: Summary - if: always() - shell: bash - run: python scripts/verify/summary.py --title "real capture windows-2022" "$CAP_VERIFY_REPORT_DIR" >> "$GITHUB_STEP_SUMMARY" || true - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: always() - with: - name: verify-recording-windows-2022 - path: | - ${{ github.workspace }}/verify-report - ${{ github.workspace }}/verify-dump - ${{ github.workspace }}/verify-scratch - if-no-files-found: ignore diff --git a/.github/workflows/verify-recording.yml b/.github/workflows/verify-recording.yml index 28a0af88722..7962dc209ed 100644 --- a/.github/workflows/verify-recording.yml +++ b/.github/workflows/verify-recording.yml @@ -1,8 +1,9 @@ name: Verify recording (real capture) # Records a marker stimulus through Cap's real Studio and Instant pipelines -# (ScreenCaptureKit + VideoToolbox) on macOS runners and checks every frame -# and audio marker against the host-clock truth. See +# on macOS (ScreenCaptureKit + VideoToolbox) and GitHub-hosted Windows +# (Windows Graphics Capture + Media Foundation) and checks every frame and +# audio marker against the host-clock truth. See # crates/verify/tests/recording.rs. # # Every pull request gets a "Verify recording" verdict (the job to require): @@ -328,11 +329,87 @@ jobs: ${{ github.workspace }}/verify-scratch if-no-files-found: ignore + # GitHub-hosted Windows runs jobs in the runner's interactive console + # session, where Windows Graphics Capture records the desktop; Blacksmith + # Windows VMs run jobs in session 0, which has no desktop (probe runs + # 38997642473, 38997747111). Hosted Windows has no audio endpoints, so the + # stimulus stays silent and system audio is not recorded. + real-capture-windows: + name: real-capture (windows-2022) + needs: scope + if: needs.scope.outputs.run == 'true' + runs-on: windows-2022 + timeout-minutes: 60 + env: + CAP_VERIFY_CAPTURE: display + CAP_VERIFY_REQUIRE_CAPTURE: "1" + CAP_VERIFY_TIER: ${{ needs.scope.outputs.tier }} + CAP_VERIFY_REPEAT: ${{ inputs.repeat || (github.event_name == 'schedule' && '3') || '1' }} + CAP_VERIFY_CASE: ${{ inputs.case_filter || '' }} + CAP_VERIFY_KEEP: ${{ inputs.keep_artifacts && '1' || '' }} + CAP_VERIFY_SCRATCH: ${{ github.workspace }}/verify-scratch + CAP_VERIFY_REPORT_DIR: ${{ github.workspace }}/verify-report + CAP_VERIFY_DUMP: ${{ github.workspace }}/verify-dump + CAP_VERIFY_HOST: github-hosted + CAP_VERIFY_REQUIRE_SUPPORTED: "1" + CAP_VERIFY_UNSUPPORTED_ALLOW: recording/instant/system-only + CAP_VERIFY_STIMULUS_AUDIO: "0" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup + id: setup + uses: ./.github/actions/engine-verify-setup + with: + target: x86_64-pc-windows-msvc + tier: fast + + - name: Build + shell: bash + run: | + start=$(date +%s) + cargo build --locked --profile ci-verify -p cap-verify-stimulus + cargo test --locked --profile ci-verify -p cap-verify --test recording --no-run + echo "build seconds: $(( $(date +%s) - start ))" + + - name: Save build cache + if: steps.setup.outputs.build-cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ci-verify/ + key: ${{ steps.setup.outputs.build-cache-key }} + + - name: Real-capture recording oracle + shell: bash + run: cargo test --locked --profile ci-verify -p cap-verify --test recording -- --nocapture + + - name: Summary + if: always() + shell: bash + run: python scripts/verify/summary.py --title "real capture windows-2022 ${{ needs.scope.outputs.tier }}" "$CAP_VERIFY_REPORT_DIR" >> "$GITHUB_STEP_SUMMARY" || true + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: verify-recording-windows-2022 + path: | + ${{ github.workspace }}/verify-report + ${{ github.workspace }}/verify-dump + ${{ github.workspace }}/verify-scratch + if-no-files-found: ignore + # The check to require: green when the change is out of scope or every # planned runner passed with at least the tier's case count. gate: name: Verify recording - needs: [scope, real-capture] + needs: [scope, real-capture, real-capture-windows] if: always() runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 @@ -353,6 +430,7 @@ jobs: SCOPE: ${{ needs.scope.result }} RUN: ${{ needs.scope.outputs.run }} CAPTURE: ${{ needs.real-capture.result }} + CAPTURE_WINDOWS: ${{ needs.real-capture-windows.result }} TIER: ${{ needs.scope.outputs.tier }} RUNNERS: ${{ needs.scope.outputs.runners }} CASE_FILTER: ${{ inputs.case_filter }} @@ -365,10 +443,11 @@ jobs: echo "No recording code changed: nothing to verify." | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi - if [[ "$CAPTURE" != success ]]; then - echo "real capture $CAPTURE ($TIER tier): see the runner jobs" >&2 + if [[ "$CAPTURE" != success || "$CAPTURE_WINDOWS" != success ]]; then + echo "real capture: macOS $CAPTURE, Windows $CAPTURE_WINDOWS ($TIER tier): see the runner jobs" >&2 exit 1 fi + RUNNERS=$(python3 -c 'import json, sys; print(json.dumps(json.loads(sys.argv[1]) + ["windows-2022"]))' "$RUNNERS") if [[ -n "$CASE_FILTER" ]]; then echo "case filter set: executed-case floor not applied" exit 0 From efdf0251cda7843c51bbaec623673f40742a0ef4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:49:21 +0900 Subject: [PATCH 11/11] fix(verify): Windows stimulus audio uses the WASAPI timestamp, snapshots survive fragment renames, required system audio fails without an output device --- crates/verify-stimulus/src/main.rs | 9 +++--- crates/verify/src/sched_windows.rs | 25 ++++++++------- crates/verify/tests/recording.rs | 51 +++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/crates/verify-stimulus/src/main.rs b/crates/verify-stimulus/src/main.rs index 8faf665aac1..44ff9124da5 100644 --- a/crates/verify-stimulus/src/main.rs +++ b/crates/verify-stimulus/src/main.rs @@ -929,12 +929,13 @@ mod app { move |data: &mut [f32], info: &cpal::OutputCallbackInfo| { #[cfg(target_os = "macos")] let callback_host = info.timestamp().callback.as_host_time(); - // cpal's WASAPI instants have no host-clock accessor; the - // callback runs at the start of the buffer it fills. + // WASAPI stream instants are QueryPerformanceCounter + // readings in 100 ns units. #[cfg(windows)] let callback_host = { - let _ = info; - host_now() + use cpal::host::wasapi::StreamInstantExt; + let units = i128::from(info.timestamp().callback.as_performance_counter()); + (units * i128::from(cap_verify::sched::qpc_frequency()) / 10_000_000) as u64 }; state.fill(data, callback_host); }, diff --git a/crates/verify/src/sched_windows.rs b/crates/verify/src/sched_windows.rs index f7cb874804a..945967c8144 100644 --- a/crates/verify/src/sched_windows.rs +++ b/crates/verify/src/sched_windows.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; -/// The requested wake period and work budget, in milliseconds. +/// Milliseconds. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Constraint { pub period_ms: f64, @@ -32,7 +32,6 @@ pub struct ThreadState { /// `THREAD_PRIORITY_TIME_CRITICAL`. pub const REALTIME_PRIORITY: i32 = 15; -/// The outcome of [`request_realtime`]. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Realtime { pub requested: Constraint, @@ -47,7 +46,6 @@ pub struct Realtime { } impl Realtime { - /// `Ok` when the thread runs at the time-critical priority. pub fn obtained(&self) -> Result<(), String> { if self.policy_status != 0 { return Err(format!( @@ -59,7 +57,6 @@ impl Realtime { } } -/// `Ok` when the thread state is at the time-critical priority. pub fn state_is_realtime(state: Option) -> Result<(), String> { match state { Some(state) if state.base_pri >= REALTIME_PRIORITY => Ok(()), @@ -117,19 +114,16 @@ unsafe extern "system" { fn timeBeginPeriod(period: u32) -> u32; } -/// The calling thread's id. pub fn current_thread() -> u32 { unsafe { GetCurrentThreadId() } } -/// QueryPerformanceCounter ticks. pub fn qpc_now() -> u64 { let mut count = 0i64; unsafe { QueryPerformanceCounter(&mut count) }; count as u64 } -/// QueryPerformanceCounter ticks per second. pub fn qpc_frequency() -> u64 { static FREQUENCY: std::sync::OnceLock = std::sync::OnceLock::new(); *FREQUENCY.get_or_init(|| { @@ -170,7 +164,6 @@ pub fn request_realtime(period_secs: f64, computation_secs: f64) -> Realtime { } } -/// The scheduling state of the thread with id `thread`. pub fn thread_state(thread: u32) -> Option { let handle = unsafe { OpenThread(THREAD_QUERY_LIMITED_INFORMATION, 0, thread) }; if handle == 0 { @@ -190,15 +183,25 @@ pub fn thread_state(thread: u32) -> Option { }) } +struct Timer(isize); + +impl Drop for Timer { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { CloseHandle(self.0) }; + } + } +} + thread_local! { - static TIMER: isize = unsafe { + static TIMER: Timer = Timer(unsafe { CreateWaitableTimerExW( std::ptr::null(), std::ptr::null(), CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS, ) - }; + }); } /// How long before the deadline the timer wait ends and spinning starts. @@ -212,7 +215,7 @@ pub fn wait_until(deadline: u64) { let remaining = |now: u64| (deadline as f64 - now as f64) / frequency; let left = remaining(qpc_now()); if left > SPIN_SECS { - TIMER.with(|&timer| { + TIMER.with(|&Timer(timer)| { if timer != 0 { // Negative: relative, in 100 ns intervals. let due = -(((left - SPIN_SECS) * 1e7) as i64).max(1); diff --git a/crates/verify/tests/recording.rs b/crates/verify/tests/recording.rs index 8aade6cd4b0..cf1f25da420 100644 --- a/crates/verify/tests/recording.rs +++ b/crates/verify/tests/recording.rs @@ -2054,14 +2054,19 @@ async fn record( reference: u64, case: &mut Case, ) -> anyhow::Result { + let system_output = platform::system_audio_output(); + if spec.system_audio && require_system_audio() && !system_output { + case.problem( + "missing", + "system audio required but this host has no audio output device", + ); + } let stimulus_spec = StimulusSpec { fps: spec.stimulus_fps, seconds: spec.seconds, holds: spec.holds.clone(), busy: true, - audio: spec.system_audio - && stimulus_plays_system_audio() - && platform::system_audio_output(), + audio: spec.system_audio && stimulus_plays_system_audio() && system_output, warmup: match capture { CaptureMode::Display => 2.0, CaptureMode::Window => 2.0, @@ -2309,16 +2314,40 @@ fn copy_project(from: &Path, to: &Path) -> anyhow::Result<()> { { fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; - for entry in std::fs::read_dir(from)? { - let entry = entry?; - let target = to.join(entry.file_name()); - if entry.file_type()?.is_dir() { - copy_dir(&entry.path(), &target)?; - } else { - std::fs::copy(entry.path(), &target)?; + let mut copied = std::collections::HashSet::new(); + // The recorder renames `.tmp` fragments as it finishes them: a + // file listed can be gone by the time it is opened, under a name + // the next listing has. + for _ in 0..5 { + let mut vanished = false; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let name = entry.file_name(); + if copied.contains(&name) { + continue; + } + let target = to.join(&name); + let result = if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &target) + } else { + std::fs::copy(entry.path(), &target).map(|_| ()) + }; + match result { + Ok(()) => { + copied.insert(name); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => vanished = true, + Err(e) => return Err(e), + } + } + if !vanished { + return Ok(()); } } - Ok(()) + Err(std::io::Error::other(format!( + "files in {} kept disappearing while it was copied", + from.display() + ))) } copy_dir(from, to).map_err(Into::into) }