mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
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
69 lines
2.1 KiB
Python
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)
|