Close the four gaps that let a decided lock never reach the screen
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 15:09:42 +02:00
parent 254d9b3587
commit 1d5965b9a7
40 changed files with 1681 additions and 169 deletions

View File

@ -7,7 +7,7 @@ requires-python = ">=3.14"
# 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.7.2#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
]

View File

@ -4,7 +4,7 @@ bandit==1.9.4
codespell==2.4.3
coverage==7.16.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.2#subdirectory=gatelock
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
mcp==2.1.1
mypy==2.3.1
pre-commit==4.6.2

View File

@ -27,13 +27,27 @@ import subprocess
_logger = logging.getLogger(__name__)
# Every unit that must be armed for a workout-less day to end in a lock.
# workout-locker.service itself is WantedBy=graphical-session.target and so is
# only ever a login one-shot; the timers are what make enforcement recur.
# The timers are what make enforcement recur.
REQUIRED_TIMERS = (
"early-bird-workout-check.timer",
"workout-locker.timer",
)
# workout-locker.service is WantedBy=graphical-session.target: a login
# one-shot that closes the window between a boot and the next timer tick.
# It is checked here because on 2026-08-30 it had silently drifted to
# `disabled`, the machine rebooted at 14:06, and nothing enforced until
# 14:30 -- while this very module reported "armed" the whole time, because
# it only ever looked at the two timers.
REQUIRED_SERVICES = ("workout-locker.service",)
# The target the login run hangs off. Checked as well as `is-enabled`
# because ~/.config/i3/config notes that i3 "does not reliably activate
# graphical-session.target" -- an enabled service anchored to an inactive
# target is exactly the correct-looking-but-disarmed state this module was
# written to catch.
_LOGIN_TARGET = "graphical-session.target"
_TIMEOUT_SECONDS = 10
@ -60,20 +74,34 @@ class TimerState:
return self.enabled and self.scheduled
def describe(self) -> str:
"""Return a one-line human summary of this timer's state.
"""Return a one-line human summary of this unit's state.
Returns:
An ``OK``/``DISARMED`` line naming every reason it is not armed.
"""
if self.armed:
return f"OK {self.name}: enabled and scheduled"
return f"OK {self.name}: {self._ready_phrase}"
problems = []
if not self.enabled:
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
if not self.scheduled:
problems.append("no next trigger in list-timers")
problems.append(self._missing_phrase)
return f"DISARMED {self.name}: {', '.join(problems)}"
@property
def _ready_phrase(self) -> str:
"""Return the wording for an armed unit of this kind."""
if self.name.endswith(".timer"):
return "enabled and scheduled"
return f"enabled and anchored to an active {_LOGIN_TARGET}"
@property
def _missing_phrase(self) -> str:
"""Return the wording for the not-scheduled half of this kind."""
if self.name.endswith(".timer"):
return "no next trigger in list-timers"
return f"{_LOGIN_TARGET} is not active, so the login run cannot fire"
def systemctl_available() -> bool:
"""Return whether ``systemctl`` can be run at all.
@ -128,15 +156,50 @@ def _scheduled_timers() -> set[str]:
return scheduled
def collect_states() -> list[TimerState]:
"""Return the current arming state of every required timer.
LOCKER_UNIT = "workout-locker.service"
def locker_unit_active() -> bool | None:
"""Return whether the locker unit is running right now.
"The lock would fire" is a statement about the *decision*; it says
nothing about whether anything is currently enforcing it. On 2026-08-30
the machine had rebooted, no locker process existed, and the status page
read exactly the same as it does with a lock on screen.
Returns:
One :class:`TimerState` per entry in :data:`REQUIRED_TIMERS`.
True/False, or None when systemd could not be asked -- which must
never be rendered as "not running".
"""
if not systemctl_available():
return None
code, out = _run(["systemctl", "--user", "is-active", LOCKER_UNIT])
if code != 0 and not out.strip():
return None
return out.strip() == "active"
def _login_target_active() -> bool:
"""Return whether the target the login run hangs off is active.
Returns:
True when ``graphical-session.target`` is active.
"""
code, out = _run(["systemctl", "--user", "is-active", _LOGIN_TARGET])
return code == 0 and out.strip() == "active"
def collect_states() -> list[TimerState]:
"""Return the current arming state of every unit enforcement depends on.
Returns:
One :class:`TimerState` per entry in :data:`REQUIRED_TIMERS` and
:data:`REQUIRED_SERVICES`.
"""
scheduled = _scheduled_timers()
login_target_up = _login_target_active()
states = []
for name in REQUIRED_TIMERS:
for name in (*REQUIRED_TIMERS, *REQUIRED_SERVICES):
code, out = _run(["systemctl", "--user", "is-enabled", name])
raw = out.strip()
states.append(
@ -144,7 +207,13 @@ def collect_states() -> list[TimerState]:
name=name,
enabled=code == 0 and raw == "enabled",
enabled_raw=raw,
scheduled=name in scheduled,
# For a timer this is "systemd has a next trigger for it";
# for the login-run service it is "the target it is wanted by
# is actually up". Both answer the same question: would this
# unit really fire?
scheduled=(
name in scheduled if name.endswith(".timer") else login_target_up
),
)
)
return states

View File

@ -236,13 +236,15 @@ def explain_lock_decision(
if result is not None:
return result
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
return _stage(
fired=True,
stage="full_lock_pending_heat_check",
stage="would_lock",
reason=(
"No skip condition applies. The real locker still checks live "
"Warsaw temperature before showing the full lock — not "
"evaluated here." + degraded_note
"No skip condition applies, so a locker run starting now would "
"lock. This page re-derives the decision only: no live "
"temperature is checked, and a run may or may not be happening "
"right now (see Health)." + degraded_note
),
context=stage_context,
)

View File

@ -178,6 +178,15 @@ SYNC_STATE_FILE: Path = (
)
SYNC_TIMEOUT_SECONDS: float = 10.0
# Published while the locker has DECIDED to lock but is waiting for a
# higher-ranked holder (wake_alarm) to release the screen. From outside the
# process that state is indistinguishable from a hang: on 2026-08-30 the
# locker sat in gatelock's queue for 2h58m and the only evidence anywhere was
# a single INFO line at the moment it started waiting.
QUEUE_STATE_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / "queue_state.json"
)
# Sync runs from the morning routine, which is started right after boot and
# right after resume — often seconds before networking is actually up. Such a
# run used to fail outright, and reported it as a permissions problem

View File

@ -30,8 +30,18 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
import logging
from typing import TYPE_CHECKING
from screen_locker._armed_state import locker_unit_active
from screen_locker._decision_trail import (
DECISION_LOG_FILE,
DECISION_LOG_MAX_ENTRIES,
append_record,
)
from screen_locker._queue_state import read_queue_wait
if TYPE_CHECKING:
from pathlib import Path
from typing import Final
__all__ = [
"DECISION_LOG_FILE",
@ -43,17 +53,8 @@ __all__ = [
_logger = logging.getLogger(__name__)
# Outside the repo on purpose -- workout state is private and git-untracked.
DECISION_LOG_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / "decisions.jsonl"
)
# Trimmed on write so the recurring timers cannot grow this without bound.
# Writers are the 30-minute locker timer plus the 15-minute sync run (which
# records "made no decision"), i.e. ~100 lines/day -> roughly a month of
# history. That comfortably covers the 13-day blind spot this file exists to
# make impossible.
DECISION_LOG_MAX_ENTRIES: Final[int] = 3000
# Both live in _decision_trail (which owns the writer) and are re-exported
# here, where every reader already looks for them.
@dataclass(frozen=True)
@ -107,13 +108,6 @@ class LockDecision:
return record
def _trimmed(lines: list[str], new_line: str) -> list[str]:
"""Return the retained history with ``new_line`` appended."""
if len(lines) >= DECISION_LOG_MAX_ENTRIES:
lines = lines[-(DECISION_LOG_MAX_ENTRIES - 1) :]
return [*lines, new_line]
def record_decision(decision: LockDecision, *, log_file: Path | None = None) -> None:
"""Log ``decision`` to the journal and append it to the durable trail.
@ -129,35 +123,7 @@ def record_decision(decision: LockDecision, *, log_file: Path | None = None) ->
else:
_logger.warning("%s", line)
_append_record(decision.as_record(), log_file=log_file)
def _append_record(record: dict[str, object], *, log_file: Path | None = None) -> None:
"""Append one record to the durable trail, trimming old history.
Never raises: failing to *record* why the locker acted must not stop the
locker from acting. A write failure is reported at ``warning`` rather than
swallowed, so the gap in the trail is itself visible.
"""
target = DECISION_LOG_FILE if log_file is None else log_file
try:
target.parent.mkdir(parents=True, exist_ok=True)
existing = (
target.read_text(encoding="utf-8").splitlines() if target.exists() else []
)
kept = _trimmed(
[entry for entry in existing if entry.strip()],
json.dumps(record),
)
target.write_text("\n".join(kept) + "\n", encoding="utf-8")
except OSError as exc:
_logger.warning(
"Could not append to the decision log at %s (%s) — this run's "
"decision is in the journal only, so the durable trail now has a "
"gap",
target,
exc,
)
append_record(decision.as_record(), log_file=log_file)
def record_no_decision(mode: str, *, log_file: Path | None = None) -> None:
@ -172,17 +138,67 @@ def record_no_decision(mode: str, *, log_file: Path | None = None) -> None:
Written at ``info`` because these modes run constantly and are expected;
the durable trail is what makes them auditable, not the log level.
"""
# What the LOCKER was doing at this moment rides along. systemd cannot
# start a second instance of an already-active Type=simple unit, so on
# 2026-08-30 the 09:30-14:00 timer triggers were silent no-ops and the
# only records in that window were these sync lines -- which said nothing
# about the run that had decided to lock at 09:01 and was still waiting.
# This is the every-15-minutes heartbeat that already exists, so putting
# the observation here needs no new unit in the enforcement path.
locker = locker_unit_active()
waiting = read_queue_wait()
_logger.info(
"DECISION lock=n/a reason=mode_makes_no_decision mode=%s — this mode "
"never evaluates the lock",
"DECISION lock=n/a reason=mode_makes_no_decision mode=%s "
"locker_running=%s%s — this mode never evaluates the lock",
mode,
"unknown" if locker is None else locker,
(
f" queued_behind={','.join(waiting.get('blocked_by', []))}"
if waiting
else ""
),
)
_append_record(
{
record: dict[str, object] = {
"timestamp": datetime.now(tz=UTC).isoformat(),
"locked": None,
"reason": "mode_makes_no_decision",
"mode": mode,
"locker_running": locker,
}
if waiting:
record["queued_behind"] = waiting.get("blocked_by", [])
append_record(record, log_file=log_file)
def record_run_aborted(
reason: str, detail: str, *, log_file: Path | None = None
) -> None:
"""Note that a run could not reach the decision ladder at all.
Distinct from :func:`record_no_decision`, which covers modes that are
*designed* never to decide. This covers a run that meant to decide and
could not -- the case that produced the 2026-08-30 restart loop, where
1695 runs died before recording anything and the only evidence was a
stack trace in the journal.
Written at ``warning``: unlike the sync modes, this is never expected.
Args:
reason: Machine-readable slug for the trail.
detail: One human sentence about what was missing.
log_file: Override for the trail path (for testing).
"""
_logger.warning(
"DECISION lock=n/a reason=%s — %s",
reason,
detail,
)
append_record(
{
"timestamp": datetime.now(tz=UTC).isoformat(),
"locked": None,
"reason": reason,
"detail": detail,
},
log_file=log_file,
)

View File

@ -0,0 +1,130 @@
"""Writing the durable decision trail, including repeat-collapsing.
Split out of ``_decision_log.py`` to keep every file under the 250-line cap.
``_decision_log`` owns *what* a decision is and how it reads in the journal;
this module owns how it lands on disk.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Final
_logger = logging.getLogger(__name__)
# Outside the repo on purpose -- workout state is private and git-untracked.
DECISION_LOG_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / "decisions.jsonl"
)
# Trimmed on write so the recurring timers cannot grow this without bound.
# Writers are the 5-minute locker timer plus the 15-minute sync run (which
# records "made no decision"). Consecutive identical records collapse into
# one row, so a restart loop can no longer evict a month of history.
DECISION_LOG_MAX_ENTRIES: Final[int] = 3000
# Fields that differ between two recordings of the *same* event, and so must
# not stop them collapsing into one row.
_COLLAPSE_VOLATILE_KEYS = frozenset({"timestamp", "repeat_count", "last_timestamp"})
def _is_repeat(previous_line: str, record: dict[str, object]) -> bool:
"""Return whether ``record`` restates the event on ``previous_line``.
Everything but the timestamps and the repeat counter must match, so a
change in ``weekly_count`` or ``detail`` still opens a new row -- only a
genuinely identical event collapses.
Args:
previous_line: The newest line already in the trail.
record: The record about to be written.
Returns:
True when the two describe the same event.
"""
try:
previous = json.loads(previous_line)
except json.JSONDecodeError as exc:
# An unparsable tail is history we cannot compare against, so we keep
# it and start a new row rather than overwrite it. Said out loud: a
# corrupt trail is exactly the thing that must not stay quiet.
_logger.warning(
"Newest line of the decision trail is unparsable (%s) — starting "
"a new row instead of collapsing into it, so nothing is lost",
exc,
)
return False
if not isinstance(previous, dict):
return False
return {
key: value
for key, value in previous.items()
if key not in _COLLAPSE_VOLATILE_KEYS
} == {
key: value
for key, value in record.items()
if key not in _COLLAPSE_VOLATILE_KEYS
}
def _trimmed(lines: list[str], record: dict[str, object]) -> list[str]:
"""Return the retained history with ``record`` folded in.
Consecutive identical events collapse into one row carrying
``repeat_count`` and ``last_timestamp`` instead of appending a new line.
On 2026-08-30 a restart loop (no X server at 02:00, ``Restart=on-failure``
every ~6s) wrote ~1693 identical ``enforced`` records in three hours and
evicted the entire history behind them: the tool built to make blind spots
impossible erased its own. Collapsing bounds any such storm to a single
row, and does the same for the every-15-minutes ``--sync-only`` runs.
Args:
lines: The existing trail, oldest first.
record: The record to fold in.
Returns:
The new trail, oldest first.
"""
if lines and _is_repeat(lines[-1], record):
previous = json.loads(lines[-1])
merged = {
**record,
# The FIRST sighting stays in `timestamp` so the row still says
# when the streak began; readers wanting freshness use
# `last_timestamp` (see _web_payload.decision_age_seconds).
"timestamp": previous.get("timestamp", record["timestamp"]),
"repeat_count": int(previous.get("repeat_count", 1)) + 1,
"last_timestamp": record["timestamp"],
}
return [*lines[:-1], json.dumps(merged)]
if len(lines) >= DECISION_LOG_MAX_ENTRIES:
lines = lines[-(DECISION_LOG_MAX_ENTRIES - 1) :]
return [*lines, json.dumps(record)]
def append_record(record: dict[str, object], *, log_file: Path | None = None) -> None:
"""Append one record to the durable trail, trimming old history.
Never raises: failing to *record* why the locker acted must not stop the
locker from acting. A write failure is reported at ``warning`` rather than
swallowed, so the gap in the trail is itself visible.
"""
target = DECISION_LOG_FILE if log_file is None else log_file
try:
target.parent.mkdir(parents=True, exist_ok=True)
existing = (
target.read_text(encoding="utf-8").splitlines() if target.exists() else []
)
kept = _trimmed([entry for entry in existing if entry.strip()], record)
target.write_text("\n".join(kept) + "\n", encoding="utf-8")
except OSError as exc:
_logger.warning(
"Could not append to the decision log at %s (%s) — this run's "
"decision is in the journal only, so the durable trail now has a "
"gap",
target,
exc,
)

