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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ _This release uses the MIT License._

### Bug Fixes

- Allow the first Windows UI Automation initialization to complete within a
bounded cold-start window. Make performance plotting opt-in so the first
recorder shutdown does not build a Matplotlib font cache.
- Refuse ambiguous macOS window selectors instead of choosing a different
matching window.
- Keep FFmpeg outside the recorder's interrupt process group so Ctrl-C can
Expand Down
6 changes: 4 additions & 2 deletions openadapt_capture/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ class Settings(BaseSettings):
# Maximum screenshots per second (0 = unlimited / legacy behavior)
SCREEN_CAPTURE_FPS: float = 10.0

# Performance plotting
PLOT_PERFORMANCE: bool = True
# Performance plotting is an opt-in diagnostic. Importing Matplotlib and
# building its font cache during the first recorder shutdown can take close
# to a minute on a clean Windows install.
PLOT_PERFORMANCE: bool = False

# Database
DB_ECHO: bool = False
Expand Down
10 changes: 8 additions & 2 deletions openadapt_capture/input_observer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def __init__(
observe_mouse: bool,
capture_mouse_moves: bool,
startup_timeout: float = 5.0,
delivery_startup_timeout: float | None = None,
shutdown_timeout: float = 5.0,
delivery_queue_size: int = 4096,
) -> None:
Expand All @@ -171,6 +172,11 @@ def __init__(
self.observe_mouse = observe_mouse
self.capture_mouse_moves = capture_mouse_moves
self.startup_timeout = startup_timeout
self.delivery_startup_timeout = (
startup_timeout
if delivery_startup_timeout is None
else delivery_startup_timeout
)
self.shutdown_timeout = shutdown_timeout
if delivery_queue_size <= 0:
raise ValueError("delivery_queue_size must be positive")
Expand Down Expand Up @@ -730,11 +736,11 @@ def start(self) -> None:
self._commit_delivery_start()
except BaseException as exc:
self._abort_start(exc)
if not self._delivery_ready.wait(self.startup_timeout):
if not self._delivery_ready.wait(self.delivery_startup_timeout):
self._abort_start(
InputObserverError(
f"{type(self).__name__} delivery setup did not become ready within "
f"{self.startup_timeout:.1f}s"
f"{self.delivery_startup_timeout:.1f}s"
)
)
try:
Expand Down
5 changes: 5 additions & 0 deletions openadapt_capture/input_observer/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ def __init__(
observe_mouse: bool,
capture_mouse_moves: bool,
startup_timeout: float = 5.0,
# A clean Windows install can need more than five seconds for the first
# pywinauto/comtypes UIA initialization on the delivery thread. Keep
# native hook setup at five seconds and give only UIA the larger bound.
delivery_startup_timeout: float = 20.0,
shutdown_timeout: float = 5.0,
delivery_queue_size: int = 4096,
translation_queue_size: int | None = None,
Expand All @@ -392,6 +396,7 @@ def __init__(
observe_mouse=observe_mouse,
capture_mouse_moves=capture_mouse_moves,
startup_timeout=startup_timeout,
delivery_startup_timeout=delivery_startup_timeout,
shutdown_timeout=shutdown_timeout,
delivery_queue_size=delivery_queue_size,
)
Expand Down
9 changes: 8 additions & 1 deletion tests/test_highlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,15 +635,22 @@ def test_config_override_applies_and_restores(self):

original_video = config.RECORD_VIDEO
original_audio = config.RECORD_AUDIO
original_plot = config.PLOT_PERFORMANCE

rc = RecordingConfig(capture_video=False, capture_audio=True)
rc = RecordingConfig(
capture_video=False,
capture_audio=True,
plot_performance=True,
)
with config_override(rc):
assert config.RECORD_VIDEO is False
assert config.RECORD_AUDIO is True
assert config.PLOT_PERFORMANCE is True

# Restored
assert config.RECORD_VIDEO == original_video
assert config.RECORD_AUDIO == original_audio
assert config.PLOT_PERFORMANCE == original_plot

def test_config_override_none_values_unchanged(self):
"""Test that None values in RecordingConfig don't change config."""
Expand Down
22 changes: 22 additions & 0 deletions tests/test_input_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ def _openadapt_delivery_thread_stop(self) -> None:
assert len({thread_id for _name, thread_id in lifecycle}) == 1


def test_delivery_setup_can_use_a_larger_bound_than_native_setup() -> None:
"""A bounded cold service start must not weaken native-hook readiness."""
delivery_started = threading.Event()

class Callback:
def _openadapt_delivery_thread_start(self) -> None:
time.sleep(0.05)
delivery_started.set()

def __call__(self, _event) -> None:
return

observer = _ReadyObserver(Callback())
observer.startup_timeout = 0.02
observer.delivery_startup_timeout = 0.2

observer.start()
observer.stop()

assert delivery_started.is_set()


def test_setup_events_are_discarded_when_start_fails() -> None:
delivered = []
observer = _SetupEmittingObserver(delivered.append, fail_setup=True)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_input_observer_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ def make_observer(
)


def test_default_startup_bound_allows_cold_windows_uia_initialization() -> None:
"""The production factory must isolate native and cold UIA bounds."""
observer = WindowsInputObserver(
lambda _event: None,
observe_keyboard=True,
observe_mouse=True,
capture_mouse_moves=True,
_user32=FakeUser32(),
_kernel32=FakeKernel32(),
)

assert observer.startup_timeout == 5.0
assert observer.delivery_startup_timeout == 20.0


def wait_until(predicate, *, timeout: float = 1.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
Expand Down
7 changes: 7 additions & 0 deletions tests/test_runtime_import_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ def test_external_ffmpeg_keeps_core_recorder_video_first_and_pyav_free() -> None
assert config.RECORD_IMAGES is False


def test_performance_plotting_is_opt_in() -> None:
"""A clean first shutdown must not build Matplotlib's font cache."""
from openadapt_capture.config import Settings

assert Settings(_env_file=None).PLOT_PERFORMANCE is False


def test_default_install_exposes_recorder() -> None:
"""Recorder import never fails because a runtime dependency is undeclared."""
result = subprocess.run(
Expand Down