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
38 changes: 36 additions & 2 deletions openadapt_capture/input_observer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion openadapt_capture/input_observer/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
ObservedMouseMove,
ObservedMouseScroll,
ThreadedInputObserver,
_InputObserverStartupCancelledError,
add_exception_note,
)

Expand Down Expand Up @@ -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"
)

Expand Down
24 changes: 24 additions & 0 deletions tests/test_input_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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:
Expand Down
Loading