View File

@ -0,0 +1,106 @@
"""Publish the locker's gatelock queue wait so it is visible from outside.
A locker run that has decided to lock but is waiting for a higher-ranked
holder (``wake_alarm``) to release the screen looks, from any other process,
exactly like a hung one: the unit is ``active``, no window is up, and nothing
is written anywhere. On 2026-08-30 that state lasted 2h58m and the status
page still read a bare "Lock would fire", which is what sent the
investigation to the heat check instead.
The blocked run cannot answer questions about itself, so it writes this file
before it starts waiting and removes it when it stops. Readers (the web
status payload) treat its absence as "not queued".
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from typing import Any
from screen_locker._constants import QUEUE_STATE_FILE
_logger = logging.getLogger(__name__)
def publish_queue_wait(blocked_by: tuple[str, ...], elapsed_seconds: float) -> None:
"""Record that this run is queued behind *blocked_by*, or no longer is.
Args:
blocked_by: Apps holding the screen ahead of us; empty means clear.
elapsed_seconds: How long the wait has lasted so far.
"""
if not blocked_by:
clear_queue_wait()
return
record = {
"blocked_by": list(blocked_by),
"elapsed_seconds": round(elapsed_seconds, 1),
"updated": datetime.now(tz=UTC).isoformat(),
}
try:
QUEUE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
QUEUE_STATE_FILE.write_text(json.dumps(record), encoding="utf-8")
except OSError as exc:
_logger.warning(
"Could not publish the queue wait to %s (%s) — the status page "
"will show this run as simply 'would lock' while it is in fact "
"blocked behind %s",
QUEUE_STATE_FILE,
exc,
", ".join(blocked_by),
)
def clear_queue_wait() -> None:
"""Remove the published wait, so readers stop reporting one."""
try:
QUEUE_STATE_FILE.unlink(missing_ok=True)
except OSError as exc:
_logger.warning(
"Could not clear the queue wait at %s (%s) — the status page may "
"keep claiming this run is queued after it has armed",
QUEUE_STATE_FILE,
exc,
)
def read_queue_wait() -> dict[str, Any] | None:
"""Return the currently published wait, or None when there is none.
Returns:
The published record, or None when nothing is queued or the file
could not be read.
"""
# Absence is the normal case (nothing is queued), so it is tested for
# rather than caught -- an `except FileNotFoundError: return None` here
# would be indistinguishable from swallowing a real read failure.
if not QUEUE_STATE_FILE.exists():
return None
try:
raw = QUEUE_STATE_FILE.read_text(encoding="utf-8")
except OSError as exc:
_logger.warning(
"Could not read the queue wait at %s (%s) — reporting 'not "
"queued', which may be wrong",
QUEUE_STATE_FILE,
exc,
)
return None
try:
record = json.loads(raw)
except json.JSONDecodeError as exc:
_logger.warning(
"Queue wait at %s is unparsable (%s) — reporting 'not queued'",
QUEUE_STATE_FILE,
exc,
)
return None
if not isinstance(record, dict):
_logger.warning(
"Queue wait at %s is not an object — reporting 'not queued'",
QUEUE_STATE_FILE,
)
return None
return record

View File

@ -0,0 +1,46 @@
"""Waiting for the screen in gatelock's queue, observably.
Separate from ``_queue_state`` on purpose: that module is a pure state-file
reader/writer, which ``_decision_log`` imports to annotate its sync lines.
Putting this function there would close the cycle
``_queue_state -> _decision_log -> _queue_state``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from gatelock import wait_for_turn
from screen_locker._decision_log import record_run_aborted
from screen_locker._queue_state import publish_queue_wait
if TYPE_CHECKING:
from gatelock import Arbiter
def wait_for_screen(arbiter: Arbiter) -> None:
"""Wait for the screen, publishing the wait and recording how long it took.
gatelock's deadline is left at its 6h default on purpose. A higher-ranked
holder means the screen IS locked, just by someone else -- on 2026-08-30
the 2h58m wait behind ``wake_alarm`` was a real wake-alarm lock held with
the grab and VT, not an unenforced machine. Arming over it early would
draw the workout lock on top of a live alarm, which is the behaviour
gatelock's queue exists to prevent.
Args:
arbiter: Our own published arbiter.
"""
queued = wait_for_turn(arbiter, on_state=publish_queue_wait)
if not queued.queued:
return
# The decision to lock was recorded when the run started, possibly hours
# earlier; without this the trail shows "enforced" and then silence until
# the window finally appears.
record_run_aborted(
"queued_behind_holder",
f"Waited {queued.waited_seconds:.0f}s behind "
f"{', '.join(queued.blocked_by)} before the screen was free; arming "
f"now{' (deadline hit)' if queued.timed_out else ''}.",
)

View File

@ -13,6 +13,7 @@ from __future__ import annotations
import logging
import sys
import tkinter as tk
from screen_locker._compliance_predicates import is_relaxed_day_skipped_today
from screen_locker._constants import (
@ -30,7 +31,10 @@ from screen_locker._degraded_sources import degraded_sources
from screen_locker._extra_benefits import process_week_transition
from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker._sync_mixin import SyncMixin
from screen_locker._temperature import fetch_current_temp_with_status
from screen_locker._temperature import (
HARD_TIMEOUT_SECONDS,
fetch_current_temp_with_status,
)
from screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
@ -148,47 +152,85 @@ class StartupChecksMixin(SyncMixin):
# "skip a workout" credit — that mechanic works against the goal of
# maximizing weekly workouts, so it was removed in favor of a
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
self._check_heat_skip_exit()
heat = self._check_heat_skip_exit()
# Nothing excused today: falling through here means the lock WILL be
# built. Recorded explicitly so the trail shows enforcement happening,
# not merely the absence of a skip -- "no line at all" was precisely
# the signature of the 2026-08 outage.
#
# The heat reading rides along because the check was previously
# invisible on its most common outcome: a grep for "temperat|heat"
# across ~1700 runs returned nothing, so "checked, 27°C, under the
# threshold" and "never got here" looked identical in the trail.
self._record_decision(
locked=True,
reason="enforced",
detail="No exemption applied — building the lock screen.",
heat_check=heat,
)
def _check_heat_skip_exit(self) -> None:
def _check_heat_skip_exit(self) -> str:
"""Exit early if today qualifies for the extreme-heat skip dialog.
Fail-closed by construction: a failed or timed-out temperature fetch
falls straight through to the lock, same as "not hot enough" — the
only difference is this logs *why* explicitly, so a fetch failure
is never silently indistinguishable from a normal day in the logs.
Returns:
A short summary of what the check saw, for the decision record.
Only returns at all when the lock is going ahead; the skip path
exits the process from inside ``_record_skip``.
"""
check = fetch_current_temp_with_status(HEAT_SKIP_CITY)
if check.timed_out:
_logger.warning(
"Heat-skip temperature check timed out — defaulting to lock."
"Heat-skip temperature check timed out after %.1fs — "
"defaulting to lock.",
HARD_TIMEOUT_SECONDS,
)
return
return "timed_out"
if check.temp_celsius is None:
_logger.warning(
"Heat-skip temperature check failed (network/API error) — "
"defaulting to lock."
)
return
return "fetch_failed"
if check.temp_celsius < HEAT_SKIP_TEMP_THRESHOLD:
return
# Logged even though nothing is wrong: this is the check's most
# common outcome and it used to leave no trace at all, so "the
# heat check ran and said no" was indistinguishable from "the
# heat check was never reached".
_logger.info(
"%s is %.0f°C, under the %d°C heat-skip threshold — "
"the lock goes ahead.",
HEAT_SKIP_CITY,
check.temp_celsius,
HEAT_SKIP_TEMP_THRESHOLD,
)
return f"{check.temp_celsius:.0f}C_under_threshold"
_logger.info(
"Temperature %.0f°C exceeds threshold — showing heat-skip dialog.",
check.temp_celsius,
)
if self._show_heat_skip_dialog(check.temp_celsius):
try:
skipped = self._show_heat_skip_dialog(check.temp_celsius)
except tk.TclError as exc:
# The dialog owns its own tk.Tk(). With no reachable display that
# raises here, and letting it escape would kill the process before
# the "enforced" record below is written -- a lock that silently
# never happened. Offering no skip is the fail-closed answer.
_logger.warning(
"Heat-skip dialog could not be shown (%s) — defaulting to "
"lock rather than skipping the workout.",
exc,
)
return f"{check.temp_celsius:.0f}C_dialog_unavailable"
if skipped:
self._save_heat_skip_log(check.temp_celsius)
self._record_skip(
"heat_skip",
f"User skipped the workout due to heat ({check.temp_celsius:.0f}°C).",
temp_celsius=round(check.temp_celsius, 1),
)
return f"{check.temp_celsius:.0f}C_skip_declined"

View File

@ -6,23 +6,51 @@ user claims about the temperature.
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import Future
from concurrent.futures import TimeoutError as _FutureTimeoutError
from dataclasses import dataclass
import http.client
import json
import logging
import threading
from typing import TYPE_CHECKING
import urllib.error
import urllib.request
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_WTTR_URL = "https://wttr.in/{city}?format=j1"
_TIMEOUT_SECONDS = 5
_TIMEOUT_SECONDS = 4
# urlopen's `timeout` bounds the socket connect/read, but not necessarily DNS
# resolution (`getaddrinfo`) on every platform/resolver — a stalled resolver
# can block far past `_TIMEOUT_SECONDS` with no visible failure. This is a
# second, hard wall-clock ceiling enforced independently in a worker thread.
HARD_TIMEOUT_SECONDS = 6.0
# 5.0 is the whole budget the locker is allowed to spend deciding whether it
# is too hot to train; the socket timeout is set below it so the ordinary
# network path fails first and reports *why* rather than just "too slow".
HARD_TIMEOUT_SECONDS = 5.0
# Everything `urlopen` + `json.loads` + the subscripting below can raise.
# Spelled out rather than caught as a blind `except Exception`, which ruff
# rejects (BLE001) and which this repo has no escape hatch for.
# `http.client.HTTPException` (IncompleteRead, BadStatusLine, ...) is the
# one that used to be missing: it inherits Exception, *not* OSError, so it
# escaped this function, escaped `future.result()` (which only catches the
# timeout), and killed the locker before it could record its decision — a
# lock that silently never happened.
_FETCH_ERRORS = (
urllib.error.URLError,
TimeoutError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
KeyError,
IndexError,
ValueError,
)
@dataclass(frozen=True)
@ -33,6 +61,38 @@ class TemperatureCheck:
timed_out: bool
def submit_background[T](fn: Callable[[], T]) -> Future[T]:
"""Run *fn* on a daemon thread and return a Future for its result.
Deliberately not a ``ThreadPoolExecutor``. Its worker threads are
non-daemon and joined by an interpreter-exit hook, so a genuinely stuck
DNS lookup kept the locker's process alive past ``sys.exit(0)`` — the
hard timeout below returned on time while the process did not. A daemon
thread is abandoned at exit, which is the correct behaviour for a
fire-and-forget diagnostic read.
Args:
fn: The zero-argument callable to run off the calling thread.
Returns:
A Future that resolves when *fn* returns.
"""
future: Future[T] = Future()
def _run() -> None:
try:
future.set_result(fn())
except _FETCH_ERRORS as exc:
# Never reachable via _fetch_current_temp_celsius_unbounded, which
# handles these itself; here so a future caller cannot turn a
# background thread into a future that resolves to nothing.
_logger.warning("Background fetch failed: %s", exc)
future.set_exception(exc)
threading.Thread(target=_run, daemon=True, name="temperature-fetch").start()
return future
def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
"""Return the current temperature in °C for *city*, or None on failure.
@ -41,6 +101,12 @@ def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
May block past `_TIMEOUT_SECONDS` if DNS resolution stalls — callers
should go through `fetch_current_temp_with_status` for a hard bound
instead of calling this directly.
Args:
city: The city name to look up, URL-quoted before use.
Returns:
The temperature in °C, or None if it could not be determined.
"""
url = _WTTR_URL.format(city=urllib.request.quote(city, safe=""))
try:
@ -48,11 +114,13 @@ def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
data = json.loads(resp.read())
temp_str = data["current_condition"][0]["temp_C"]
return float(temp_str)
except (urllib.error.URLError, TimeoutError, OSError) as exc:
_logger.warning("Temperature fetch failed (network): %s", exc)
return None
except (KeyError, IndexError, ValueError, json.JSONDecodeError) as exc:
_logger.warning("Temperature fetch failed (parse): %s", exc)
except _FETCH_ERRORS as exc:
_logger.warning(
"Temperature fetch failed (%s: %s) — the caller must fail closed "
"and lock rather than treat this as 'not hot'",
type(exc).__name__,
exc,
)
return None
@ -61,21 +129,37 @@ def fetch_current_temp_with_status(
) -> TemperatureCheck:
"""Fetch °C for *city*, bounded to *hard_timeout* seconds wall-clock.
Runs the fetch in a worker thread and stops waiting on it after
Runs the fetch on a daemon thread and stops waiting on it after
*hard_timeout* regardless of what the network call is doing internally
(DNS, connect, read). The worker thread itself can't be killed and may
keep running in the background after we give up on it — acceptable for
a fire-and-forget diagnostic read. Distinguishes "timed out" from other
failures (network/parse errors) so callers can report which happened.
(DNS, connect, read). The thread itself can't be killed and may keep
running in the background after we give up on it — acceptable for a
fire-and-forget diagnostic read, and harmless at exit because it is a
daemon. Distinguishes "timed out" from other failures (network/parse
errors) so callers can report which happened.
Args:
city: The city name to look up.
hard_timeout: Wall-clock ceiling in seconds.
Returns:
The temperature and whether the wall-clock ceiling was hit.
"""
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(_fetch_current_temp_celsius_unbounded, city)
executor.shutdown(wait=False)
future = submit_background(lambda: _fetch_current_temp_celsius_unbounded(city))
try:
temp = future.result(timeout=hard_timeout)
except _FutureTimeoutError:
_logger.warning(
"Temperature fetch exceeded hard timeout of %.1fs", hard_timeout
"Temperature fetch exceeded hard timeout of %.1fs — the caller "
"must fail closed and lock rather than treat this as 'not hot'",
hard_timeout,
)
return TemperatureCheck(temp_celsius=None, timed_out=True)
except _FETCH_ERRORS as exc:
_logger.warning(
"Temperature fetch raised past its own handler (%s: %s) — "
"reporting failure so the caller fails closed and locks",
type(exc).__name__,
exc,
)
return TemperatureCheck(temp_celsius=None, timed_out=False)
return TemperatureCheck(temp_celsius=temp, timed_out=False)

View File

@ -15,11 +15,10 @@ writes to ``log.json``. Host class must provide:
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from typing import TYPE_CHECKING
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
from screen_locker._temperature import HARD_TIMEOUT_SECONDS
from screen_locker._temperature import HARD_TIMEOUT_SECONDS, submit_background
if TYPE_CHECKING:
import tkinter as tk
@ -83,9 +82,13 @@ class TemperatureStatusMixin:
before this call returns would trigger a second, redundant render
from within the same call stack that hasn't finished the first one.
"""
executor = ThreadPoolExecutor(max_workers=1)
self._temp_future = executor.submit(self.temperature_fetcher, HEAT_SKIP_CITY)
executor.shutdown(wait=False)
# Shares _temperature.submit_background rather than spinning up its
# own ThreadPoolExecutor per open and per refresh: those workers are
# non-daemon and joined at interpreter exit, so one stalled fetch
# held the whole process open past sys.exit(0).
self._temp_future = submit_background(
lambda: self.temperature_fetcher(HEAT_SKIP_CITY)
)
self.root.after(500, self._poll_temperature_check)
def _poll_temperature_check(self) -> None:

View File

@ -0,0 +1,95 @@
"""The decision-history payload, with timestamps rendered server-side.
Split out of ``_web_payload.py`` to keep every file under the 250-line cap.
The local-time rendering lives here rather than in the browser because
LibreWolf's ``privacy.resistFingerprinting`` pins JavaScript's ``Date`` to
UTC: on 2026-08-30 a 14:00 CEST decision rendered as "12:00 PM" beside a
correct, server-computed "6m ago", and the two-hour disagreement made a live
page look stale.
"""
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any
from screen_locker._decision_log import read_decisions
_logger = logging.getLogger(__name__)
# How many decisions the Why view gets by default. The trail holds 3000;
# the browser only ever needs the recent tail to answer "why did nothing
# happen".
DEFAULT_DECISION_LIMIT = 200
def build_decisions_payload(
*,
limit: int = DEFAULT_DECISION_LIMIT,
) -> dict[str, Any]:
"""Build the decision-history payload, newest first.
Args:
limit: Maximum number of decisions to return.
Returns:
The recent decisions and the total the trail holds.
"""
decisions = read_decisions()
tail = decisions[-limit:] if limit > 0 else decisions
return {
"total": len(decisions),
"returned": len(tail),
"decisions": [_with_local_stamps(entry) for entry in reversed(tail)],
}
def _local_stamp(value: object) -> str | None:
"""Render one ISO timestamp in this machine's local time.
Formatted server-side on purpose. The browser used to do it with
``toLocaleString``, which LibreWolf's ``privacy.resistFingerprinting``
pins to UTC -- so on 2026-08-30 a 14:00 CEST decision rendered as
"12:00 PM", two hours stale-looking, right next to a correct
server-computed "6m ago".
Args:
value: The stored ISO-8601 timestamp, if it is one.
Returns:
A local-time string, or None when there is nothing to render.
"""
if not isinstance(value, str):
return None
try:
moment = datetime.fromisoformat(value)
except ValueError:
_logger.warning(
"Decision timestamp %r is unparsable — leaving it unformatted "
"rather than showing a wrong local time",
value,
)
return None
return moment.astimezone().strftime("%b %d, %H:%M")
def _with_local_stamps(entry: dict[str, object]) -> dict[str, object]:
"""Return *entry* with server-rendered local-time fields added.
Args:
entry: One decision record from the trail.
Returns:
The record plus ``local_time`` (and ``local_last_time`` for a
collapsed row).
"""
enriched = dict(entry)
local = _local_stamp(entry.get("timestamp"))
if local is not None:
enriched["local_time"] = local
last_local = _local_stamp(entry.get("last_timestamp"))
if last_local is not None:
enriched["local_last_time"] = last_local
return enriched

View File

@ -18,9 +18,14 @@ import logging
from pathlib import Path
from typing import Any
from screen_locker._armed_state import collect_states, systemctl_available
from screen_locker._armed_state import (
collect_states,
locker_unit_active,
systemctl_available,
)
from screen_locker._decision_log import DECISION_LOG_FILE, read_decisions
from screen_locker._log_io import load_workout_log
from screen_locker._queue_state import read_queue_wait
from screen_locker._status_data import gather_status
from screen_locker._status_projection import (
compliance_state_word,
@ -39,10 +44,6 @@ LOG_FILE = Path(__file__).resolve().parent / "log.json"
# scripts/disarm_guard.sh, which is the thing that writes it.
DISARM_MARKER = Path.home() / ".local" / "share" / "screen_locker" / "DISARMED"
# How many decisions the Why view gets by default. The trail holds 3000; the
# browser only ever needs the recent tail to answer "why did nothing happen".
DEFAULT_DECISION_LIMIT = 200
def workout_credits_today(*, now: datetime | None = None) -> int:
"""Return how many workout credits today has earned so far.
@ -77,6 +78,13 @@ def build_status_payload(*, now: datetime | None = None) -> dict[str, Any]:
credits_today = workout_credits_today(now=now)
return {
"snapshot": asdict(snapshot),
# Whether a locker run has decided to lock and is waiting for a
# higher-ranked holder to release the screen. Without this the page
# says "Lock would fire" both when nothing is running and when a run
# is blocked behind wake_alarm -- two very different situations that
# looked identical on 2026-08-30.
"queue_wait": read_queue_wait(),
"locker_running": locker_unit_active(),
"summary_line": format_summary_line(snapshot),
"compliance_state": compliance_state_word(snapshot),
# The gaming budget is decided by steam-backlog-enforcer, which owns the
@ -94,27 +102,6 @@ def build_status_payload(*, now: datetime | None = None) -> dict[str, Any]:
}
def build_decisions_payload(
*,
limit: int = DEFAULT_DECISION_LIMIT,
) -> dict[str, Any]:
"""Build the decision-history payload, newest first.
Args:
limit: Maximum number of decisions to return.
Returns:
The recent decisions and the total the trail holds.
"""
decisions = read_decisions()
tail = decisions[-limit:] if limit > 0 else decisions
return {
"total": len(decisions),
"returned": len(tail),
"decisions": list(reversed(tail)),
}
def _log_age_seconds(*, now: datetime | None = None) -> float | None:
"""Return how long ago the workout log was last written.
@ -152,7 +139,12 @@ def _last_decision_age_seconds(*, now: datetime | None = None) -> float | None:
decisions = read_decisions()
if not decisions:
return None
stamp = decisions[-1].get("timestamp")
newest = decisions[-1]
# `timestamp` is when a collapsed row's streak *began*, which for the
# every-15-minutes --sync-only runs would report an hours-old trail as
# stale while it is in fact current. `last_timestamp` is the most recent
# sighting, so it is what freshness must be measured from.
stamp = newest.get("last_timestamp", newest.get("timestamp"))
if not isinstance(stamp, str):
return None
try:

View File

@ -27,9 +27,11 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, urlsplit
from screen_locker._web_payload import (
from screen_locker._web_decisions import (
DEFAULT_DECISION_LIMIT,
build_decisions_payload,
)
from screen_locker._web_payload import (
build_health_payload,
build_status_payload,
)

View File

@ -17,9 +17,9 @@ from gatelock import (
Arbiter,
LockWindow,
ScrollableSurface,
wait_for_turn,
)
from screen_locker._queue_wait import wait_for_screen
from screen_locker._surface_group import FrameGroup
if TYPE_CHECKING:
@ -93,7 +93,7 @@ class WindowSetupMixin:
# 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)
wait_for_screen(arbiter)
arbiter.acquire_holder()
lock = LockWindow(self.root, self._colors, hooks=self, arbiter=arbiter)
lock.setup()

View File

@ -7,6 +7,7 @@ Requires user to log their workout to unlock the screen.
from __future__ import annotations
import logging
import os
from pathlib import Path
import sys
import tkinter as tk
@ -33,6 +34,7 @@ from screen_locker._constants import (
SCHEDULED_SKIPS_FILE,
SICK_LOCKOUT_SECONDS,
)
from screen_locker._decision_log import record_run_aborted
from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._heat_skip import HeatSkipMixin
from screen_locker._log_mixin import LogMixin
@ -139,7 +141,35 @@ class ScreenLocker(
self.workout_data: dict[str, str] = {}
self._relaxed_day_mode: bool = False
self._check_early_exits(verify_only=verify_only)
try:
self.root = GateRoot()
except tk.TclError as exc:
_logger.warning(
"Could not open a window on DISPLAY=%s (%s) — this run cannot "
"enforce anything, so it stops cleanly instead of failing and "
"being restarted in a loop.",
os.environ.get("DISPLAY"),
exc,
)
# No reachable X server. This used to escape as an unhandled
# TclError and exit 1, which `Restart=on-failure` turned into a
# storm: on 2026-08-30 the display was down from 02:00 and the
# unit restarted ~1695 times in three hours, ~6.3s apart -- just
# slow enough to stay under the default StartLimitBurst=5/10s, and
# fast enough that the identical records evicted the entire
# decision trail behind them.
#
# There is no screen to lock, so there is nothing this run can
# enforce; exiting 0 (which RestartPreventExitStatus=0 honours)
# is the correct answer, and recording it keeps "could not run"
# from looking like "chose not to lock".
record_run_aborted(
"no_display",
f"No usable X display ({exc}) — nothing to lock, so this run "
f"stops instead of restarting. DISPLAY={os.environ.get('DISPLAY')!r}",
)
self._instance_lock.release()
sys.exit(0)
self.root.on_callback_error = self.on_callback_error
title_suffix = (
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")

View File

@ -44,7 +44,21 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
# The durable lock-decision trail. Written on EVERY locker run, so without
# this the suite would append test decisions to the user's real
# enforcement history in ~/.local/share/screen_locker/.
("decisions.jsonl", ("_decision_log.DECISION_LOG_FILE",)),
# BOTH bindings: _decision_trail owns the writer and reads its own
# module global, so patching only _decision_log would silently let
# the suite write into the real enforcement history.
(
"decisions.jsonl",
(
"_decision_log.DECISION_LOG_FILE",
"_decision_trail.DECISION_LOG_FILE",
),
),
# Written whenever a locker run waits in gatelock's queue.
(
"queue_state.json",
("_constants.QUEUE_STATE_FILE", "_queue_state.QUEUE_STATE_FILE"),
),
# Real $XDG_RUNTIME_DIR/gatelock file; unisolated, only the first test in
# the whole suite would win it, since it is shared across all of them.
(

View File

@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
import pytest
from screen_locker._degraded_sources import clear_degraded_sources
from screen_locker.tests._gatelock_fixtures import (
FAKE_OUTPUTS,
TWO_OUTPUTS,
@ -155,6 +156,19 @@ def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[N
yield
@pytest.fixture(autouse=True)
def _clear_degraded_sources() -> Iterator[None]:
"""Reset the process-scoped degraded-backend registry around every test.
``_degraded_sources._degraded`` is a module-level list, so a test that
records a failed Firebase read leaks into every test after it. Invisible
in file order; only ``pytest-randomly`` (which the CI mirror enables and
the local venvs do not) exposes it -- it failed on push and nowhere else.
"""
clear_degraded_sources()
return
@pytest.fixture(autouse=True)
def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
"""Redirect every on-disk state path to tmp_path for every test.

View File

@ -11,12 +11,15 @@ import subprocess
from unittest.mock import MagicMock, patch
from screen_locker._armed_state import (
REQUIRED_SERVICES,
REQUIRED_TIMERS,
TimerState,
collect_states,
locker_unit_active,
systemctl_available,
)
_ALL_UNITS = (*REQUIRED_TIMERS, *REQUIRED_SERVICES)
_PKG = "screen_locker._armed_state"
_LISTED = """NEXT LEFT LAST PASSED UNIT
@ -46,25 +49,31 @@ class TestTimerState:
def test_enabled_and_scheduled_is_armed(self) -> None:
"""Both halves present means the timer will really fire."""
state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=True)
state = TimerState(
"t.timer", enabled=True, enabled_raw="enabled", scheduled=True
)
assert state.armed is True
assert "OK" in state.describe()
def test_enabled_but_unscheduled_is_not_armed(self) -> None:
"""The exact 2026-08 failure: enabled, but the job was deleted."""
state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=False)
state = TimerState(
"t.timer", enabled=True, enabled_raw="enabled", scheduled=False
)
assert state.armed is False
assert "no next trigger" in state.describe()
def test_scheduled_but_disabled_is_not_armed(self) -> None:
"""A transient start does not survive a reboot."""
state = TimerState("t", enabled=False, enabled_raw="disabled", scheduled=True)
state = TimerState(
"t.timer", enabled=False, enabled_raw="disabled", scheduled=True
)
assert state.armed is False
assert "not enabled (is-enabled=disabled)" in state.describe()
def test_unknown_enabled_state_is_named(self) -> None:
"""An empty is-enabled reads as unknown, not as a blank."""
state = TimerState("t", enabled=False, enabled_raw="", scheduled=False)
state = TimerState("t.timer", enabled=False, enabled_raw="", scheduled=False)
assert "is-enabled=unknown" in state.describe()
@ -86,16 +95,50 @@ class TestCollectStates:
"""collect_states reads systemd's own view, not the unit files."""
def test_all_armed(self) -> None:
"""Both timers enabled and listed with a next trigger."""
"""Every timer enabled and listed, and the login run anchored."""
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(_LISTED),
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
_completed("active\n"),
*[_completed("enabled\n") for _ in _ALL_UNITS],
]
states = collect_states()
assert [s.name for s in states] == list(REQUIRED_TIMERS)
assert [s.name for s in states] == list(_ALL_UNITS)
assert all(s.armed for s in states)
def test_the_login_run_is_checked_too(self) -> None:
"""The 2026-08-30 gap: timers fine, login run silently disabled.
Enforcement read "armed" through a 24-minute unenforced window
because only the timers were ever looked at.
"""
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(_LISTED),
_completed("active\n"),
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
_completed("disabled\n", code=1),
]
states = collect_states()
service = states[-1]
assert service.name == REQUIRED_SERVICES[0]
assert service.armed is False
assert not all(s.armed for s in states)
def test_an_inactive_login_target_disarms_the_service(self) -> None:
"""Enabled is not enough: i3 may never activate the target."""
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(_LISTED),
_completed("inactive\n", code=3),
*[_completed("enabled\n") for _ in _ALL_UNITS],
]
states = collect_states()
service = states[-1]
assert service.enabled is True
assert service.armed is False
assert "is not active" in service.describe()
def test_timer_listed_with_no_next_trigger_is_unscheduled(self) -> None:
"""An "n/a" NEXT column means systemd will never run it."""
listed = (
@ -104,7 +147,8 @@ class TestCollectStates:
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(listed),
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
_completed("inactive\n", code=3),
*[_completed("enabled\n") for _ in _ALL_UNITS],
]
states = collect_states()
assert all(not s.scheduled for s in states)
@ -114,7 +158,8 @@ class TestCollectStates:
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(_LISTED),
*[_completed("disabled\n", code=1) for _ in REQUIRED_TIMERS],
_completed("active\n"),
*[_completed("disabled\n", code=1) for _ in _ALL_UNITS],
]
states = collect_states()
assert all(not s.enabled for s in states)
@ -133,3 +178,37 @@ class TestCollectStates:
):
states = collect_states()
assert all(not s.armed for s in states)
class TestLockerUnitActive:
""" "Would lock" says nothing about whether anything is enforcing it."""
def test_an_active_unit_reads_as_running(self) -> None:
"""The ordinary case while a lock is up."""
with patch(f"{_PKG}.subprocess.run", return_value=_completed("active\n")):
assert locker_unit_active() is True
def test_an_inactive_unit_reads_as_not_running(self) -> None:
"""The 2026-08-30 screenshot: decided to lock, nothing running."""
with patch(
f"{_PKG}.subprocess.run", return_value=_completed("inactive\n", code=3)
):
assert locker_unit_active() is False
def test_no_systemctl_is_unknown_not_false(self) -> None:
""" "Could not ask" must never render as "not running"."""
with patch(f"{_PKG}.shutil.which", return_value=None):
assert locker_unit_active() is None
def test_a_failed_probe_is_unknown_not_false(self) -> None:
"""A systemctl that cannot run answers nothing, not "no"."""
with patch(f"{_PKG}.subprocess.run", side_effect=OSError("no exec")):
assert locker_unit_active() is None
def test_an_armed_login_run_says_what_anchors_it(self) -> None:
"""The Health row must name the target, since that is what drifts."""
state = TimerState(
REQUIRED_SERVICES[0], enabled=True, enabled_raw="enabled", scheduled=True
)
assert state.armed is True
assert "graphical-session.target" in state.describe()

