mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 15:33:58 +01:00
The demo promised three escapes and delivered none: the only way out was a hard reboot. Two independent causes, and only one of them was demo-only. launch_workout_app blocks until the app exits. Called straight from a Tk button that froze the event loop, so the Escape binding and the close button could not fire -- measured: zero handlers ran during the block, and the events were never delivered, not merely delivered late. WorkoutSession drives the same phases through `after` instead, on the main thread, so grab_release and grab_input stay where Tk requires them and the loop keeps pumping (199 ticks during a run that previously managed none). The second cause survives a healthy event loop. The Flutter app takes its own gdk_seat_grab with GDK_SEAT_CAPABILITY_ALL and never releases it, so while it is up X delivers the keyboard to it and no binding of ours can fire. Correct for production, fatal for a demo, and no flag turns it off -- so the demo's escape is a file the app cannot intercept: touch ~/.cache/stop-workout-demo. The previous commit's rationale for the blocking was wrong and is corrected here rather than left to mislead. It claimed pumping Tk would let the recovery watchdog steal the screen mid-workout; in fact release() stops the watchdog first and stop() cancels its timers outright, so there were never any ticks to race with. The blocking was incidental, which is why removing it costs the production handoff nothing. Verified with the real debug bundle under Xvfb: with the app up and holding its seat grab, the event loop is alive, the escape file terminates it, and the lock takes the grab back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
210 lines
7.2 KiB
Python
210 lines
7.2 KiB
Python
"""Tests for running the workout app without freezing the lock's event loop.
|
|
|
|
The invariant these defend: no phase blocks. Every wait is an ``after``
|
|
callback, because a blocked event loop makes the lock's own escapes inert --
|
|
which is what once forced a hard reboot out of a "safe" demo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._workout_app import GrabHandoff, ProcessHooks
|
|
from screen_locker._workout_ready import READY_MARKER
|
|
from screen_locker._workout_session import WorkoutSession
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
class _FakeAfter:
|
|
"""A drainable stand-in for ``root.after``."""
|
|
|
|
def __init__(self) -> None:
|
|
self.queue: list[Callable[[], None]] = []
|
|
|
|
def __call__(self, _ms: int, callback: Callable[[], None]) -> object:
|
|
self.queue.append(callback)
|
|
return object()
|
|
|
|
def drain(self, limit: int = 5000) -> None:
|
|
for _ in range(limit):
|
|
if not self.queue:
|
|
return
|
|
self.queue.pop(0)()
|
|
|
|
|
|
class _FakeStdout:
|
|
def __init__(self, chunks: list[str]) -> None:
|
|
self._chunks = chunks
|
|
|
|
def readline(self) -> str:
|
|
return self._chunks.pop(0) if self._chunks else ""
|
|
|
|
|
|
class _FakeChild:
|
|
def __init__(self, chunks: list[str], *, exits_after: int = 1) -> None:
|
|
self.stdout = _FakeStdout(chunks)
|
|
self.returncode: int | None = None
|
|
self.terminated = False
|
|
self._polls = 0
|
|
self._exits_after = exits_after
|
|
|
|
def poll(self) -> int | None:
|
|
self._polls += 1
|
|
if self._polls >= self._exits_after:
|
|
self.returncode = 0
|
|
return 0
|
|
return None
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
self.returncode = -15
|
|
|
|
|
|
def _session(child: _FakeChild, after: _FakeAfter, calls: list[str]):
|
|
return WorkoutSession(
|
|
GrabHandoff(
|
|
release=lambda: calls.append("release"),
|
|
reacquire=lambda: calls.append("reacquire"),
|
|
),
|
|
after=after,
|
|
on_status=lambda text: calls.append(f"status:{text[:20]}"),
|
|
binary=Path("/nonexistent/workout_app"),
|
|
hooks=ProcessHooks(
|
|
popen=lambda *_a, **_k: child,
|
|
selector=lambda r, _w, _x, _t: (r, [], []),
|
|
),
|
|
)
|
|
|
|
|
|
class TestWorkoutSession:
|
|
"""Each phase hands control back to the event loop."""
|
|
|
|
def test_releases_the_grab_only_after_the_ready_line(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=2)
|
|
_session(child, after, calls).start()
|
|
assert "release" not in calls # nothing released synchronously
|
|
after.drain()
|
|
assert "release" in calls
|
|
assert calls.index("release") < calls.index("reacquire")
|
|
|
|
def test_never_releases_when_the_app_dies_before_ready(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
_session(_FakeChild([]), after, calls).start()
|
|
after.drain()
|
|
assert "release" not in calls
|
|
|
|
def test_reacquires_when_the_app_exits(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=3)
|
|
_session(child, after, calls).start()
|
|
after.drain()
|
|
assert calls[-2] == "reacquire"
|
|
assert "Workout app closed" in calls[-1]
|
|
|
|
def test_abort_terminates_the_app_and_restores_the_lock(self) -> None:
|
|
"""The escape hatch: usable at any point, from outside X."""
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
|
|
session = _session(child, after, calls)
|
|
session.start()
|
|
for _ in range(3):
|
|
if after.queue:
|
|
after.queue.pop(0)()
|
|
session.abort()
|
|
assert child.terminated
|
|
assert "reacquire" in calls
|
|
|
|
def test_abort_before_release_does_not_reacquire(self) -> None:
|
|
"""Nothing was lent, so there is nothing to take back."""
|
|
after, calls = _FakeAfter(), []
|
|
session = _session(_FakeChild([], exits_after=99), after, calls)
|
|
session.start()
|
|
session.abort()
|
|
assert "reacquire" not in calls
|
|
|
|
def test_times_out_without_ever_blocking(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild(["noise\n"] * 10, exits_after=99)
|
|
session = _session(child, after, calls)
|
|
session._remaining = 3
|
|
session.start()
|
|
after.drain()
|
|
assert child.terminated
|
|
assert "release" not in calls
|
|
|
|
def test_says_so_when_the_app_is_not_built(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
session = WorkoutSession(
|
|
GrabHandoff(release=lambda: None, reacquire=lambda: None),
|
|
after=after,
|
|
on_status=calls.append,
|
|
hooks=ProcessHooks(popen=lambda *_a, **_k: None),
|
|
)
|
|
# Force the unbuilt case regardless of what this machine has built.
|
|
session._binary = None
|
|
monkey = "screen_locker._workout_session.workout_app_binary"
|
|
import unittest.mock
|
|
|
|
with unittest.mock.patch(monkey, return_value=None):
|
|
session.start()
|
|
assert any("not built" in c for c in calls)
|
|
|
|
def test_handles_a_missing_stdout_pipe(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([])
|
|
child.stdout = None
|
|
_session(child, after, calls).start()
|
|
after.drain()
|
|
assert "release" not in calls
|
|
|
|
|
|
class TestSessionEdges:
|
|
"""Paths a fake pipe or a happy path does not reach."""
|
|
|
|
def test_keeps_waiting_when_the_pipe_has_nothing_yet(self) -> None:
|
|
"""A quiet app is not a dead app; poll again, never block."""
|
|
after, calls = _FakeAfter(), []
|
|
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
|
|
session = WorkoutSession(
|
|
GrabHandoff(
|
|
release=lambda: calls.append("release"),
|
|
reacquire=lambda: calls.append("reacquire"),
|
|
),
|
|
after=after,
|
|
on_status=lambda _t: None,
|
|
binary=Path("/nonexistent/workout_app"),
|
|
hooks=ProcessHooks(
|
|
popen=lambda *_a, **_k: child,
|
|
# Never readable: the app is up but silent.
|
|
selector=lambda _r, _w, _x, _t: ([], [], []),
|
|
),
|
|
)
|
|
session._remaining = 2
|
|
session.start()
|
|
after.drain()
|
|
assert "release" not in calls
|
|
assert child.terminated
|
|
|
|
def test_abort_with_no_child_is_harmless(self) -> None:
|
|
after, calls = _FakeAfter(), []
|
|
session = _session(_FakeChild([]), after, calls)
|
|
session.abort() # never started
|
|
assert len(calls) == 1
|
|
assert "Workout app closed" in calls[0]
|
|
|
|
def test_reads_a_real_pipe_without_waiting_for_a_newline(self) -> None:
|
|
"""The production path: a text stream wrapping a binary buffer."""
|
|
import os
|
|
|
|
from screen_locker._workout_session import _read_available
|
|
|
|
read_fd, write_fd = os.pipe()
|
|
with os.fdopen(read_fd, "r") as reader, os.fdopen(write_fd, "w") as writer:
|
|
writer.write("WORKOUT_")
|
|
writer.flush()
|
|
assert _read_available(reader) == "WORKOUT_"
|