screen-locker/screen_locker/_instance.py
Krzysztof kuhy Rudnicki 6a7a7d8933 Queue behind stronger lockers, single-instance guard, fix budget-crash in fit check
Root-caused the 2026-08-21 stuck-lock incident: a manual workout
unlocked correctly, but the mechanism behind "locked again seconds
later" was a stray workout-locker.service re-arm racing a second
process into building a competing lock window -- not a leetcode-guard
conflict as first suspected (its timer wasn't due until 13:00 that
day). Separately, diet_guard held the global grab for ~3 minutes
against a newly-arriving, higher-ranked screen_locker that morning,
which just retried and logged instead of making progress.

- _build_lock_window now calls gatelock.wait_for_turn before arming,
  so a stronger incumbent is queued behind instead of raced.
- New screen_locker/_instance.py (flock-based, mirrors
  leetcode_guard/diet_guard's own single-instance guards) stops a
  second process from ever building a competing window in the first
  place.
- Bumped to gatelock v0.7.1, which adds weaker-holder preemption
  (SIGTERM, reusing the holder's own clean close() path) so a lower-
  ranked incumbent can no longer block a stronger claim for minutes.
- Fixed a live crash in scripts/verify_screen_fits.py: it read the
  real log.json, so today's exhausted manual-workout budget (itself
  caused by the incident's duplicate entry) made the fit-check crash
  on an unset _mw_error_label. Points at a path that can never exist
  instead, so the measurement no longer depends on live data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
2026-08-21 13:04:54 +02:00

69 lines
2.1 KiB
Python

"""One workout-locker process at a time.
A stray re-arm (a delayed ``workout-locker.timer`` catch-up landing right on
top of a session that just unlocked, a direct spawn outside the tracked
systemd unit, or any other double-start) used to mean two Tk processes
racing to build a lock window and grab the display. Copied in shape from
``leetcode_guard._instance`` / ``diet_guard._gatelock.acquire_gate_lock``.
Liveness is the kernel's ``flock``, held for the process lifetime: it is
released on *any* death, including SIGKILL and a crashed X server. No PID
files, no staleness heuristics, no timeouts.
"""
from __future__ import annotations
from dataclasses import dataclass
import fcntl
import logging
import os
from typing import IO, TYPE_CHECKING, Final
_logger: Final = logging.getLogger(__name__)
if TYPE_CHECKING:
from pathlib import Path
@dataclass
class InstanceLock:
"""A held single-instance lock."""
handle: IO[str]
path: Path
def release(self) -> None:
"""Drop the lock. Idempotent, and safe on a closed handle."""
try:
self.handle.close()
except OSError as exc:
_logger.warning("could not close the instance lock %s: %s", self.path, exc)
def acquire(path: Path) -> InstanceLock | None:
"""Take the single-instance lock, or ``None`` if another run holds it.
Opened ``"a+"`` rather than ``"w"``: ``"w"`` truncates at ``open()`` time,
which happens *before* the lock attempt, so a losing contender would erase
the incumbent's record on its way out.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+", encoding="utf-8")
except OSError:
_logger.exception("cannot open the instance lock %s", path)
return None
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
_logger.warning(
"another workout-locker run already holds %s -- standing down", path
)
handle.close()
return None
handle.seek(0)
handle.truncate()
handle.write(str(os.getpid()))
handle.flush()
return InstanceLock(handle=handle, path=path)