View File

@ -82,7 +82,7 @@ class TestExplainLockDecision:
now=now,
)
assert result.fired is True
assert result.stage == "full_lock_pending_heat_check"
assert result.stage == "would_lock"
assert result.auto_upgrade.via == "early_bird_expired"
expired_step = next(
t for t in result.trace if t.name == "early_bird_pending_expired"
@ -229,7 +229,7 @@ class TestExplainLockDecision:
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
)
assert result.fired is True
assert result.stage == "full_lock_pending_heat_check"
assert result.stage == "would_lock"
assert result.auto_upgrade.via == "none"
assert result.heat_skip_evaluated is False

View File

@ -19,13 +19,15 @@ from screen_locker._decision_log import (
LockDecision,
read_decisions,
record_decision,
record_no_decision,
)
if TYPE_CHECKING:
import pytest
_PKG = "screen_locker._decision_log"
class TestLockDecisionRendering:
"""The one-line summary must stay greppable and complete."""
@ -153,19 +155,6 @@ class TestRecordDecision:
)
class TestRecordNoDecision:
"""Modes that never evaluate the lock must be distinguishable."""
def test_records_mode_and_null_lock(self, tmp_path: Path) -> None:
"""'did not decide' must not look like 'decided not to lock'."""
target = tmp_path / "d.jsonl"
record_no_decision("--sync-only", log_file=target)
(record,) = read_decisions(log_file=target)
assert record["locked"] is None
assert record["reason"] == "mode_makes_no_decision"
assert record["mode"] == "--sync-only"
class TestReadDecisions:
"""Reading must degrade gracefully, never crash an investigation."""

