From e47ced06e68ff4ba94e6edd466950c632dce02e0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 14:35:14 -0400 Subject: [PATCH] fix: preserve the capture clock epoch --- openadapt_capture/recorder.py | 12 +++---- tests/test_desktop_capture.py | 64 +++++++++++++++++++++++++++++++++++ tests/test_performance.py | 7 +++- tests/test_window_capture.py | 37 -------------------- 4 files changed, 75 insertions(+), 45 deletions(-) diff --git a/openadapt_capture/recorder.py b/openadapt_capture/recorder.py index 6e86e58..4f12bb3 100644 --- a/openadapt_capture/recorder.py +++ b/openadapt_capture/recorder.py @@ -758,8 +758,6 @@ def process_events( processing_aborted: Stop without publishing a completed journal after a startup failure leaves a producer alive. """ - utils.set_start_time(recording.timestamp) - logger.info("Starting") prev_event = None @@ -1691,8 +1689,6 @@ def read_screen_events( """ if window_scope is not None and desktop_scope is not None: raise ValueError("screen reader cannot use both window and desktop scopes") - utils.set_start_time(recording.timestamp) - fps = config.SCREEN_CAPTURE_FPS min_interval = 1.0 / fps if fps > 0 else 0.0 @@ -1870,8 +1866,6 @@ def read_window_events( recording: The recording object. started_event: Event to set once started. """ - utils.set_start_time(recording.timestamp) - # Refuse at the boundary. Without this the loop below polls a backend that # can never answer, never sets started_event, and the recording hangs in # startup with no stated cause. @@ -2206,7 +2200,6 @@ def deliver_observed(event: ObservedInput, reservation: object) -> None: setattr(on_observed, "_openadapt_input_receipt", reserve_observed) setattr(on_observed, "_openadapt_input_delivery", deliver_observed) - utils.set_start_time(recording.timestamp) observer = None started = False observer_failed = False @@ -2559,6 +2552,11 @@ def record( ) recording_timestamp = recording.timestamp + # create_recording() established the one shared clock epoch for this + # capture. Every thread producer inherits that epoch. A thread must not + # call set_start_time() again because doing so can place a later frame + # before the retained initial frame in capture time. + event_q = OrderedEventJournal() producers_finished = threading.Event() input_finished = threading.Event() diff --git a/tests/test_desktop_capture.py b/tests/test_desktop_capture.py index 9f93988..655d8ff 100644 --- a/tests/test_desktop_capture.py +++ b/tests/test_desktop_capture.py @@ -17,6 +17,7 @@ from openadapt_capture.db import create_db, crud from openadapt_capture.desktop_capture import DesktopCaptureError, DesktopCaptureScope from openadapt_capture.recorder import ( + Event, NativeInputFrameBoundary, OrderedEventJournal, create_recording, @@ -165,6 +166,69 @@ def test_recording_rejects_ambiguous_coordinate_scopes(tmp_path) -> None: ) +def test_desktop_screen_reader_preserves_the_initial_frame_clock( + monkeypatch, +) -> None: + """A later screen frame uses the initial frame's monotonic epoch.""" + monkeypatch.setattr(recorder_module.config, "SCREEN_CAPTURE_FPS", 0) + terminate = threading.Event() + capture_entered = threading.Event() + release_capture = threading.Event() + monotonic_now = [10.5] + monotonic_origin = [10.0] + wall_origin = 100.0 + reset_calls: list[float] = [] + + def get_timestamp() -> float: + return wall_origin + monotonic_now[0] - monotonic_origin[0] + + def reset_clock(value: float) -> None: + reset_calls.append(value) + monotonic_origin[0] = monotonic_now[0] + + def take_screenshot() -> Image.Image: + capture_entered.set() + assert release_capture.wait(timeout=5) + terminate.set() + return Image.new("RGB", (4480, 1440), "black") + + monkeypatch.setattr(recorder_module.utils, "get_timestamp", get_timestamp) + monkeypatch.setattr(recorder_module.utils, "set_start_time", reset_clock) + monkeypatch.setattr(recorder_module.utils, "take_screenshot", take_screenshot) + + journal = OrderedEventJournal() + journal.put( + Event( + get_timestamp(), + "screen", + Image.new("RGB", (4480, 1440), "white"), + ) + ) + + reader = threading.Thread( + target=read_screen_events, + args=( + journal, + terminate, + SimpleNamespace(timestamp=wall_origin), + threading.Event(), + ), + kwargs={"desktop_scope": _two_monitor_scope()}, + ) + monotonic_now[0] = 11.0 + reader.start() + assert capture_entered.wait(timeout=5) + monotonic_now[0] = 11.25 + release_capture.set() + reader.join(timeout=5) + + assert not reader.is_alive() + frames = [journal.get_nowait(), journal.get_nowait()] + assert [frame.source_ordinal for frame in frames] == [1, 2] + assert frames[0].timestamp < frames[1].timestamp + assert reset_calls == [] + + def test_desktop_screen_reader_discards_a_frame_crossed_by_native_input( monkeypatch, ) -> None: diff --git a/tests/test_performance.py b/tests/test_performance.py index 06e1867..6110662 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -147,7 +147,12 @@ def test_initial_frame_ready_without_input(self, capture_dir): assert rec.screen_count >= 1 with CaptureSession.load(capture_dir) as capture: - assert capture.frames(), "capture completed without an initial frame" + frames = capture.frames() + assert len(frames) >= 2, "capture completed without a later retained frame" + timestamps = [frame.timestamp for frame in frames] + assert all( + earlier < later for earlier, later in zip(timestamps, timestamps[1:]) + ), "retained frame capture times did not increase" @pytest.mark.skipif(_NO_INPUT_INJECTION, reason=_INJECTION_SKIP_REASON) def test_record_and_load_roundtrip(self, capture_dir): diff --git a/tests/test_window_capture.py b/tests/test_window_capture.py index 796d507..c7aabf7 100644 --- a/tests/test_window_capture.py +++ b/tests/test_window_capture.py @@ -69,43 +69,6 @@ def test_journal_orders_concurrent_observations_by_reservation_not_timestamp(): assert (second.timestamp, second.source_ordinal) == (10.0, 2) -def test_journal_accepts_a_frame_after_a_process_clock_reset(scope): - journal = OrderedEventJournal() - first_image, _ = scope.capture_frame(publish=False) - first_generation = scope.current_generation() - journal.commit_window_frame( - Event( - 20.0, - "screen", - WindowScopedFrame( - image=first_image, - window_event_data=scope.window_event_data(), - geometry_generation=first_generation, - ), - ), - scope, - first_generation, - ) - - second_image, _ = scope.capture_frame(publish=False) - second_generation = scope.current_generation() - journal.commit_window_frame( - Event( - 10.0, - "screen", - WindowScopedFrame( - image=second_image, - window_event_data=scope.window_event_data(), - geometry_generation=second_generation, - ), - ), - scope, - second_generation, - ) - - assert [journal.get_nowait().source_ordinal for _ in range(2)] == [1, 2] - - def test_action_reservation_cannot_bind_a_later_frame_generation(scope, fake, monkeypatch): scope.capture_frame() journal = OrderedEventJournal()