From 7d207b52f91dd349953ba6fd379e5e18e56c585a Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 8 Sep 2026 09:06:18 -0700 Subject: [PATCH] test(capture): keep fixture streams live across stale frames A single synthetic frame can expire under scheduling pressure, leaving lifecycle tests with no subsequent frame to observe. Deliver fixture frames at 60fps until each observation completes, then stop and join the producer before crossing any reconfiguration boundary. Preserve stale-frame rejection and prove recovery with fresh pixels. Require new geometry and new content after a worker generation change. --- .../tests/macos_screen_capture_tests.rs | 164 +++++++++++++++--- 1 file changed, 139 insertions(+), 25 deletions(-) diff --git a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs index 700a3906f..a2f95d7bd 100644 --- a/crates/hypercolor-core/tests/macos_screen_capture_tests.rs +++ b/crates/hypercolor-core/tests/macos_screen_capture_tests.rs @@ -1,7 +1,7 @@ //! ScreenCaptureKit core worker fixture contracts. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, mpsc}; use std::thread; use std::time::{Duration, Instant}; @@ -182,6 +182,41 @@ fn diagnostics_payload(snapshot: &SourceStatus) -> &serde_json::Value { diagnostics.payload() } +// A live capture source keeps delivering after an individual frame expires. +// Stop and join each producer before reconfiguration so it cannot submit new +// frames across the worker generation boundary. +fn with_fixture_stream( + source: &mut S, + fixture: &MacosScreenCaptureFixture, + mut frame: MacosCaptureFrame, + observe: impl FnOnce(&mut S) -> T, +) -> (T, Vec) { + thread::scope(|scope| { + let (stop, stopped) = mpsc::channel::<()>(); + let producer = scope.spawn(move || { + let mut capture_times = Vec::new(); + loop { + let captured_at = Instant::now(); + capture_times.push(captured_at); + fixture.publish_at(frame.clone(), captured_at); + frame.sequence = frame + .sequence + .checked_add(1) + .expect("fixture sequence fits"); + match stopped.recv_timeout(Duration::from_nanos(1_000_000_000 / 60)) { + Err(mpsc::RecvTimeoutError::Timeout) => {} + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + capture_times + }); + let result = observe(source); + drop(stop); + let capture_times = producer.join().expect("fixture producer completes"); + (result, capture_times) + }) +} + fn wait_for_screen(source: &mut impl InputSource) -> hypercolor_core::input::ScreenData { let deadline = Instant::now() + Duration::from_secs(2); loop { @@ -190,7 +225,12 @@ fn wait_for_screen(source: &mut impl InputSource) -> hypercolor_core::input::Scr InputData::None if Instant::now() < deadline => { thread::sleep(Duration::from_millis(1)); } - InputData::None => panic!("fixture worker did not publish before the deadline"), + InputData::None => panic!( + "fixture worker did not publish before the deadline: {:?}", + source + .source_status_handle() + .map(|status| status.snapshot()) + ), _ => panic!("macOS fixture published the wrong input kind"), } } @@ -199,11 +239,14 @@ fn wait_for_screen(source: &mut impl InputSource) -> hypercolor_core::input::Scr fn wait_for_grid_width( source: &mut impl InputSource, grid_width: u32, + matches_frame: impl Fn(&hypercolor_core::input::ScreenData) -> bool, ) -> hypercolor_core::input::ScreenData { let deadline = Instant::now() + Duration::from_secs(2); loop { match source.sample().expect("fixture sample succeeds") { - InputData::Screen(data) if data.grid_width == grid_width => return data, + InputData::Screen(data) if data.grid_width == grid_width && matches_frame(&data) => { + return data; + } InputData::Screen(_) | InputData::None if Instant::now() < deadline => { thread::sleep(Duration::from_millis(1)); } @@ -267,8 +310,12 @@ fn native_refresh_hdr_and_cursor_demand_reaches_capture_and_screen_cast() { fixture.set_selection(MacosCaptureSelection::Display { source_id: Arc::from("display:hdr-effect-fixture"), }); - fixture.publish(fixture_hdr_frame(1, [0x00, 0x3c, 0, 0, 0, 0, 0x00, 0x3c])); - let screen = wait_for_screen(&mut source); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_hdr_frame(1, [0x00, 0x3c, 0, 0, 0, 0, 0x00, 0x3c]), + wait_for_screen, + ); let reference = screen .canvas_downscale .as_ref() @@ -394,16 +441,19 @@ fn fixture_capture_activates_only_for_live_demand() { hdr_capture: true, dual_range_screenshots: false, })); - let captured_at = Instant::now(); - fixture.publish_at(fixture_frame(1, [0, 0, 255, 255]), captured_at); - let data = wait_for_screen(&mut source); + let (data, capture_times) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(1, [0, 0, 255, 255]), + wait_for_screen, + ); assert_eq!(data.grid_width, 2); assert_eq!(data.grid_height, 1); assert_eq!(data.source_width, 4); assert_eq!(data.source_height, 2); assert_eq!(data.zone_colors.len(), 2); let live = status.snapshot(); - assert_eq!(live.last_sample_at, Some(captured_at)); + assert!(capture_times.contains(&live.last_sample_at.expect("live sample has capture time"))); let platform = diagnostics_payload(&live); assert_eq!(platform["state"], "live"); assert_eq!(platform["stream_state"], "active"); @@ -513,8 +563,13 @@ fn rejected_demand_request_preserves_the_committed_worker_and_demand() { assert_eq!(source.screen_capture_demand(), committed); assert_eq!(fixture.stream_request(), request); assert!(fixture.is_active()); - fixture.publish(fixture_frame(1, [0, 0, 255, 255])); - assert_eq!(wait_for_screen(&mut source).grid_width, 2); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(1, [0, 0, 255, 255]), + wait_for_screen, + ); + assert_eq!(screen.grid_width, 2); } #[test] @@ -544,8 +599,13 @@ fn rejected_reconfiguration_request_preserves_the_committed_worker_config() { assert_eq!(fixture.stream_request(), request); assert!(fixture.is_active()); - fixture.publish(fixture_frame(1, [0, 255, 0, 255])); - assert_eq!(wait_for_screen(&mut source).grid_width, 2); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(1, [0, 255, 0, 255]), + wait_for_screen, + ); + assert_eq!(screen.grid_width, 2); } #[test] @@ -589,8 +649,13 @@ fn asynchronous_demand_request_failure_preserves_the_committed_worker_and_demand assert!(format!("{error:#}").contains("failed asynchronously")); assert_eq!(source.screen_capture_demand(), committed); assert_eq!(fixture.stream_request(), request); - fixture.publish(fixture_frame(1, [0, 0, 255, 255])); - assert_eq!(wait_for_screen(&mut source).grid_width, 2); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(1, [0, 0, 255, 255]), + wait_for_screen, + ); + assert_eq!(screen.grid_width, 2); } #[test] @@ -634,8 +699,13 @@ fn asynchronous_reconfiguration_commits_after_native_activation() { .expect("native activation commits reconfiguration"); }); - fixture.publish(fixture_frame(1, [0, 255, 0, 255])); - assert_eq!(wait_for_screen(&mut source).grid_width, 1); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(1, [0, 255, 0, 255]), + wait_for_screen, + ); + assert_eq!(screen.grid_width, 1); } #[test] @@ -653,8 +723,13 @@ fn processing_reconfiguration_changes_legacy_hdr_bytes_at_a_frame_boundary() { .set_screen_capture_demand(ScreenCaptureDemand::active()) .expect("fixture demand activates"); let encoded = [0x00, 0x38, 0x00, 0x3c, 0x00, 0x40, 0x00, 0x3c]; - fixture.publish(fixture_hdr_frame(1, encoded)); - let before = canvas_bytes(&wait_for_screen(&mut source)); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_hdr_frame(1, encoded), + wait_for_screen, + ); + let before = canvas_bytes(&screen); source .reconfigure_screen_processing(&CaptureConfig { @@ -662,8 +737,13 @@ fn processing_reconfiguration_changes_legacy_hdr_bytes_at_a_frame_boundary() { ..config }) .expect("valid processing calibration commits on the worker"); - fixture.publish(fixture_hdr_frame(2, encoded)); - let after = canvas_bytes(&wait_for_canvas_change(&mut source, &before)); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_hdr_frame(2, encoded), + |source| wait_for_canvas_change(source, &before), + ); + let after = canvas_bytes(&screen); assert_ne!(&after[..4], &before[..4]); assert!(after[0] < before[0]); @@ -706,6 +786,25 @@ fn stale_native_frame_never_enters_the_legacy_cpu_publication() { assert!(Instant::now() < deadline, "stale frame was not observed"); thread::sleep(Duration::from_millis(1)); } + + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(2, [0, 255, 0, 255]), + wait_for_screen, + ); + assert!( + canvas_bytes(&screen) + .chunks_exact(4) + .all(|pixel| pixel[1] > pixel[0]) + ); + let recovered = status.snapshot(); + assert!( + diagnostics_payload(&recovered)["frames_stale"] + .as_u64() + .expect("stale count is numeric") + >= 1 + ); } #[test] @@ -722,8 +821,13 @@ fn reconfiguration_fences_the_previous_worker_generation() { source .set_screen_capture_demand(ScreenCaptureDemand::active()) .expect("fixture demand activates"); - fixture.publish(fixture_frame(1, [255, 0, 0, 255])); - assert_eq!(wait_for_screen(&mut source).zone_colors.len(), 2); + let (screen, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(1, [255, 0, 0, 255]), + wait_for_screen, + ); + assert_eq!(screen.zone_colors.len(), 2); source .reconfigure_screen_capture(&CaptureConfig { @@ -746,8 +850,18 @@ fn reconfiguration_fences_the_previous_worker_generation() { }; assert_eq!(retained.grid_width, 2); - fixture.publish(fixture_frame(2, [0, 255, 0, 255])); - let data = wait_for_grid_width(&mut source, 1); + let (data, _) = with_fixture_stream( + &mut source, + &fixture, + fixture_frame(2, [0, 255, 0, 255]), + |source| { + wait_for_grid_width(source, 1, |data| { + canvas_bytes(data) + .chunks_exact(4) + .all(|pixel| pixel[1] > pixel[0] && pixel[1] > pixel[2]) + }) + }, + ); assert_eq!(data.grid_width, 1); assert_eq!(data.grid_height, 1); assert_eq!(data.zone_colors.len(), 1);