View File

@ -0,0 +1,91 @@
"""Tests for the run-modes that never reach the lock decision.
Split out of test_decision_log.py for the 250-line cap. These cover the two
records that keep "this run did not decide" distinguishable from "this run
decided not to lock" -- the confusion that hid a 13-day enforcement outage.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._decision_log import (
read_decisions,
record_no_decision,
record_run_aborted,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
_PKG = "screen_locker._decision_log"
class TestRecordNoDecision:
"""Modes that never evaluate the lock must be distinguishable."""
def test_records_mode_and_null_lock(self, tmp_path: Path) -> None:
"""'did not decide' must not look like 'decided not to lock'."""
target = tmp_path / "d.jsonl"
record_no_decision("--sync-only", log_file=target)
(record,) = read_decisions(log_file=target)
assert record["locked"] is None
assert record["reason"] == "mode_makes_no_decision"
assert record["mode"] == "--sync-only"
def test_it_reports_what_the_locker_was_doing(self, tmp_path: Path) -> None:
"""The every-15-minutes heartbeat is where this gets observed.
systemd cannot start a second instance of an already-active
Type=simple unit, so on 2026-08-30 the 09:30-14:00 timer triggers were
silent no-ops and these sync lines were the only records in the
window -- saying nothing about the run that had decided to lock at
09:01 and was still waiting behind wake_alarm.
"""
target = tmp_path / "d.jsonl"
with (
patch(f"{_PKG}.locker_unit_active", return_value=True),
patch(
f"{_PKG}.read_queue_wait",
return_value={"blocked_by": ["wake_alarm"], "elapsed_seconds": 9000},
),
):
record_no_decision("--sync-only", log_file=target)
(record,) = read_decisions(log_file=target)
assert record["locker_running"] is True
assert record["queued_behind"] == ["wake_alarm"]
def test_an_unqueued_run_omits_the_blocker_field(self, tmp_path: Path) -> None:
"""No blocker is the normal case; it must not add noise to every row."""
target = tmp_path / "d.jsonl"
with (
patch(f"{_PKG}.locker_unit_active", return_value=False),
patch(f"{_PKG}.read_queue_wait", return_value=None),
):
record_no_decision("--sync-only", log_file=target)
(record,) = read_decisions(log_file=target)
assert "queued_behind" not in record
assert record["locker_running"] is False
class TestRecordRunAborted:
"""A run that meant to decide and could not is its own outcome."""
def test_it_records_the_reason_and_the_detail(self, tmp_path: Path) -> None:
"""1695 runs died at 02:00 leaving only a stack trace behind."""
target = tmp_path / "d.jsonl"
record_run_aborted("no_display", "No usable X display.", log_file=target)
(record,) = read_decisions(log_file=target)
assert record["locked"] is None
assert record["reason"] == "no_display"
assert record["detail"] == "No usable X display."
def test_it_is_loud(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Unlike the sync modes, this is never an expected outcome."""
record_run_aborted(
"no_display", "No usable X display.", log_file=tmp_path / "d"
)
assert any(r.levelname == "WARNING" for r in caplog.records)

View File

@ -0,0 +1,128 @@
"""Tests for _decision_trail: collapsing repeats so a storm cannot wipe history.
On 2026-08-30 the locker had no X server at 02:00, crashed, was restarted by
``Restart=on-failure`` every ~6.3s, and wrote ~1693 identical records in three
hours. At ``DECISION_LOG_MAX_ENTRIES = 3000`` that evicted everything behind
them: the tool built to make blind spots impossible erased its own history.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._decision_trail import append_record
if TYPE_CHECKING:
from pathlib import Path
def _rows(target: Path) -> list[dict[str, object]]:
"""Return the trail as parsed records.
Args:
target: The trail file.
Returns:
One dict per line, oldest first.
"""
return [json.loads(line) for line in target.read_text().splitlines() if line]
def _record(index: int, *, reason: str = "enforced") -> dict[str, object]:
"""Build one record whose only varying field is its timestamp.
Args:
index: Distinguishes the timestamps.
reason: The decision reason.
Returns:
A record ready for append_record.
"""
return {
"timestamp": f"2026-08-30T02:{index:02d}:00+00:00",
"locked": True,
"reason": reason,
}
class TestCollapsing:
"""Consecutive identical events become one row, not N."""
def test_a_storm_of_identical_records_stays_one_row(self, tmp_path: Path) -> None:
"""The 1695-restart loop, in miniature."""
target = tmp_path / "d.jsonl"
for i in range(50):
append_record(_record(i), log_file=target)
rows = _rows(target)
assert len(rows) == 1
assert rows[0]["repeat_count"] == 50
def test_the_row_keeps_both_ends_of_the_streak(self, tmp_path: Path) -> None:
"""`timestamp` is when it began; `last_timestamp` is freshness."""
target = tmp_path / "d.jsonl"
append_record(_record(0), log_file=target)
append_record(_record(7), log_file=target)
row = _rows(target)[0]
assert row["timestamp"] == "2026-08-30T02:00:00+00:00"
assert row["last_timestamp"] == "2026-08-30T02:07:00+00:00"
def test_a_different_decision_opens_a_new_row(self, tmp_path: Path) -> None:
"""Collapsing must never hide a change of outcome."""
target = tmp_path / "d.jsonl"
append_record(_record(0), log_file=target)
append_record(_record(1, reason="weekly_minimum_met"), log_file=target)
assert len(_rows(target)) == 2
def test_only_consecutive_records_collapse(self, tmp_path: Path) -> None:
"""An intervening event ends the streak, as the history shows it must."""
target = tmp_path / "d.jsonl"
append_record(_record(0), log_file=target)
append_record(_record(1, reason="mode_makes_no_decision"), log_file=target)
append_record(_record(2), log_file=target)
rows = _rows(target)
assert len(rows) == 3
assert all("repeat_count" not in row for row in rows)
def test_a_changed_field_is_not_a_repeat(self, tmp_path: Path) -> None:
"""Everything but the timestamps must match to collapse."""
target = tmp_path / "d.jsonl"
append_record(_record(0), log_file=target)
append_record({**_record(1), "weekly_count": 4}, log_file=target)
assert len(_rows(target)) == 2
class TestCorruptTrail:
"""A trail that cannot be parsed is kept, loudly -- never overwritten."""
def test_an_unparsable_tail_starts_a_new_row(
self, tmp_path: Path, caplog: MagicMock
) -> None:
"""Overwriting a line we cannot read would destroy real history."""
target = tmp_path / "d.jsonl"
target.write_text("{truncated\n", encoding="utf-8")
append_record(_record(0), log_file=target)
lines = target.read_text().splitlines()
assert lines[0] == "{truncated"
assert len(lines) == 2
assert "unparsable" in caplog.text
def test_a_non_object_tail_starts_a_new_row(self, tmp_path: Path) -> None:
"""Valid JSON of the wrong shape is not a record to merge into."""
target = tmp_path / "d.jsonl"
target.write_text('["not", "a", "record"]\n', encoding="utf-8")
append_record(_record(0), log_file=target)
assert len(target.read_text().splitlines()) == 2
def test_a_write_failure_is_reported(
self, tmp_path: Path, caplog: MagicMock
) -> None:
"""A gap in the trail must itself be visible."""
target = tmp_path / "nested" / "d.jsonl"
with patch(
"screen_locker._decision_trail.Path.write_text",
side_effect=OSError("full"),
):
append_record(_record(0), log_file=target)
assert "durable trail now has a gap" in caplog.text

View File

@ -33,7 +33,7 @@ from screen_locker._status_data import (
def _lock_explanation() -> LockExplanation:
return LockExplanation(
fired=True,
stage="full_lock_pending_heat_check",
stage="would_lock",
reason="No skip condition applies.",
trace=(PredicateResult("scheduled_skip", fired=False, reason="not a skip"),),
auto_upgrade=AutoUpgradeOpportunity(

View File

@ -0,0 +1,149 @@
"""Tests for _queue_state and _queue_wait: making a blocked run visible.
On 2026-08-30 the locker decided to lock at 09:01, sat in gatelock's queue
behind ``wake_alarm`` until 11:59, and nothing anywhere said so -- the unit
was ``active``, no window was up, and the status page read a bare "Lock would
fire". These cover the file that closes that blind spot.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from gatelock import QueueResult
from screen_locker._queue_state import (
clear_queue_wait,
publish_queue_wait,
read_queue_wait,
)
from screen_locker._queue_wait import wait_for_screen
_PKG = "screen_locker._queue_state"
class TestPublishAndRead:
"""A blocked run cannot answer for itself, so it writes the answer down."""
def test_a_published_wait_reads_back(self) -> None:
"""The round trip a status page depends on."""
publish_queue_wait(("wake_alarm",), 10716.0)
record = read_queue_wait()
assert record is not None
assert record["blocked_by"] == ["wake_alarm"]
assert record["elapsed_seconds"] == 10716.0
def test_no_file_means_not_queued(self) -> None:
"""Absence is the normal case and must not read as a failure."""
clear_queue_wait()
assert read_queue_wait() is None
def test_an_empty_blocker_set_clears_the_wait(self) -> None:
"""gatelock signals "clear" with an empty tuple."""
publish_queue_wait(("wake_alarm",), 5.0)
publish_queue_wait((), 10.0)
assert read_queue_wait() is None
def test_clearing_twice_is_harmless(self) -> None:
"""The locker clears on every exit path, armed or not."""
clear_queue_wait()
clear_queue_wait()
assert read_queue_wait() is None
class TestUnreadableState:
"""Every failure says why: a silent None is a lie about enforcement."""
def test_an_unwritable_path_warns(self, caplog: MagicMock) -> None:
"""A status page that cannot learn of a wait must say so."""
with patch.object(Path, "write_text", side_effect=OSError("readonly")):
publish_queue_wait(("wake_alarm",), 1.0)
assert "Could not publish the queue wait" in caplog.text
def test_an_unremovable_path_warns(self, caplog: MagicMock) -> None:
"""Otherwise the page keeps claiming a wait that ended."""
with patch.object(Path, "unlink", side_effect=OSError("busy")):
clear_queue_wait()
assert "Could not clear the queue wait" in caplog.text
def test_an_unreadable_file_warns(self, caplog: MagicMock) -> None:
""" "Not queued" is a claim, so a failed read cannot make it quietly."""
publish_queue_wait(("wake_alarm",), 1.0)
with patch.object(Path, "read_text", side_effect=OSError("eio")):
assert read_queue_wait() is None
assert "Could not read the queue wait" in caplog.text
def test_unparsable_json_warns(self, caplog: MagicMock, tmp_path: Path) -> None:
"""A truncated write must not crash the status server."""
target = tmp_path / "queue_state.json"
target.write_text("{not json", encoding="utf-8")
with patch(f"{_PKG}.QUEUE_STATE_FILE", target):
assert read_queue_wait() is None
assert "unparsable" in caplog.text
def test_a_non_object_payload_warns(
self, caplog: MagicMock, tmp_path: Path
) -> None:
"""Valid JSON is not automatically the right shape."""
target = tmp_path / "queue_state.json"
target.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
with patch(f"{_PKG}.QUEUE_STATE_FILE", target):
assert read_queue_wait() is None
assert "not an object" in caplog.text
class TestWaitForScreen:
"""The wait itself: published while it happens, recorded when it ends."""
@staticmethod
def _wait(result: QueueResult) -> MagicMock:
"""Run wait_for_screen with a canned queue result.
Args:
result: What gatelock's wait_for_turn should return.
Returns:
The patched ``record_run_aborted`` mock.
"""
with (
patch(
"screen_locker._queue_wait.wait_for_turn", return_value=result
) as turn,
patch("screen_locker._queue_wait.record_run_aborted") as aborted,
):
wait_for_screen(MagicMock())
# The publisher must be handed to gatelock, or the wait stays invisible
# for its whole duration -- only its end would ever be recorded.
assert turn.call_args.kwargs["on_state"] is publish_queue_wait
return aborted
def test_an_unblocked_run_records_nothing(self) -> None:
"""The common case must not add a row to every single locker run."""
aborted = self._wait(
QueueResult(waited_seconds=0.0, blocked_by=(), timed_out=False)
)
aborted.assert_not_called()
def test_a_real_wait_is_recorded_with_its_length(self) -> None:
"""The 2h58m gap between "enforced" and a window on screen."""
aborted = self._wait(
QueueResult(
waited_seconds=10716.0, blocked_by=("wake_alarm",), timed_out=False
)
)
reason, detail = aborted.call_args.args
assert reason == "queued_behind_holder"
assert "10716s" in detail
assert "wake_alarm" in detail
assert "deadline hit" not in detail
def test_hitting_the_deadline_is_named(self) -> None:
"""Arming anyway is a different event from the holder standing down."""
aborted = self._wait(
QueueResult(
waited_seconds=21600.0, blocked_by=("wake_alarm",), timed_out=True
)
)
assert "deadline hit" in aborted.call_args.args[1]

