diff --git a/openadapt_capture/input_observer/base.py b/openadapt_capture/input_observer/base.py index 8c31289..d3da66b 100644 --- a/openadapt_capture/input_observer/base.py +++ b/openadapt_capture/input_observer/base.py @@ -13,6 +13,10 @@ class InputObserverError(RuntimeError): """Base error for fail-loud input observation.""" +class _InputObserverStartupCancelledError(InputObserverError): + """Internal marker for setup failures caused by the parent's startup abort.""" + + class InputObserverUnavailableError(InputObserverError): """The current platform/session cannot provide complete input observation.""" @@ -448,7 +452,7 @@ def start(self) -> None: f"{type(self).__name__} did not become ready within " f"{self.startup_timeout:.1f}s" ) - self._abort_start(primary) + self._abort_start(primary, prefer_observer_failure=True) try: self.check_health() except BaseException as exc: @@ -458,7 +462,12 @@ def start(self) -> None: except BaseException as exc: self._abort_start(exc) - def _abort_start(self, primary: BaseException) -> None: + def _abort_start( + self, + primary: BaseException, + *, + prefer_observer_failure: bool = False, + ) -> None: """Make a failed ``start`` transactional before surfacing its cause.""" self._startup_failure = primary self._abort_delivery_start() @@ -475,6 +484,31 @@ def _abort_start(self, primary: BaseException) -> None: f"observer wake during failed startup also failed: {cleanup_exc}" ) thread.join(self.shutdown_timeout) + if prefer_observer_failure: + # A setup failure can race with the readiness deadline. Teardown + # must still complete transactionally, but once the observer + # thread has reported its concrete cause it is more useful and + # accurate than the parent thread's generic timeout. A clean + # cancellation (the genuinely-never-ready case) retains the + # timeout above. + with self._failure_lock: + observer_failure = self._failure + if ( + observer_failure is not None + and not isinstance( + observer_failure, + _InputObserverStartupCancelledError, + ) + ): + if isinstance(observer_failure, InputObserverError): + primary = observer_failure + else: + wrapped = InputObserverError( + f"{type(self).__name__} failed: {observer_failure}" + ) + wrapped.__cause__ = observer_failure + primary = wrapped + self._startup_failure = primary if thread.is_alive(): add_exception_note( primary, diff --git a/openadapt_capture/input_observer/linux.py b/openadapt_capture/input_observer/linux.py index ca9bdb0..53b952a 100644 --- a/openadapt_capture/input_observer/linux.py +++ b/openadapt_capture/input_observer/linux.py @@ -35,6 +35,7 @@ ObservedMouseMove, ObservedMouseScroll, ThreadedInputObserver, + _InputObserverStartupCancelledError, add_exception_note, ) @@ -531,7 +532,7 @@ def _raise_if_setup_cancelled(self) -> None: return self._waiting_baseline_marker = False self._accepting_events = False - raise InputObserverError( + raise _InputObserverStartupCancelledError( "X RECORD setup was cancelled before the input boundary was ready" ) diff --git a/tests/test_input_observer.py b/tests/test_input_observer.py index 55fb93c..c94a1d6 100644 --- a/tests/test_input_observer.py +++ b/tests/test_input_observer.py @@ -69,6 +69,15 @@ def _setup(self) -> None: self.release_setup.wait() +class _FailureAfterReadinessTimeoutObserver(_CooperativeNeverReadyObserver): + """Report a concrete setup failure only after start begins its abort.""" + + def _setup(self) -> None: + self.resource_open = True + self._stop_requested.wait() + raise RuntimeError("setup failed while readiness timed out") + + class _ReadyObserver(ThreadedInputObserver): def __init__(self, callback=lambda _event: None) -> None: super().__init__( @@ -214,6 +223,21 @@ def test_failed_startup_joins_and_tears_down_before_raising() -> None: assert not observer.resource_open +def test_readiness_timeout_surfaces_setup_failure_observed_during_abort() -> None: + observer = _FailureAfterReadinessTimeoutObserver() + + with pytest.raises( + InputObserverError, + match="setup failed while readiness timed out", + ): + observer.start() + + assert observer._thread is None + assert observer._delivery_thread is None + assert observer.torn_down.is_set() + assert not observer.resource_open + + def test_lingering_failed_startup_cannot_be_reused_as_success() -> None: observer = _StubbornNeverReadyObserver() try: