mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
The supervisor hands the X grab to the Flutter app only once the app has printed WORKOUT_LOCK: ready, so the screen is covered continuously and a window that never started is never handed the machine. Enforcing that handshake needs a timeout that actually fires. readline() on a pipe blocks until a newline arrives, so polling the clock around it cannot bound the wait: against a real subprocess a 2s deadline took 30s, and the hang was then misreported as "exited before signalling ready" -- the wrong diagnostic for the wrong failure. The wait is now select-based and reads whatever bytes are available, since a half-written line would otherwise block inside readline() past the deadline just as surely. Reads accumulate before matching, because a chunk boundary can fall inside the marker and dropping it would strand a healthy app at the lock screen. Verified against real pipes, not fakes: a silent app and a partial line both time out on schedule, the ready line is still detected, and a marker split across two reads is reassembled. Split into _workout_ready for the 250-line cap. The grab handoff and the process hooks are injected as frozen dataclasses so both directions must be supplied at the call site and a test that fakes the process also fakes the wait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
108 lines
4.1 KiB
Python
108 lines
4.1 KiB
Python
"""Waiting for the workout app's ready line on a pipe that may stall.
|
|
|
|
Split from ``_workout_app`` for the 250-line cap: this is the whole of the
|
|
"is the app up yet?" protocol, and it is the part with the sharp edges.
|
|
|
|
``readline()`` on a pipe blocks until a newline arrives, so polling the clock
|
|
around it cannot enforce a timeout -- an app that starts and prints nothing,
|
|
or leaves half a line in the buffer, would hold the lock screen forever and
|
|
then be misreported as having exited. Everything here exists to make the
|
|
deadline real.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import select
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
from typing import IO, Protocol
|
|
|
|
_Streams = Sequence[IO[str]]
|
|
|
|
class _Selector(Protocol):
|
|
"""``select.select``, narrowed to the readable-wait used here."""
|
|
|
|
def __call__(
|
|
self, rlist: _Streams, wlist: _Streams, xlist: _Streams, timeout: float
|
|
) -> tuple[list[IO[str]], list[IO[str]], list[IO[str]]]: ...
|
|
|
|
class _HasStdout(Protocol):
|
|
"""The half of the child process this module reads from."""
|
|
|
|
stdout: IO[str] | None
|
|
|
|
|
|
__all__ = ["READY_MARKER", "await_ready", "default_selector"]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Printed by linux/runner/my_application.cc once the window is mapped and the
|
|
# grab retry loop is running. The supervisor waits for this before releasing.
|
|
READY_MARKER = "WORKOUT_LOCK: ready"
|
|
|
|
default_selector = select.select
|
|
|
|
|
|
def _read_available(stream: IO[str]) -> str:
|
|
"""Read whatever the pipe currently holds, without waiting for a newline.
|
|
|
|
``select`` has already said the stream is readable. Text streams wrap a
|
|
binary buffer whose ``read1`` returns the available bytes immediately;
|
|
``readline`` would instead block until a newline, which is what let a
|
|
half-written line outlive the deadline. Fakes with no ``buffer`` fall back
|
|
to a line, which never blocks for them.
|
|
"""
|
|
buffer = getattr(stream, "buffer", None)
|
|
if buffer is None:
|
|
return stream.readline()
|
|
return buffer.read1().decode("utf-8", "replace")
|
|
|
|
|
|
def await_ready(
|
|
child: _HasStdout, timeout: float, selector: _Selector = default_selector
|
|
) -> tuple[bool, str]:
|
|
"""Wait for the app's ready line, or for it to die first.
|
|
|
|
Returns ``(ready, why)``; ``why`` distinguishes a crash from a hang, which
|
|
need different fixes (a broken build vs. a slow first sync).
|
|
|
|
The wait is ``select``-based, and reads whatever is available rather than
|
|
whole lines, because ``readline()`` on a real pipe blocks until a newline
|
|
arrives. Polling the clock around a blocking read cannot enforce a
|
|
timeout, so an app that started and printed nothing -- or left a half
|
|
written line in the pipe -- would hold the lock screen forever and then be
|
|
misreported as having exited.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
seen = ""
|
|
while (remaining := deadline - time.monotonic()) > 0:
|
|
if child.stdout is None:
|
|
return _not_ready("the workout app exited before signalling ready")
|
|
readable, _, _ = selector([child.stdout], [], [], remaining)
|
|
if not readable:
|
|
break
|
|
chunk = _read_available(child.stdout)
|
|
if not chunk:
|
|
# Nothing left to read AND the pipe is at EOF: the app is gone.
|
|
return _not_ready("the workout app exited before signalling ready")
|
|
# Accumulated, not matched per read: a chunk boundary can fall inside
|
|
# the marker, and dropping it would strand a healthy app at the lock.
|
|
seen += chunk
|
|
if READY_MARKER in seen:
|
|
return True, "ready"
|
|
reason = f"the workout app never signalled ready within {timeout:.0f}s"
|
|
_logger.error("%s (expected %r on stdout).", reason, READY_MARKER)
|
|
return False, reason
|
|
|
|
|
|
def _not_ready(reason: str) -> tuple[bool, str]:
|
|
"""Log a failed start loudly, so a dead app never fails silently."""
|
|
_logger.error(
|
|
"%s — the lock screen stays up and the workout was NOT started.", reason
|
|
)
|
|
return False, reason
|