mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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
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:
parent
254d9b3587
commit
1d5965b9a7
@ -7,7 +7,7 @@ requires-python = ">=3.14"
|
|||||||
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
|
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
|
||||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
||||||
dependencies = [
|
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",
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ bandit==1.9.4
|
|||||||
codespell==2.4.3
|
codespell==2.4.3
|
||||||
coverage==7.16.0
|
coverage==7.16.0
|
||||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
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
|
mcp==2.1.1
|
||||||
mypy==2.3.1
|
mypy==2.3.1
|
||||||
pre-commit==4.6.2
|
pre-commit==4.6.2
|
||||||
|
|||||||
@ -27,13 +27,27 @@ import subprocess
|
|||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Every unit that must be armed for a workout-less day to end in a lock.
|
# 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
|
# The timers are what make enforcement recur.
|
||||||
# only ever a login one-shot; the timers are what make enforcement recur.
|
|
||||||
REQUIRED_TIMERS = (
|
REQUIRED_TIMERS = (
|
||||||
"early-bird-workout-check.timer",
|
"early-bird-workout-check.timer",
|
||||||
"workout-locker.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
|
_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
@ -60,20 +74,34 @@ class TimerState:
|
|||||||
return self.enabled and self.scheduled
|
return self.enabled and self.scheduled
|
||||||
|
|
||||||
def describe(self) -> str:
|
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:
|
Returns:
|
||||||
An ``OK``/``DISARMED`` line naming every reason it is not armed.
|
An ``OK``/``DISARMED`` line naming every reason it is not armed.
|
||||||
"""
|
"""
|
||||||
if self.armed:
|
if self.armed:
|
||||||
return f"OK {self.name}: enabled and scheduled"
|
return f"OK {self.name}: {self._ready_phrase}"
|
||||||
problems = []
|
problems = []
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
|
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
|
||||||
if not self.scheduled:
|
if not self.scheduled:
|
||||||
problems.append("no next trigger in list-timers")
|
problems.append(self._missing_phrase)
|
||||||
return f"DISARMED {self.name}: {', '.join(problems)}"
|
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:
|
def systemctl_available() -> bool:
|
||||||
"""Return whether ``systemctl`` can be run at all.
|
"""Return whether ``systemctl`` can be run at all.
|
||||||
@ -128,15 +156,50 @@ def _scheduled_timers() -> set[str]:
|
|||||||
return scheduled
|
return scheduled
|
||||||
|
|
||||||
|
|
||||||
def collect_states() -> list[TimerState]:
|
LOCKER_UNIT = "workout-locker.service"
|
||||||
"""Return the current arming state of every required timer.
|
|
||||||
|
|
||||||
|
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:
|
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()
|
scheduled = _scheduled_timers()
|
||||||
|
login_target_up = _login_target_active()
|
||||||
states = []
|
states = []
|
||||||
for name in REQUIRED_TIMERS:
|
for name in (*REQUIRED_TIMERS, *REQUIRED_SERVICES):
|
||||||
code, out = _run(["systemctl", "--user", "is-enabled", name])
|
code, out = _run(["systemctl", "--user", "is-enabled", name])
|
||||||
raw = out.strip()
|
raw = out.strip()
|
||||||
states.append(
|
states.append(
|
||||||
@ -144,7 +207,13 @@ def collect_states() -> list[TimerState]:
|
|||||||
name=name,
|
name=name,
|
||||||
enabled=code == 0 and raw == "enabled",
|
enabled=code == 0 and raw == "enabled",
|
||||||
enabled_raw=raw,
|
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
|
return states
|
||||||
|
|||||||
@ -236,13 +236,15 @@ def explain_lock_decision(
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
|
||||||
return _stage(
|
return _stage(
|
||||||
fired=True,
|
fired=True,
|
||||||
stage="full_lock_pending_heat_check",
|
stage="would_lock",
|
||||||
reason=(
|
reason=(
|
||||||
"No skip condition applies. The real locker still checks live "
|
"No skip condition applies, so a locker run starting now would "
|
||||||
"Warsaw temperature before showing the full lock — not "
|
"lock. This page re-derives the decision only: no live "
|
||||||
"evaluated here." + degraded_note
|
"temperature is checked, and a run may or may not be happening "
|
||||||
|
"right now (see Health)." + degraded_note
|
||||||
),
|
),
|
||||||
context=stage_context,
|
context=stage_context,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -178,6 +178,15 @@ SYNC_STATE_FILE: Path = (
|
|||||||
)
|
)
|
||||||
SYNC_TIMEOUT_SECONDS: float = 10.0
|
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
|
# 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
|
# right after resume — often seconds before networking is actually up. Such a
|
||||||
# run used to fail outright, and reported it as a permissions problem
|
# run used to fail outright, and reported it as a permissions problem
|
||||||
|
|||||||
@ -30,8 +30,18 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from typing import TYPE_CHECKING
|
||||||
from typing import Final
|
|
||||||
|
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
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DECISION_LOG_FILE",
|
"DECISION_LOG_FILE",
|
||||||
@ -43,17 +53,8 @@ __all__ = [
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Outside the repo on purpose -- workout state is private and git-untracked.
|
# Both live in _decision_trail (which owns the writer) and are re-exported
|
||||||
DECISION_LOG_FILE: Path = (
|
# here, where every reader already looks for them.
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -107,13 +108,6 @@ class LockDecision:
|
|||||||
return record
|
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:
|
def record_decision(decision: LockDecision, *, log_file: Path | None = None) -> None:
|
||||||
"""Log ``decision`` to the journal and append it to the durable trail.
|
"""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:
|
else:
|
||||||
_logger.warning("%s", line)
|
_logger.warning("%s", line)
|
||||||
|
|
||||||
_append_record(decision.as_record(), log_file=log_file)
|
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def record_no_decision(mode: str, *, log_file: Path | None = None) -> None:
|
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;
|
Written at ``info`` because these modes run constantly and are expected;
|
||||||
the durable trail is what makes them auditable, not the log level.
|
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(
|
_logger.info(
|
||||||
"DECISION lock=n/a reason=mode_makes_no_decision mode=%s — this mode "
|
"DECISION lock=n/a reason=mode_makes_no_decision mode=%s "
|
||||||
"never evaluates the lock",
|
"locker_running=%s%s — this mode never evaluates the lock",
|
||||||
mode,
|
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(),
|
"timestamp": datetime.now(tz=UTC).isoformat(),
|
||||||
"locked": None,
|
"locked": None,
|
||||||
"reason": "mode_makes_no_decision",
|
"reason": reason,
|
||||||
"mode": mode,
|
"detail": detail,
|
||||||
},
|
},
|
||||||
log_file=log_file,
|
log_file=log_file,
|
||||||
)
|
)
|
||||||
|
|||||||
130
screen_locker/_decision_trail.py
Normal file
130
screen_locker/_decision_trail.py
Normal 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,
|
||||||
|
)
|
||||||
106
screen_locker/_queue_state.py
Normal file
106
screen_locker/_queue_state.py
Normal 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
|
||||||
46
screen_locker/_queue_wait.py
Normal file
46
screen_locker/_queue_wait.py
Normal 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 ''}.",
|
||||||
|
)
|
||||||
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
from screen_locker._compliance_predicates import is_relaxed_day_skipped_today
|
from screen_locker._compliance_predicates import is_relaxed_day_skipped_today
|
||||||
from screen_locker._constants import (
|
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._extra_benefits import process_week_transition
|
||||||
from screen_locker._shutdown_base import reset_to_base_if_new_day
|
from screen_locker._shutdown_base import reset_to_base_if_new_day
|
||||||
from screen_locker._sync_mixin import SyncMixin
|
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 (
|
from screen_locker._weekly_check import (
|
||||||
WEEKLY_WORKOUT_MINIMUM,
|
WEEKLY_WORKOUT_MINIMUM,
|
||||||
count_weekly_workouts,
|
count_weekly_workouts,
|
||||||
@ -148,47 +152,85 @@ class StartupChecksMixin(SyncMixin):
|
|||||||
# "skip a workout" credit — that mechanic works against the goal of
|
# "skip a workout" credit — that mechanic works against the goal of
|
||||||
# maximizing weekly workouts, so it was removed in favor of a
|
# maximizing weekly workouts, so it was removed in favor of a
|
||||||
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
|
# 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
|
# Nothing excused today: falling through here means the lock WILL be
|
||||||
# built. Recorded explicitly so the trail shows enforcement happening,
|
# built. Recorded explicitly so the trail shows enforcement happening,
|
||||||
# not merely the absence of a skip -- "no line at all" was precisely
|
# not merely the absence of a skip -- "no line at all" was precisely
|
||||||
# the signature of the 2026-08 outage.
|
# 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(
|
self._record_decision(
|
||||||
locked=True,
|
locked=True,
|
||||||
reason="enforced",
|
reason="enforced",
|
||||||
detail="No exemption applied — building the lock screen.",
|
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.
|
"""Exit early if today qualifies for the extreme-heat skip dialog.
|
||||||
|
|
||||||
Fail-closed by construction: a failed or timed-out temperature fetch
|
Fail-closed by construction: a failed or timed-out temperature fetch
|
||||||
falls straight through to the lock, same as "not hot enough" — the
|
falls straight through to the lock, same as "not hot enough" — the
|
||||||
only difference is this logs *why* explicitly, so a fetch failure
|
only difference is this logs *why* explicitly, so a fetch failure
|
||||||
is never silently indistinguishable from a normal day in the logs.
|
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)
|
check = fetch_current_temp_with_status(HEAT_SKIP_CITY)
|
||||||
if check.timed_out:
|
if check.timed_out:
|
||||||
_logger.warning(
|
_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:
|
if check.temp_celsius is None:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Heat-skip temperature check failed (network/API error) — "
|
"Heat-skip temperature check failed (network/API error) — "
|
||||||
"defaulting to lock."
|
"defaulting to lock."
|
||||||
)
|
)
|
||||||
return
|
return "fetch_failed"
|
||||||
if check.temp_celsius < HEAT_SKIP_TEMP_THRESHOLD:
|
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(
|
_logger.info(
|
||||||
"Temperature %.0f°C exceeds threshold — showing heat-skip dialog.",
|
"Temperature %.0f°C exceeds threshold — showing heat-skip dialog.",
|
||||||
check.temp_celsius,
|
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._save_heat_skip_log(check.temp_celsius)
|
||||||
self._record_skip(
|
self._record_skip(
|
||||||
"heat_skip",
|
"heat_skip",
|
||||||
f"User skipped the workout due to heat ({check.temp_celsius:.0f}°C).",
|
f"User skipped the workout due to heat ({check.temp_celsius:.0f}°C).",
|
||||||
temp_celsius=round(check.temp_celsius, 1),
|
temp_celsius=round(check.temp_celsius, 1),
|
||||||
)
|
)
|
||||||
|
return f"{check.temp_celsius:.0f}C_skip_declined"
|
||||||
|
|||||||
@ -6,23 +6,51 @@ user claims about the temperature.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import Future
|
||||||
from concurrent.futures import TimeoutError as _FutureTimeoutError
|
from concurrent.futures import TimeoutError as _FutureTimeoutError
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_WTTR_URL = "https://wttr.in/{city}?format=j1"
|
_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
|
# urlopen's `timeout` bounds the socket connect/read, but not necessarily DNS
|
||||||
# resolution (`getaddrinfo`) on every platform/resolver — a stalled resolver
|
# resolution (`getaddrinfo`) on every platform/resolver — a stalled resolver
|
||||||
# can block far past `_TIMEOUT_SECONDS` with no visible failure. This is a
|
# can block far past `_TIMEOUT_SECONDS` with no visible failure. This is a
|
||||||
# second, hard wall-clock ceiling enforced independently in a worker thread.
|
# 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)
|
@dataclass(frozen=True)
|
||||||
@ -33,6 +61,38 @@ class TemperatureCheck:
|
|||||||
timed_out: bool
|
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:
|
def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
|
||||||
"""Return the current temperature in °C for *city*, or None on failure.
|
"""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
|
May block past `_TIMEOUT_SECONDS` if DNS resolution stalls — callers
|
||||||
should go through `fetch_current_temp_with_status` for a hard bound
|
should go through `fetch_current_temp_with_status` for a hard bound
|
||||||
instead of calling this directly.
|
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=""))
|
url = _WTTR_URL.format(city=urllib.request.quote(city, safe=""))
|
||||||
try:
|
try:
|
||||||
@ -48,11 +114,13 @@ def _fetch_current_temp_celsius_unbounded(city: str) -> float | None:
|
|||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
temp_str = data["current_condition"][0]["temp_C"]
|
temp_str = data["current_condition"][0]["temp_C"]
|
||||||
return float(temp_str)
|
return float(temp_str)
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
except _FETCH_ERRORS as exc:
|
||||||
_logger.warning("Temperature fetch failed (network): %s", exc)
|
_logger.warning(
|
||||||
return None
|
"Temperature fetch failed (%s: %s) — the caller must fail closed "
|
||||||
except (KeyError, IndexError, ValueError, json.JSONDecodeError) as exc:
|
"and lock rather than treat this as 'not hot'",
|
||||||
_logger.warning("Temperature fetch failed (parse): %s", exc)
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -61,21 +129,37 @@ def fetch_current_temp_with_status(
|
|||||||
) -> TemperatureCheck:
|
) -> TemperatureCheck:
|
||||||
"""Fetch °C for *city*, bounded to *hard_timeout* seconds wall-clock.
|
"""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
|
*hard_timeout* regardless of what the network call is doing internally
|
||||||
(DNS, connect, read). The worker thread itself can't be killed and may
|
(DNS, connect, read). The thread itself can't be killed and may keep
|
||||||
keep running in the background after we give up on it — acceptable for
|
running in the background after we give up on it — acceptable for a
|
||||||
a fire-and-forget diagnostic read. Distinguishes "timed out" from other
|
fire-and-forget diagnostic read, and harmless at exit because it is a
|
||||||
failures (network/parse errors) so callers can report which happened.
|
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 = submit_background(lambda: _fetch_current_temp_celsius_unbounded(city))
|
||||||
future = executor.submit(_fetch_current_temp_celsius_unbounded, city)
|
|
||||||
executor.shutdown(wait=False)
|
|
||||||
try:
|
try:
|
||||||
temp = future.result(timeout=hard_timeout)
|
temp = future.result(timeout=hard_timeout)
|
||||||
except _FutureTimeoutError:
|
except _FutureTimeoutError:
|
||||||
_logger.warning(
|
_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)
|
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)
|
return TemperatureCheck(temp_celsius=temp, timed_out=False)
|
||||||
|
|||||||
@ -15,11 +15,10 @@ writes to ``log.json``. Host class must provide:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
|
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:
|
if TYPE_CHECKING:
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@ -83,9 +82,13 @@ class TemperatureStatusMixin:
|
|||||||
before this call returns would trigger a second, redundant render
|
before this call returns would trigger a second, redundant render
|
||||||
from within the same call stack that hasn't finished the first one.
|
from within the same call stack that hasn't finished the first one.
|
||||||
"""
|
"""
|
||||||
executor = ThreadPoolExecutor(max_workers=1)
|
# Shares _temperature.submit_background rather than spinning up its
|
||||||
self._temp_future = executor.submit(self.temperature_fetcher, HEAT_SKIP_CITY)
|
# own ThreadPoolExecutor per open and per refresh: those workers are
|
||||||
executor.shutdown(wait=False)
|
# 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)
|
self.root.after(500, self._poll_temperature_check)
|
||||||
|
|
||||||
def _poll_temperature_check(self) -> None:
|
def _poll_temperature_check(self) -> None:
|
||||||
|
|||||||
95
screen_locker/_web_decisions.py
Normal file
95
screen_locker/_web_decisions.py
Normal 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
|
||||||
@ -18,9 +18,14 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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._decision_log import DECISION_LOG_FILE, read_decisions
|
||||||
from screen_locker._log_io import load_workout_log
|
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_data import gather_status
|
||||||
from screen_locker._status_projection import (
|
from screen_locker._status_projection import (
|
||||||
compliance_state_word,
|
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.
|
# scripts/disarm_guard.sh, which is the thing that writes it.
|
||||||
DISARM_MARKER = Path.home() / ".local" / "share" / "screen_locker" / "DISARMED"
|
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:
|
def workout_credits_today(*, now: datetime | None = None) -> int:
|
||||||
"""Return how many workout credits today has earned so far.
|
"""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)
|
credits_today = workout_credits_today(now=now)
|
||||||
return {
|
return {
|
||||||
"snapshot": asdict(snapshot),
|
"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),
|
"summary_line": format_summary_line(snapshot),
|
||||||
"compliance_state": compliance_state_word(snapshot),
|
"compliance_state": compliance_state_word(snapshot),
|
||||||
# The gaming budget is decided by steam-backlog-enforcer, which owns the
|
# 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:
|
def _log_age_seconds(*, now: datetime | None = None) -> float | None:
|
||||||
"""Return how long ago the workout log was last written.
|
"""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()
|
decisions = read_decisions()
|
||||||
if not decisions:
|
if not decisions:
|
||||||
return None
|
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):
|
if not isinstance(stamp, str):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -27,9 +27,11 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
from screen_locker._web_payload import (
|
from screen_locker._web_decisions import (
|
||||||
DEFAULT_DECISION_LIMIT,
|
DEFAULT_DECISION_LIMIT,
|
||||||
build_decisions_payload,
|
build_decisions_payload,
|
||||||
|
)
|
||||||
|
from screen_locker._web_payload import (
|
||||||
build_health_payload,
|
build_health_payload,
|
||||||
build_status_payload,
|
build_status_payload,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,9 +17,9 @@ from gatelock import (
|
|||||||
Arbiter,
|
Arbiter,
|
||||||
LockWindow,
|
LockWindow,
|
||||||
ScrollableSurface,
|
ScrollableSurface,
|
||||||
wait_for_turn,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from screen_locker._queue_wait import wait_for_screen
|
||||||
from screen_locker._surface_group import FrameGroup
|
from screen_locker._surface_group import FrameGroup
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -93,7 +93,7 @@ class WindowSetupMixin:
|
|||||||
# leetcode_guard's own fix for the same grab race (gatelock's own
|
# leetcode_guard's own fix for the same grab race (gatelock's own
|
||||||
# module docstring: 2026-07-25, and again with diet_guard on
|
# module docstring: 2026-07-25, and again with diet_guard on
|
||||||
# 2026-08-21).
|
# 2026-08-21).
|
||||||
wait_for_turn(arbiter)
|
wait_for_screen(arbiter)
|
||||||
arbiter.acquire_holder()
|
arbiter.acquire_holder()
|
||||||
lock = LockWindow(self.root, self._colors, hooks=self, arbiter=arbiter)
|
lock = LockWindow(self.root, self._colors, hooks=self, arbiter=arbiter)
|
||||||
lock.setup()
|
lock.setup()
|
||||||
|
|||||||
@ -7,6 +7,7 @@ Requires user to log their workout to unlock the screen.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@ -33,6 +34,7 @@ from screen_locker._constants import (
|
|||||||
SCHEDULED_SKIPS_FILE,
|
SCHEDULED_SKIPS_FILE,
|
||||||
SICK_LOCKOUT_SECONDS,
|
SICK_LOCKOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
from screen_locker._decision_log import record_run_aborted
|
||||||
from screen_locker._early_bird import EarlyBirdMixin
|
from screen_locker._early_bird import EarlyBirdMixin
|
||||||
from screen_locker._heat_skip import HeatSkipMixin
|
from screen_locker._heat_skip import HeatSkipMixin
|
||||||
from screen_locker._log_mixin import LogMixin
|
from screen_locker._log_mixin import LogMixin
|
||||||
@ -139,7 +141,35 @@ class ScreenLocker(
|
|||||||
self.workout_data: dict[str, str] = {}
|
self.workout_data: dict[str, str] = {}
|
||||||
self._relaxed_day_mode: bool = False
|
self._relaxed_day_mode: bool = False
|
||||||
self._check_early_exits(verify_only=verify_only)
|
self._check_early_exits(verify_only=verify_only)
|
||||||
self.root = GateRoot()
|
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
|
self.root.on_callback_error = self.on_callback_error
|
||||||
title_suffix = (
|
title_suffix = (
|
||||||
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
||||||
|
|||||||
@ -44,7 +44,21 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|||||||
# The durable lock-decision trail. Written on EVERY locker run, so without
|
# The durable lock-decision trail. Written on EVERY locker run, so without
|
||||||
# this the suite would append test decisions to the user's real
|
# this the suite would append test decisions to the user's real
|
||||||
# enforcement history in ~/.local/share/screen_locker/.
|
# 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
|
# 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.
|
# the whole suite would win it, since it is shared across all of them.
|
||||||
(
|
(
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from screen_locker._degraded_sources import clear_degraded_sources
|
||||||
from screen_locker.tests._gatelock_fixtures import (
|
from screen_locker.tests._gatelock_fixtures import (
|
||||||
FAKE_OUTPUTS,
|
FAKE_OUTPUTS,
|
||||||
TWO_OUTPUTS,
|
TWO_OUTPUTS,
|
||||||
@ -155,6 +156,19 @@ def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[N
|
|||||||
yield
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
|
def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
|
||||||
"""Redirect every on-disk state path to tmp_path for every test.
|
"""Redirect every on-disk state path to tmp_path for every test.
|
||||||
|
|||||||
@ -11,12 +11,15 @@ import subprocess
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from screen_locker._armed_state import (
|
from screen_locker._armed_state import (
|
||||||
|
REQUIRED_SERVICES,
|
||||||
REQUIRED_TIMERS,
|
REQUIRED_TIMERS,
|
||||||
TimerState,
|
TimerState,
|
||||||
collect_states,
|
collect_states,
|
||||||
|
locker_unit_active,
|
||||||
systemctl_available,
|
systemctl_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_ALL_UNITS = (*REQUIRED_TIMERS, *REQUIRED_SERVICES)
|
||||||
_PKG = "screen_locker._armed_state"
|
_PKG = "screen_locker._armed_state"
|
||||||
|
|
||||||
_LISTED = """NEXT LEFT LAST PASSED UNIT
|
_LISTED = """NEXT LEFT LAST PASSED UNIT
|
||||||
@ -46,25 +49,31 @@ class TestTimerState:
|
|||||||
|
|
||||||
def test_enabled_and_scheduled_is_armed(self) -> None:
|
def test_enabled_and_scheduled_is_armed(self) -> None:
|
||||||
"""Both halves present means the timer will really fire."""
|
"""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 state.armed is True
|
||||||
assert "OK" in state.describe()
|
assert "OK" in state.describe()
|
||||||
|
|
||||||
def test_enabled_but_unscheduled_is_not_armed(self) -> None:
|
def test_enabled_but_unscheduled_is_not_armed(self) -> None:
|
||||||
"""The exact 2026-08 failure: enabled, but the job was deleted."""
|
"""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 state.armed is False
|
||||||
assert "no next trigger" in state.describe()
|
assert "no next trigger" in state.describe()
|
||||||
|
|
||||||
def test_scheduled_but_disabled_is_not_armed(self) -> None:
|
def test_scheduled_but_disabled_is_not_armed(self) -> None:
|
||||||
"""A transient start does not survive a reboot."""
|
"""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 state.armed is False
|
||||||
assert "not enabled (is-enabled=disabled)" in state.describe()
|
assert "not enabled (is-enabled=disabled)" in state.describe()
|
||||||
|
|
||||||
def test_unknown_enabled_state_is_named(self) -> None:
|
def test_unknown_enabled_state_is_named(self) -> None:
|
||||||
"""An empty is-enabled reads as unknown, not as a blank."""
|
"""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()
|
assert "is-enabled=unknown" in state.describe()
|
||||||
|
|
||||||
|
|
||||||
@ -86,16 +95,50 @@ class TestCollectStates:
|
|||||||
"""collect_states reads systemd's own view, not the unit files."""
|
"""collect_states reads systemd's own view, not the unit files."""
|
||||||
|
|
||||||
def test_all_armed(self) -> None:
|
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:
|
with patch(f"{_PKG}.subprocess.run") as run:
|
||||||
run.side_effect = [
|
run.side_effect = [
|
||||||
_completed(_LISTED),
|
_completed(_LISTED),
|
||||||
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
|
_completed("active\n"),
|
||||||
|
*[_completed("enabled\n") for _ in _ALL_UNITS],
|
||||||
]
|
]
|
||||||
states = collect_states()
|
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)
|
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:
|
def test_timer_listed_with_no_next_trigger_is_unscheduled(self) -> None:
|
||||||
"""An "n/a" NEXT column means systemd will never run it."""
|
"""An "n/a" NEXT column means systemd will never run it."""
|
||||||
listed = (
|
listed = (
|
||||||
@ -104,7 +147,8 @@ class TestCollectStates:
|
|||||||
with patch(f"{_PKG}.subprocess.run") as run:
|
with patch(f"{_PKG}.subprocess.run") as run:
|
||||||
run.side_effect = [
|
run.side_effect = [
|
||||||
_completed(listed),
|
_completed(listed),
|
||||||
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
|
_completed("inactive\n", code=3),
|
||||||
|
*[_completed("enabled\n") for _ in _ALL_UNITS],
|
||||||
]
|
]
|
||||||
states = collect_states()
|
states = collect_states()
|
||||||
assert all(not s.scheduled for s in states)
|
assert all(not s.scheduled for s in states)
|
||||||
@ -114,7 +158,8 @@ class TestCollectStates:
|
|||||||
with patch(f"{_PKG}.subprocess.run") as run:
|
with patch(f"{_PKG}.subprocess.run") as run:
|
||||||
run.side_effect = [
|
run.side_effect = [
|
||||||
_completed(_LISTED),
|
_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()
|
states = collect_states()
|
||||||
assert all(not s.enabled for s in states)
|
assert all(not s.enabled for s in states)
|
||||||
@ -133,3 +178,37 @@ class TestCollectStates:
|
|||||||
):
|
):
|
||||||
states = collect_states()
|
states = collect_states()
|
||||||
assert all(not s.armed for s in 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()
|
||||||
|
|||||||
@ -82,7 +82,7 @@ class TestExplainLockDecision:
|
|||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
assert result.fired is True
|
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"
|
assert result.auto_upgrade.via == "early_bird_expired"
|
||||||
expired_step = next(
|
expired_step = next(
|
||||||
t for t in result.trace if t.name == "early_bird_pending_expired"
|
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),
|
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||||
)
|
)
|
||||||
assert result.fired is True
|
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.auto_upgrade.via == "none"
|
||||||
assert result.heat_skip_evaluated is False
|
assert result.heat_skip_evaluated is False
|
||||||
|
|
||||||
|
|||||||
@ -19,13 +19,15 @@ from screen_locker._decision_log import (
|
|||||||
LockDecision,
|
LockDecision,
|
||||||
read_decisions,
|
read_decisions,
|
||||||
record_decision,
|
record_decision,
|
||||||
record_no_decision,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_PKG = "screen_locker._decision_log"
|
||||||
|
|
||||||
|
|
||||||
class TestLockDecisionRendering:
|
class TestLockDecisionRendering:
|
||||||
"""The one-line summary must stay greppable and complete."""
|
"""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:
|
class TestReadDecisions:
|
||||||
"""Reading must degrade gracefully, never crash an investigation."""
|
"""Reading must degrade gracefully, never crash an investigation."""
|
||||||
|
|
||||||
|
|||||||
91
screen_locker/tests/test_decision_log_modes.py
Normal file
91
screen_locker/tests/test_decision_log_modes.py
Normal 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)
|
||||||
128
screen_locker/tests/test_decision_trail.py
Normal file
128
screen_locker/tests/test_decision_trail.py
Normal 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
|
||||||
@ -33,7 +33,7 @@ from screen_locker._status_data import (
|
|||||||
def _lock_explanation() -> LockExplanation:
|
def _lock_explanation() -> LockExplanation:
|
||||||
return LockExplanation(
|
return LockExplanation(
|
||||||
fired=True,
|
fired=True,
|
||||||
stage="full_lock_pending_heat_check",
|
stage="would_lock",
|
||||||
reason="No skip condition applies.",
|
reason="No skip condition applies.",
|
||||||
trace=(PredicateResult("scheduled_skip", fired=False, reason="not a skip"),),
|
trace=(PredicateResult("scheduled_skip", fired=False, reason="not a skip"),),
|
||||||
auto_upgrade=AutoUpgradeOpportunity(
|
auto_upgrade=AutoUpgradeOpportunity(
|
||||||
|
|||||||
149
screen_locker/tests/test_queue_state.py
Normal file
149
screen_locker/tests/test_queue_state.py
Normal 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]
|
||||||
@ -6,9 +6,13 @@ halves share the setup_unlock factory in _unlock_helpers.py.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
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
|
from screen_locker.tests._unlock_helpers import setup_unlock
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -91,3 +95,54 @@ class TestUnlockScreenRewards:
|
|||||||
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
|
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
|
||||||
):
|
):
|
||||||
locker.unlock_screen()
|
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()
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class TestGatherStatus:
|
|||||||
assert snap.streak == 0
|
assert snap.streak == 0
|
||||||
assert snap.early_bird_extended is False
|
assert snap.early_bird_extended is False
|
||||||
assert snap.lock_explanation.fired is True
|
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:
|
def test_populated_week_counts_workouts(self, tmp_path: Path) -> None:
|
||||||
"""Populated week counts workouts."""
|
"""Populated week counts workouts."""
|
||||||
|
|||||||
@ -12,14 +12,17 @@ with HTTP 401 for weeks behind a warning nobody read.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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._credential_recovery import RecoveryResult
|
||||||
|
from screen_locker._degraded_sources import _record_degraded
|
||||||
from screen_locker.tests._workout_sync_fixtures import (
|
from screen_locker.tests._workout_sync_fixtures import (
|
||||||
ReachableClient,
|
ReachableClient,
|
||||||
RejectedClient,
|
RejectedClient,
|
||||||
_firebase_config,
|
_firebase_config,
|
||||||
)
|
)
|
||||||
|
from screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -89,3 +92,36 @@ class TestRejectedCredentialTriggersRecovery:
|
|||||||
degraded = _sync_client.degraded_sources()
|
degraded = _sync_client.degraded_sources()
|
||||||
assert degraded, "a credential the server refuses must be recorded"
|
assert degraded, "a credential the server refuses must be recorded"
|
||||||
assert "rejected" in degraded[0].reason
|
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
|
||||||
|
|||||||
@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.client
|
||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
import urllib.error
|
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:
|
def _mock_urlopen_returning(body: bytes) -> MagicMock:
|
||||||
@ -97,3 +103,64 @@ class TestFetchCurrentTempWithStatus:
|
|||||||
):
|
):
|
||||||
result = fetch_current_temp_with_status("Warsaw", hard_timeout=0.05)
|
result = fetch_current_temp_with_status("Warsaw", hard_timeout=0.05)
|
||||||
assert result == TemperatureCheck(temp_celsius=None, timed_out=True)
|
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()
|
||||||
|
|||||||
@ -13,8 +13,8 @@ import json
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from screen_locker._web_decisions import build_decisions_payload
|
||||||
from screen_locker._web_payload import (
|
from screen_locker._web_payload import (
|
||||||
build_decisions_payload,
|
|
||||||
build_status_payload,
|
build_status_payload,
|
||||||
workout_credits_today,
|
workout_credits_today,
|
||||||
)
|
)
|
||||||
@ -23,6 +23,7 @@ if TYPE_CHECKING:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_PKG = "screen_locker._web_payload"
|
_PKG = "screen_locker._web_payload"
|
||||||
|
_DECISIONS_PKG = "screen_locker._web_decisions"
|
||||||
|
|
||||||
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
|
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
@ -130,7 +131,7 @@ class TestDecisionsPayload:
|
|||||||
def test_newest_first(self) -> None:
|
def test_newest_first(self) -> None:
|
||||||
"""The browser reads top-down; the trail is stored oldest-first."""
|
"""The browser reads top-down; the trail is stored oldest-first."""
|
||||||
trail = [{"timestamp": "1"}, {"timestamp": "2"}, {"timestamp": "3"}]
|
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)
|
payload = build_decisions_payload(limit=2)
|
||||||
assert [d["timestamp"] for d in payload["decisions"]] == ["3", "2"]
|
assert [d["timestamp"] for d in payload["decisions"]] == ["3", "2"]
|
||||||
assert payload["total"] == 3
|
assert payload["total"] == 3
|
||||||
@ -139,6 +140,44 @@ class TestDecisionsPayload:
|
|||||||
def test_a_non_positive_limit_returns_everything(self) -> None:
|
def test_a_non_positive_limit_returns_everything(self) -> None:
|
||||||
"""Guards the slice: limit=0 must not silently mean "no history"."""
|
"""Guards the slice: limit=0 must not silently mean "no history"."""
|
||||||
trail = [{"timestamp": "1"}, {"timestamp": "2"}]
|
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)
|
payload = build_decisions_payload(limit=0)
|
||||||
assert payload["returned"] == 2
|
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
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@ -39,6 +40,40 @@ class TestHeatSkipBranch:
|
|||||||
mock_dialog.assert_not_called()
|
mock_dialog.assert_not_called()
|
||||||
mock_sys_exit.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(
|
def test_too_hot_and_user_confirms_skip(
|
||||||
self,
|
self,
|
||||||
mock_tk: MagicMock,
|
mock_tk: MagicMock,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { NowCard } from './NowCard'
|
import { NowCard } from './NowCard'
|
||||||
import { makeStatus } from '../test/factories'
|
import { makeStatus } from '../test/factories'
|
||||||
|
import type { StatusPayload } from '../types'
|
||||||
|
|
||||||
describe('NowCard', () => {
|
describe('NowCard', () => {
|
||||||
it('shows the cut budget and says why when no workout was logged', () => {
|
it('shows the cut budget and says why when no workout was logged', () => {
|
||||||
@ -40,7 +41,7 @@ describe('NowCard', () => {
|
|||||||
lock_explanation: {
|
lock_explanation: {
|
||||||
...base.snapshot.lock_explanation,
|
...base.snapshot.lock_explanation,
|
||||||
fired: true,
|
fired: true,
|
||||||
stage: 'full_lock_pending_heat_check',
|
stage: 'would_lock',
|
||||||
reason: 'No skip condition applies.',
|
reason: 'No skip condition applies.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -89,4 +90,57 @@ describe('NowCard', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.getByText(/degraded/)).toBeInTheDocument()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,7 +7,13 @@ interface Props {
|
|||||||
|
|
||||||
/** The decision as it stands right now, in words rather than slugs. */
|
/** The decision as it stands right now, in words rather than slugs. */
|
||||||
export function NowCard({ status }: Props) {
|
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 { week, lock_explanation: lock } = snapshot
|
||||||
const hours = budgetHours(gaming.workout_today)
|
const hours = budgetHours(gaming.workout_today)
|
||||||
|
|
||||||
@ -18,6 +24,20 @@ export function NowCard({ status }: Props) {
|
|||||||
{lock.fired ? 'Lock would fire' : 'No lock'}
|
{lock.fired ? 'Lock would fire' : 'No lock'}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">{lock.reason}</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">
|
<div className="row">
|
||||||
<span>This week</span>
|
<span>This week</span>
|
||||||
|
|||||||
@ -76,4 +76,74 @@ describe('WhyTable', () => {
|
|||||||
// weekly_required missing falls back to 0 rather than rendering undefined.
|
// weekly_required missing falls back to 0 rather than rendering undefined.
|
||||||
expect(screen.getByText('2/0')).toBeInTheDocument()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -32,7 +32,19 @@ export function WhyTable({ decisions }: Props) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{decisions.decisions.map((decision, index) => (
|
{decisions.decisions.map((decision, index) => (
|
||||||
<tr key={`${decision.timestamp}-${String(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'}>
|
<td className={decision.locked === true ? 'state-lock' : 'muted'}>
|
||||||
{decision.locked === null
|
{decision.locked === null
|
||||||
? '—'
|
? '—'
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
|
|||||||
credits_today: 0,
|
credits_today: 0,
|
||||||
reason: 'no counted workout logged today',
|
reason: 'no counted workout logged today',
|
||||||
},
|
},
|
||||||
|
queue_wait: null,
|
||||||
|
locker_running: true,
|
||||||
...over,
|
...over,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,11 +53,22 @@ export interface GamingFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/status. */
|
/** 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 {
|
export interface StatusPayload {
|
||||||
readonly snapshot: StatusSnapshot
|
readonly snapshot: StatusSnapshot
|
||||||
readonly summary_line: string
|
readonly summary_line: string
|
||||||
readonly compliance_state: string
|
readonly compliance_state: string
|
||||||
readonly gaming: GamingFact
|
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. */
|
/** One recorded decision from decisions.jsonl. */
|
||||||
@ -70,6 +81,14 @@ export interface Decision {
|
|||||||
readonly weekly_required?: number
|
readonly weekly_required?: number
|
||||||
readonly also?: string
|
readonly also?: string
|
||||||
readonly mode?: 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. */
|
/** GET /api/decisions. */
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Workout Screen Locker
|
Description=Workout Screen Locker
|
||||||
After=graphical-session.target
|
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]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
@ -16,12 +16,18 @@ Description=Periodically re-evaluate the workout lock decision
|
|||||||
# early-exit ladder handles "already logged", "window still active",
|
# early-exit ladder handles "already logged", "window still active",
|
||||||
# "weekly minimum met" and "relaxed day" -- so a re-check costs nothing when
|
# "weekly minimum met" and "relaxed day" -- so a re-check costs nothing when
|
||||||
# there is nothing to do.
|
# 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.
|
# Catch up after suspend/downtime rather than waiting for the next half hour.
|
||||||
Persistent=true
|
Persistent=true
|
||||||
# A lock that appears at a hard-predictable second feels like a glitch; a small
|
# 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.
|
# spread also avoids stacking with workout-sync.timer on the same tick.
|
||||||
RandomizedDelaySec=60
|
RandomizedDelaySec=15
|
||||||
Unit=workout-locker.service
|
Unit=workout-locker.service
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user