mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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
This commit is contained in:
parent
c1c73d43ba
commit
6a7a7d8933
@ -7,7 +7,7 @@ requires-python = ">=3.10"
|
||||
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
|
||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.7.0#subdirectory=crdt-sync",
|
||||
]
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ bandit>=1.7.0
|
||||
codespell>=2.2.0
|
||||
coverage>=7.4.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.7.0#subdirectory=crdt-sync
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock
|
||||
mcp>=2,<3
|
||||
mypy>=1.8.0
|
||||
pre-commit>=3.6.0
|
||||
|
||||
@ -4,8 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from gatelock import default_runtime_dir
|
||||
from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
|
||||
|
||||
# Ephemeral runtime state (self-clears on reboot, same directory gatelock's
|
||||
# own Arbiter uses) -- not repo-tracked data, so it does not belong next to
|
||||
# log.json/sick_history.json below.
|
||||
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
|
||||
|
||||
# Single-sourced from gatelock so the literal key path can't drift.
|
||||
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
|
||||
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
||||
@ -87,8 +93,6 @@ MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
|
||||
# that one is a deliberate concession to the user, this one corrects a
|
||||
# measurement error. Duration now gets a leeway too, but for the other reason.
|
||||
RUNNERUP_DISTANCE_TOLERANCE = 0.05
|
||||
RUNNERUP_ACCEPTED_SPORTS: frozenset[int] = frozenset({0, 3, 5})
|
||||
# 0=RUNNING, 3=ORIENTEERING, 5=TREADMILL
|
||||
MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP
|
||||
EARLY_BIRD_START_HOUR = 5
|
||||
EARLY_BIRD_END_HOUR = 8
|
||||
|
||||
68
screen_locker/_instance.py
Normal file
68
screen_locker/_instance.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""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)
|
||||
@ -17,6 +17,7 @@ from gatelock import (
|
||||
Arbiter,
|
||||
LockWindow,
|
||||
ScrollableSurface,
|
||||
wait_for_turn,
|
||||
)
|
||||
|
||||
from screen_locker._surface_group import FrameGroup
|
||||
@ -86,6 +87,13 @@ class WindowSetupMixin:
|
||||
disable_vt=self._colors.resolved_disable_vt(),
|
||||
)
|
||||
arbiter.publish()
|
||||
# Published first so anything lower-ranked queues behind us in turn,
|
||||
# then wait with nothing on screen. Never exits here: a higher-ranked
|
||||
# locker (wake_alarm) means "later", never "skip" -- mirrors
|
||||
# leetcode_guard's own fix for the same grab race (gatelock's own
|
||||
# module docstring: 2026-07-25, and again with diet_guard on
|
||||
# 2026-08-21).
|
||||
wait_for_turn(arbiter)
|
||||
arbiter.acquire_holder()
|
||||
lock = LockWindow(self.root, self._colors, hooks=self, arbiter=arbiter)
|
||||
lock.setup()
|
||||
|
||||
@ -18,12 +18,14 @@ from gatelock import (
|
||||
LockWindow,
|
||||
)
|
||||
|
||||
from screen_locker import _instance
|
||||
from screen_locker._auto_upgrade import AutoUpgradeMixin
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
EARLY_BIRD_START_HOUR,
|
||||
HMAC_KEY_FILE,
|
||||
INSTANCE_LOCK_FILE,
|
||||
MAX_CLOCK_SKEW_SECONDS,
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
PHONE_PENALTY_DELAY_DEMO,
|
||||
@ -114,6 +116,23 @@ class ScreenLocker(
|
||||
) -> None:
|
||||
"""Initialize screen locker with optional demo mode."""
|
||||
_assert_not_under_pytest()
|
||||
# Guards against a stray re-arm racing a second process into building
|
||||
# a competing lock window (the actual mechanism behind "unlocked, then
|
||||
# locked again seconds later" -- see 2026-08-21 incident notes). Not
|
||||
# released in a try/finally: standing down here calls sys.exit(0)
|
||||
# immediately, and process exit already drops the flock for free.
|
||||
instance_lock = _instance.acquire(INSTANCE_LOCK_FILE)
|
||||
if instance_lock is None:
|
||||
# _instance.acquire already logged who holds it; standing down
|
||||
# here (rather than building a second competing window) is the
|
||||
# fix for the actual mechanism behind "unlocked, then locked
|
||||
# again seconds later".
|
||||
_logger.warning(
|
||||
"standing down: another workout-locker instance already holds %s",
|
||||
INSTANCE_LOCK_FILE,
|
||||
)
|
||||
sys.exit(0)
|
||||
self._instance_lock: _instance.InstanceLock = instance_lock
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
self.log_file = script_dir / "log.json"
|
||||
self.verify_only = verify_only
|
||||
@ -169,6 +188,7 @@ class ScreenLocker(
|
||||
self._lock.close()
|
||||
else:
|
||||
self.root.destroy()
|
||||
self._instance_lock.release()
|
||||
# The lock screen coming down is the end of enforcement for this run,
|
||||
# so it belongs in the journal next to the decision that raised it —
|
||||
# otherwise "the lock closed" and "the lock never opened" look alike.
|
||||
|
||||
@ -95,8 +95,18 @@ class ScreenHost(
|
||||
self.root = surface.content.winfo_toplevel()
|
||||
self.demo_mode = False
|
||||
self.verify_only = False
|
||||
# Deliberately NOT the real log.json: is_budget_exhausted() reads it,
|
||||
# and the real file's content depends on today's actual usage --
|
||||
# exhausting the manual-workout budget in production made this crash
|
||||
# (paint_manual_form_with_error touches _mw_error_label, which the
|
||||
# budget-exhausted branch of _show_manual_workout_form never sets).
|
||||
# A path that can never exist keeps every measured screen's shape
|
||||
# independent of live data, which is the same guarantee every other
|
||||
# attribute on this class already gives (see class docstring).
|
||||
self.log_file = (
|
||||
Path(__file__).resolve().parent.parent / "screen_locker" / "log.json"
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "screen_locker"
|
||||
/ "fit_check_never_exists.json"
|
||||
)
|
||||
self._colors = LockConfig()
|
||||
self.container = FrameGroup([surface.content])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user