View File

@ -6,9 +6,13 @@ halves share the setup_unlock factory in _unlock_helpers.py.
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker.tests._locker_factories import create_locker
from screen_locker.tests._unlock_helpers import setup_unlock
if TYPE_CHECKING:
@ -91,3 +95,54 @@ class TestUnlockScreenRewards:
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
class TestNoDisplayStandsDown:
"""The 2026-08-30 restart storm: no X server at 02:00.
``GateRoot()`` raised an unhandled ``TclError``, the process exited 1,
``Restart=on-failure`` restarted it ~6.3s later, and 1695 identical runs
in three hours evicted the entire durable decision trail behind them.
"""
@staticmethod
def _build_with_no_display(tmp_path: Path) -> tuple[MagicMock, MagicMock]:
"""Construct a locker whose GateRoot cannot reach a display.
Args:
tmp_path: pytest's per-test directory.
Returns:
The patched ``record_run_aborted`` and ``sys.exit`` mocks.
"""
# conftest stubs sys.exit process-wide, so without a real SystemExit
# here execution would fall through to `self.root`, which was never
# assigned -- and the test would assert on an AttributeError instead
# of on standing down.
with (
patch(
"screen_locker.screen_lock.GateRoot",
side_effect=tk.TclError('couldn\'t connect to display ":0"'),
),
patch("screen_locker.screen_lock.record_run_aborted") as aborted,
patch(
"screen_locker.screen_lock.sys.exit", side_effect=SystemExit(0)
) as exit_mock,
pytest.raises(SystemExit) as raised,
):
create_locker(MagicMock(), tmp_path, demo_mode=False)
assert raised.value.code == 0
return aborted, exit_mock
def test_it_exits_zero_instead_of_failing(self, tmp_path: Path) -> None:
"""Exit 0 is what RestartPreventExitStatus=0 honours."""
_, exit_mock = self._build_with_no_display(tmp_path)
exit_mock.assert_called_once_with(0)
def test_it_records_why_rather_than_going_quiet(self, tmp_path: Path) -> None:
""" "Could not run" must not look like "chose not to lock"."""
aborted, _ = self._build_with_no_display(tmp_path)
aborted.assert_called_once()
reason, detail = aborted.call_args.args
assert reason == "no_display"
assert "display" in detail.lower()

View File

@ -47,7 +47,7 @@ class TestGatherStatus:
assert snap.streak == 0
assert snap.early_bird_extended is False
assert snap.lock_explanation.fired is True
assert snap.lock_explanation.stage == "full_lock_pending_heat_check"
assert snap.lock_explanation.stage == "would_lock"
def test_populated_week_counts_workouts(self, tmp_path: Path) -> None:
"""Populated week counts workouts."""

View File

@ -12,14 +12,17 @@ with HTTP 401 for weeks behind a warning nobody read.
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker import _sync_client, _workout_sync
from screen_locker import _startup_checks, _sync_client, _workout_sync
from screen_locker._credential_recovery import RecoveryResult
from screen_locker._degraded_sources import _record_degraded
from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
RejectedClient,
_firebase_config,
)
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
@ -89,3 +92,36 @@ class TestRejectedCredentialTriggersRecovery:
degraded = _sync_client.degraded_sources()
assert degraded, "a credential the server refuses must be recorded"
assert "rejected" in degraded[0].reason
class TestDecisionNamesUnreadableSources:
"""A weekly count taken over a dead backend is a floor, not a count.
On 2026-08-24 the only trace of a dead Firebase was a warning 90 seconds
earlier, and the DECISION line read ``weekly=0/5`` as though that zero
were a measurement. The decision itself has to say which source it could
not read.
"""
def test_a_degraded_source_is_named_on_the_decision(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The annotation exists precisely so the zero cannot mislead."""
locker = create_locker(mock_tk, tmp_path, has_logged=True)
_record_degraded("firebase", "credential refused")
with patch.object(_startup_checks, "record_decision") as recorded:
locker._record_decision(
locked=True, reason="enforced", detail="No exemption applied."
)
assert recorded.call_args.args[0].extra["unreadable_sources"] == "firebase"
def test_a_healthy_run_adds_no_such_note(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Every decision must not carry an empty caveat."""
locker = create_locker(mock_tk, tmp_path, has_logged=True)
with patch.object(_startup_checks, "record_decision") as recorded:
locker._record_decision(
locked=True, reason="enforced", detail="No exemption applied."
)
assert "unreadable_sources" not in recorded.call_args.args[0].extra

View File

@ -2,11 +2,17 @@
from __future__ import annotations
import http.client
import time
from unittest.mock import MagicMock, patch
import urllib.error
from screen_locker._temperature import TemperatureCheck, fetch_current_temp_with_status
from screen_locker._temperature import (
HARD_TIMEOUT_SECONDS,
TemperatureCheck,
fetch_current_temp_with_status,
submit_background,
)
def _mock_urlopen_returning(body: bytes) -> MagicMock:
@ -97,3 +103,64 @@ class TestFetchCurrentTempWithStatus:
):
result = fetch_current_temp_with_status("Warsaw", hard_timeout=0.05)
assert result == TemperatureCheck(temp_celsius=None, timed_out=True)
class TestNothingEscapesTheFetch:
"""The heat check must never be able to kill the locker.
`http.client.HTTPException` inherits Exception, not OSError, so it used to
escape the fetch, escape `future.result()` (which catches only the
timeout), and take the process down *before* the "enforced" record was
written -- a lock that never happened and left no line anywhere.
"""
def test_an_http_exception_is_a_failed_check_not_a_crash(self) -> None:
"""The specific gap: IncompleteRead is not an OSError."""
with patch(
"screen_locker._temperature.urllib.request.urlopen",
side_effect=http.client.IncompleteRead(b"partial"),
):
result = fetch_current_temp_with_status("Warsaw")
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
def test_a_worker_that_raises_still_resolves_the_future(self) -> None:
"""A future nobody resolves is a hang, not a failure."""
with patch(
"screen_locker._temperature._fetch_current_temp_celsius_unbounded",
side_effect=http.client.BadStatusLine("garbage"),
):
result = fetch_current_temp_with_status("Warsaw")
assert result.temp_celsius is None
assert result.timed_out is False
def test_the_whole_check_is_bounded_by_the_hard_timeout(self) -> None:
"""The user's requirement: at most 5s, then lock regardless."""
with patch(
"screen_locker._temperature._fetch_current_temp_celsius_unbounded",
side_effect=lambda _city: time.sleep(30),
):
started = time.monotonic()
result = fetch_current_temp_with_status("Warsaw")
elapsed = time.monotonic() - started
assert result.timed_out is True
assert elapsed <= HARD_TIMEOUT_SECONDS + 0.5
class TestSubmitBackground:
"""The daemon thread that replaced ThreadPoolExecutor."""
def test_it_resolves_with_the_callables_result(self) -> None:
"""The ordinary path every caller depends on."""
assert submit_background(lambda: 42).result(timeout=5) == 42
def test_the_thread_is_a_daemon(self) -> None:
"""Non-daemon workers held the process open past sys.exit(0).
`concurrent.futures` joins its workers in an interpreter-exit hook, so
a stuck DNS lookup outlived the hard timeout it was supposed to be
bounded by. A daemon thread is abandoned at exit instead.
"""
with patch("screen_locker._temperature.threading.Thread") as thread_cls:
submit_background(lambda: 1)
assert thread_cls.call_args.kwargs["daemon"] is True
thread_cls.return_value.start.assert_called_once()

View File

@ -13,8 +13,8 @@ import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from screen_locker._web_decisions import build_decisions_payload
from screen_locker._web_payload import (
build_decisions_payload,
build_status_payload,
workout_credits_today,
)
@ -23,6 +23,7 @@ if TYPE_CHECKING:
from pathlib import Path
_PKG = "screen_locker._web_payload"
_DECISIONS_PKG = "screen_locker._web_decisions"
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
@ -130,7 +131,7 @@ class TestDecisionsPayload:
def test_newest_first(self) -> None:
"""The browser reads top-down; the trail is stored oldest-first."""
trail = [{"timestamp": "1"}, {"timestamp": "2"}, {"timestamp": "3"}]
with patch(f"{_PKG}.read_decisions", return_value=trail):
with patch(f"{_DECISIONS_PKG}.read_decisions", return_value=trail):
payload = build_decisions_payload(limit=2)
assert [d["timestamp"] for d in payload["decisions"]] == ["3", "2"]
assert payload["total"] == 3
@ -139,6 +140,44 @@ class TestDecisionsPayload:
def test_a_non_positive_limit_returns_everything(self) -> None:
"""Guards the slice: limit=0 must not silently mean "no history"."""
trail = [{"timestamp": "1"}, {"timestamp": "2"}]
with patch(f"{_PKG}.read_decisions", return_value=trail):
with patch(f"{_DECISIONS_PKG}.read_decisions", return_value=trail):
payload = build_decisions_payload(limit=0)
assert payload["returned"] == 2
def test_timestamps_are_rendered_server_side_in_local_time(self) -> None:
"""The browser cannot be trusted to do this.
LibreWolf's resistFingerprinting pins JS ``Date`` to UTC, which is how
a 14:00 CEST decision came to render as "12:00 PM" on 2026-08-30.
"""
trail = [{"timestamp": "2026-08-30T12:00:05+00:00"}]
with patch(f"{_DECISIONS_PKG}.read_decisions", return_value=trail):
payload = build_decisions_payload(limit=1)
rendered = payload["decisions"][0]["local_time"]
expected = (
datetime.fromisoformat("2026-08-30T12:00:05+00:00")
.astimezone()
.strftime("%b %d, %H:%M")
)
assert rendered == expected
def test_a_collapsed_row_reports_both_ends(self) -> None:
"""A repeat-collapsed row must not look like a gap in history."""
trail = [
{
"timestamp": "2026-08-30T12:00:05+00:00",
"last_timestamp": "2026-08-30T12:40:14+00:00",
"repeat_count": 4,
}
]
with patch(f"{_DECISIONS_PKG}.read_decisions", return_value=trail):
row = build_decisions_payload(limit=1)["decisions"][0]
assert row["local_time"] != row["local_last_time"]
assert row["repeat_count"] == 4
def test_an_unparsable_timestamp_is_left_unrendered(self) -> None:
"""Better no local time than a confidently wrong one."""
trail = [{"timestamp": "not-a-timestamp"}]
with patch(f"{_DECISIONS_PKG}.read_decisions", return_value=trail):
row = build_decisions_payload(limit=1)["decisions"][0]
assert "local_time" not in row

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -39,6 +40,40 @@ class TestHeatSkipBranch:
mock_dialog.assert_not_called()
mock_sys_exit.assert_not_called()
def test_a_dialog_that_cannot_be_shown_fails_closed(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""The heat dialog builds its own tk.Tk(), which can fail.
With no reachable display that raises here, and letting it escape
would kill the process *before* the "enforced" record is written --
a lock that silently never happened. Refusing the skip is the only
fail-closed answer.
"""
with (
patch(
"screen_locker._startup_checks.has_weekly_minimum", return_value=False
),
patch(
"screen_locker._startup_checks.fetch_current_temp_with_status",
return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False),
),
patch.object(
ScreenLocker,
"_show_heat_skip_dialog",
side_effect=tk.TclError("no display"),
),
patch.object(ScreenLocker, "_save_heat_skip_log") as saved,
):
create_locker(mock_tk, tmp_path, has_logged=False)
# No skip recorded, and the run continued to build the lock.
saved.assert_not_called()
mock_sys_exit.assert_not_called()
def test_too_hot_and_user_confirms_skip(
self,
mock_tk: MagicMock,

View File

@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { NowCard } from './NowCard'
import { makeStatus } from '../test/factories'
import type { StatusPayload } from '../types'
describe('NowCard', () => {
it('shows the cut budget and says why when no workout was logged', () => {
@ -40,7 +41,7 @@ describe('NowCard', () => {
lock_explanation: {
...base.snapshot.lock_explanation,
fired: true,
stage: 'full_lock_pending_heat_check',
stage: 'would_lock',
reason: 'No skip condition applies.',
},
},
@ -89,4 +90,57 @@ describe('NowCard', () => {
)
expect(screen.getByText(/degraded/)).toBeInTheDocument()
})
/**
* "Lock would fire" is a statement about the decision, not about whether
* anything is enforcing it. On 2026-08-30 the page said exactly that while
* the machine had just rebooted and no locker process existed.
*/
const firing = (over: Partial<StatusPayload> = {}) => {
const base = makeStatus()
return {
...base,
snapshot: {
...base.snapshot,
lock_explanation: { ...base.snapshot.lock_explanation, fired: true },
},
...over,
}
}
it('says when a decided lock is queued behind another holder', () => {
render(
<NowCard
status={firing({
queue_wait: {
blocked_by: ['wake_alarm'],
elapsed_seconds: 10716,
updated: '2026-08-30T09:01:05+00:00',
},
locker_running: true,
})}
/>,
)
expect(screen.getByText(/waiting for/)).toBeInTheDocument()
expect(screen.getByText(/179 min so far/)).toBeInTheDocument()
expect(screen.getByText(/not unlocked/)).toBeInTheDocument()
})
it('says when nothing is enforcing a lock it says would fire', () => {
render(<NowCard status={firing({ locker_running: false })} />)
expect(
screen.getByText(/No locker run is in progress right now/),
).toBeInTheDocument()
})
it('stays quiet when a run is up and unblocked', () => {
render(<NowCard status={firing({ locker_running: true })} />)
expect(screen.queryByText(/No locker run is in progress/)).toBeNull()
expect(screen.queryByText(/waiting for/)).toBeNull()
})
it('says nothing about enforcement when no lock is due', () => {
render(<NowCard status={makeStatus({ locker_running: false })} />)
expect(screen.queryByText(/No locker run is in progress/)).toBeNull()
})
})

View File

@ -7,7 +7,13 @@ interface Props {
/** The decision as it stands right now, in words rather than slugs. */
export function NowCard({ status }: Props) {
const { snapshot, gaming, compliance_state: state } = status
const {
snapshot,
gaming,
compliance_state: state,
queue_wait: queued,
locker_running: running,
} = status
const { week, lock_explanation: lock } = snapshot
const hours = budgetHours(gaming.workout_today)
@ -18,6 +24,20 @@ export function NowCard({ status }: Props) {
{lock.fired ? 'Lock would fire' : 'No lock'}
</p>
<p className="muted">{lock.reason}</p>
{lock.fired && queued !== null && (
<p className="state-lock">
A locker run has already decided to lock and is waiting for{' '}
{queued.blocked_by.join(', ')} to release the screen (
{Math.round(queued.elapsed_seconds / 60)} min so far). The screen is
held by {queued.blocked_by.join(', ')}, not unlocked.
</p>
)}
{lock.fired && queued === null && running === false && (
<p className="state-lock">
No locker run is in progress right now, so nothing is enforcing this.
The next timer tick will start one.
</p>
)}
<div className="row">
<span>This week</span>

View File

@ -76,4 +76,74 @@ describe('WhyTable', () => {
// weekly_required missing falls back to 0 rather than rendering undefined.
expect(screen.getByText('2/0')).toBeInTheDocument()
})
/**
* A restart loop wrote ~1693 identical records on 2026-08-30 and evicted
* the whole trail. They now collapse into one row, which must not then
* read as a gap in history.
*/
it('shows how many times a collapsed row repeated, and when it last did', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T00:02:00+00:00',
locked: true,
reason: 'enforced',
repeat_count: 1693,
last_timestamp: '2026-08-30T03:05:00+00:00',
local_time: 'Aug 30, 02:02',
local_last_time: 'Aug 30, 05:05',
},
],
})}
/>,
)
expect(screen.getByText('Aug 30, 02:02')).toBeInTheDocument()
expect(screen.getByText(/×1693, last Aug 30, 05:05/)).toBeInTheDocument()
})
it('prefers the server-rendered local time over the browser clock', () => {
// LibreWolf's resistFingerprinting pins JS Date to UTC, which rendered a
// 14:00 CEST decision as "12:00 PM" on 2026-08-30.
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
local_time: 'Aug 30, 14:00',
},
],
})}
/>,
)
expect(screen.getByText('Aug 30, 14:00')).toBeInTheDocument()
})
it('falls back to formatting the ISO stamp when the server sent none', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
repeat_count: 3,
},
],
})}
/>,
)
expect(screen.getByText(/×3, last/)).toBeInTheDocument()
})
it('adds no repeat note to a row that happened once', () => {
render(<WhyTable decisions={makeDecisions()} />)
expect(screen.queryByText(/×/)).toBeNull()
})
})

View File

@ -32,7 +32,19 @@ export function WhyTable({ decisions }: Props) {
<tbody>
{decisions.decisions.map((decision, index) => (
<tr key={`${decision.timestamp}-${String(index)}`}>
<td>{formatStamp(decision.timestamp)}</td>
<td>
{decision.local_time ?? formatStamp(decision.timestamp)}
{decision.repeat_count !== undefined &&
decision.repeat_count > 1 && (
<div className="muted">
×{decision.repeat_count}, last{' '}
{decision.local_last_time ??
formatStamp(
decision.last_timestamp ?? decision.timestamp,
)}
</div>
)}
</td>
<td className={decision.locked === true ? 'state-lock' : 'muted'}>
{decision.locked === null
? '—'

View File

@ -40,6 +40,8 @@ export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
credits_today: 0,
reason: 'no counted workout logged today',
},
queue_wait: null,
locker_running: true,
...over,
}
}

View File

@ -53,11 +53,22 @@ export interface GamingFact {
}
/** GET /api/status. */
/** A locker run that has decided to lock but is waiting for the screen. */
export interface QueueWait {
readonly blocked_by: readonly string[]
readonly elapsed_seconds: number
readonly updated: string
}
export interface StatusPayload {
readonly snapshot: StatusSnapshot
readonly summary_line: string
readonly compliance_state: string
readonly gaming: GamingFact
/** Non-null while a run is queued behind a higher-ranked screen holder. */
readonly queue_wait: QueueWait | null
/** Whether workout-locker.service is running; null means "could not ask". */
readonly locker_running: boolean | null
}
/** One recorded decision from decisions.jsonl. */
@ -70,6 +81,14 @@ export interface Decision {
readonly weekly_required?: number
readonly also?: string
readonly mode?: string
/** Set when consecutive identical decisions were folded into one row. */
readonly repeat_count?: number
/** The most recent sighting of a collapsed row; `timestamp` is the first. */
readonly last_timestamp?: string
/** Server-rendered local time. Preferred over formatting `timestamp` here:
* LibreWolf's resistFingerprinting pins the browser's Date to UTC. */
readonly local_time?: string
readonly local_last_time?: string
}
/** GET /api/decisions. */

View File

@ -1,6 +1,13 @@
[Unit]
Description=Workout Screen Locker
After=graphical-session.target
# Backstop for any crash the code does not turn into a clean exit 0. The
# 2026-08-30 no-display loop restarted ~1695 times in three hours at ~6.3s
# apart -- 1.6 per 10s, which slips under systemd's DEFAULT 5-per-10s limit
# forever. Over five minutes that same rate trips this and the unit stops,
# so a restart storm becomes a visibly failed unit instead of a silent one.
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple

View File

@ -16,12 +16,18 @@ Description=Periodically re-evaluate the workout lock decision
# early-exit ladder handles "already logged", "window still active",
# "weekly minimum met" and "relaxed day" -- so a re-check costs nothing when
# there is nothing to do.
OnCalendar=*-*-* *:00,30:00
# Every 5 minutes, not every 30. A reboot leaves the machine unenforced
# until the next tick (the login run below is the primary anchor, but i3 has
# been observed not to activate graphical-session.target reliably), and on
# 2026-08-30 that gap was 24 minutes wide with the lock decided and nothing
# on screen. Each run is idempotent, so a shorter period costs only journal
# lines.
OnCalendar=*-*-* *:00/5
# Catch up after suspend/downtime rather than waiting for the next half hour.
Persistent=true
# A lock that appears at a hard-predictable second feels like a glitch; a small
# spread also avoids stacking with workout-sync.timer on the same tick.
RandomizedDelaySec=60
RandomizedDelaySec=15
Unit=workout-locker.service
[Install]