mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
runnerup-watch: credit a RunnerUp run the moment its TCX upload finishes; lock the workout-log write
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
18d3789836
commit
f77e601893
1
.gitignore
vendored
1
.gitignore
vendored
@ -28,6 +28,7 @@ screen_locker/extra_benefits_state.json
|
|||||||
screen_locker/scheduled_skips.json
|
screen_locker/scheduled_skips.json
|
||||||
screen_locker/shutdown_base.json
|
screen_locker/shutdown_base.json
|
||||||
screen_locker/shutdown_base.lock
|
screen_locker/shutdown_base.lock
|
||||||
|
screen_locker/log.lock
|
||||||
screen_locker/sick_day_state.json
|
screen_locker/sick_day_state.json
|
||||||
screen_locker/early_bird_pending.json
|
screen_locker/early_bird_pending.json
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ SYNC_SERVICE_NAME="workout-sync.service"
|
|||||||
SYNC_TIMER_NAME="workout-sync.timer"
|
SYNC_TIMER_NAME="workout-sync.timer"
|
||||||
BONUS_SERVICE_NAME="earner-bonus.service"
|
BONUS_SERVICE_NAME="earner-bonus.service"
|
||||||
BONUS_PATH_NAME="earner-bonus.path"
|
BONUS_PATH_NAME="earner-bonus.path"
|
||||||
|
RUNNERUP_WATCH_NAME="runnerup-watch.service"
|
||||||
|
|
||||||
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
|
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
|
||||||
# the tkinter MODULE ships inside the `python` package, but the shared library
|
# the tkinter MODULE ships inside the `python` package, but the shared library
|
||||||
@ -37,6 +38,20 @@ ensure_runtime_deps() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# inotifywait drives runnerup-watch.service (instant RunnerUp credit).
|
||||||
|
if ! command -v inotifywait >/dev/null 2>&1; then
|
||||||
|
if command -v pacman >/dev/null 2>&1; then
|
||||||
|
echo "Installing missing system dependency: inotify-tools"
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
pacman -S --needed --noconfirm inotify-tools
|
||||||
|
else
|
||||||
|
sudo pacman -S --needed --noconfirm inotify-tools
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WARNING: inotifywait missing and pacman unavailable - RunnerUp runs are credited only by the 15-min timer" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# The two shared RUNTIME dependencies, both pinned git deps in
|
# The two shared RUNTIME dependencies, both pinned git deps in
|
||||||
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
|
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
|
||||||
# workout-sync transport). Nothing here used to install either, so a fresh
|
# workout-sync transport). Nothing here used to install either, so a fresh
|
||||||
@ -121,6 +136,10 @@ cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_TIMER_NAME"
|
|||||||
cp "$SCRIPT_DIR/$BONUS_SERVICE_NAME" "$USER_SERVICE_DIR/$BONUS_SERVICE_NAME"
|
cp "$SCRIPT_DIR/$BONUS_SERVICE_NAME" "$USER_SERVICE_DIR/$BONUS_SERVICE_NAME"
|
||||||
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_SERVICE_DIR"
|
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_SERVICE_DIR"
|
||||||
|
|
||||||
|
# The RunnerUp upload watch: a TCX landing in ~/data/cloud/RunnerUp starts
|
||||||
|
# workout-sync at once instead of waiting for the next timer tick.
|
||||||
|
cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
|
||||||
|
|
||||||
# Update paths in the service file to use absolute paths
|
# Update paths in the service file to use absolute paths
|
||||||
REPO_ROOT="$SCRIPT_DIR"
|
REPO_ROOT="$SCRIPT_DIR"
|
||||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||||
@ -145,6 +164,10 @@ systemctl --user enable --now "$SYNC_TIMER_NAME"
|
|||||||
# Enable the instant bonus pass on ledger writes
|
# Enable the instant bonus pass on ledger writes
|
||||||
systemctl --user enable --now "$BONUS_PATH_NAME"
|
systemctl --user enable --now "$BONUS_PATH_NAME"
|
||||||
|
|
||||||
|
# Enable the instant RunnerUp credit on upload (restart picks up a new script)
|
||||||
|
systemctl --user enable "$RUNNERUP_WATCH_NAME"
|
||||||
|
systemctl --user restart "$RUNNERUP_WATCH_NAME"
|
||||||
|
|
||||||
# Verify enforcement is actually armed. enable can silently no-op when systemd
|
# Verify enforcement is actually armed. enable can silently no-op when systemd
|
||||||
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
|
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
|
||||||
# on 2026-08-04 -- so a successful `enable` is NOT proof the locker will run.
|
# on 2026-08-04 -- so a successful `enable` is NOT proof the locker will run.
|
||||||
|
|||||||
@ -15,6 +15,7 @@ UNITS=(
|
|||||||
"workout-sync.service"
|
"workout-sync.service"
|
||||||
"earner-bonus.path"
|
"earner-bonus.path"
|
||||||
"earner-bonus.service"
|
"earner-bonus.service"
|
||||||
|
"runnerup-watch.service"
|
||||||
)
|
)
|
||||||
|
|
||||||
for unit in "${UNITS[@]}"; do
|
for unit in "${UNITS[@]}"; do
|
||||||
|
|||||||
13
runnerup-watch.service
Normal file
13
runnerup-watch.service
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Credit a RunnerUp run the moment its TCX upload lands (starts workout-sync)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Long-running inotify watcher on ~/data/cloud/RunnerUp: idle until dufs
|
||||||
|
# finishes writing a *.tcx, then starts workout-sync.service. See the script
|
||||||
|
# header for why this is not a .path unit.
|
||||||
|
ExecStart=%h/src/screen-locker/scripts/runnerup_watch.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import json
|
import json
|
||||||
@ -11,6 +12,7 @@ from typing import TYPE_CHECKING
|
|||||||
from gatelock.log_integrity import compute_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from screen_locker import _compliance_state
|
from screen_locker import _compliance_state
|
||||||
|
from screen_locker._bonus_lock import bonus_lock
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
||||||
@ -111,7 +113,30 @@ def write_signed_entry(
|
|||||||
|
|
||||||
Returns a :class:`RecordResult` so the credit path can tell an appended
|
Returns a :class:`RecordResult` so the credit path can tell an appended
|
||||||
workout from a duplicate and a first-of-day workout from an additional one.
|
workout from a duplicate and a first-of-day workout from an additional one.
|
||||||
|
|
||||||
|
The read-dedup-write runs under an exclusive lock beside the log: the
|
||||||
|
5-minute locker pass, the 15-minute sync and the RunnerUp upload watcher
|
||||||
|
all backfill the same run, and two of them reading before either wrote
|
||||||
|
would both see it missing -- two entries and, since only an appended
|
||||||
|
result is credited, two shutdown rewards.
|
||||||
"""
|
"""
|
||||||
|
with ExitStack() as stack:
|
||||||
|
try:
|
||||||
|
stack.enter_context(bonus_lock(log_file))
|
||||||
|
except OSError as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not lock workout log %s (%s) — writing without the lock; "
|
||||||
|
"a pass running at the same moment could record this twice",
|
||||||
|
log_file,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return _write_signed_entry_locked(log_file, date, workout_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_signed_entry_locked(
|
||||||
|
log_file: Path, date: str, workout_data: Mapping[str, object]
|
||||||
|
) -> RecordResult:
|
||||||
|
"""Body of :func:`write_signed_entry`; the caller holds the log lock."""
|
||||||
logs = load_workout_log(log_file)
|
logs = load_workout_log(log_file)
|
||||||
entries = logs.setdefault(date, [])
|
entries = logs.setdefault(date, [])
|
||||||
prior = list(entries)
|
prior = list(entries)
|
||||||
|
|||||||
77
screen_locker/tests/test_log_mixin_lock.py
Normal file
77
screen_locker/tests/test_log_mixin_lock.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for the lock around the workout-log write chokepoint.
|
||||||
|
|
||||||
|
The 5-minute locker pass, the 15-minute sync and the RunnerUp upload watcher
|
||||||
|
can backfill the same run at the same moment. Without the lock each one reads
|
||||||
|
the log before the others wrote, sees the run missing, appends it and earns
|
||||||
|
its own shutdown credit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from screen_locker import _log_mixin
|
||||||
|
from screen_locker._log_io import load_workout_log
|
||||||
|
from screen_locker._log_mixin import write_signed_entry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_RUN = {"type": "runnerup_verified", "distance_km": 4.7, "duration_minutes": 54.0}
|
||||||
|
_WRITERS = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _slow_loader(log_file: Path) -> dict:
|
||||||
|
"""Load the log, then stall so racing writers all read before any writes."""
|
||||||
|
logs = load_workout_log(log_file)
|
||||||
|
time.sleep(0.05)
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteLock:
|
||||||
|
"""write_signed_entry serialises its read-dedup-write across writers."""
|
||||||
|
|
||||||
|
def test_racing_writers_record_the_run_once(self, tmp_path: Path) -> None:
|
||||||
|
"""Concurrent appends of one run yield one entry and one ``appended``.
|
||||||
|
|
||||||
|
flock conflicts between separate open() calls even inside one
|
||||||
|
process, so threads exercise the same lock the separate systemd
|
||||||
|
passes contend on.
|
||||||
|
"""
|
||||||
|
log_file = tmp_path / "log.json"
|
||||||
|
barrier = threading.Barrier(_WRITERS)
|
||||||
|
appended: list[bool] = []
|
||||||
|
|
||||||
|
def writer() -> None:
|
||||||
|
barrier.wait()
|
||||||
|
appended.append(write_signed_entry(log_file, "2026-10-07", _RUN).appended)
|
||||||
|
|
||||||
|
with patch.object(_log_mixin, "load_workout_log", _slow_loader):
|
||||||
|
threads = [threading.Thread(target=writer) for _ in range(_WRITERS)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
assert sorted(appended) == [False] * (_WRITERS - 1) + [True]
|
||||||
|
assert len(load_workout_log(log_file)["2026-10-07"]) == 1
|
||||||
|
|
||||||
|
def test_unlockable_log_warns_and_still_writes(
|
||||||
|
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A lock file that cannot be opened is a warning, not a lost workout."""
|
||||||
|
log_file = tmp_path / "log.json"
|
||||||
|
log_file.with_suffix(".lock").mkdir() # open("a") on a dir -> OSError
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = write_signed_entry(log_file, "2026-10-07", _RUN)
|
||||||
|
|
||||||
|
assert result.appended is True
|
||||||
|
assert len(load_workout_log(log_file)["2026-10-07"]) == 1
|
||||||
|
assert "Could not lock workout log" in caplog.text
|
||||||
75
scripts/runnerup_watch.sh
Executable file
75
scripts/runnerup_watch.sh
Executable file
@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Credit a RunnerUp run the moment its TCX upload lands on this machine.
|
||||||
|
#
|
||||||
|
# RunnerUp PUTs each export to dufs, which writes it straight into
|
||||||
|
# ~/data/cloud/RunnerUp: CREATE fires when the upload starts, CLOSE_WRITE when
|
||||||
|
# it ends. So this reacts to close_write/moved_to only -- a systemd .path unit
|
||||||
|
# (PathChanged=) fires on CREATE, hands the pass a half-written TCX and drops
|
||||||
|
# the close event while that pass runs, which left a 2026-10-07 run uncredited
|
||||||
|
# until the next timer tick.
|
||||||
|
#
|
||||||
|
# Each finished *.tcx starts workout-sync.service (blocking), which ingests it
|
||||||
|
# and applies the shutdown credit. systemd serialises that against the 15-min
|
||||||
|
# timer run; when one was already in flight it started before this file was
|
||||||
|
# complete, so the start merged into it and a second start is needed.
|
||||||
|
#
|
||||||
|
# Run by runnerup-watch.service (Restart=always). Event-driven: no polling.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly WATCH_DIR="${RUNNERUP_WATCH_DIR:-$HOME/data/cloud/RunnerUp}"
|
||||||
|
readonly SYNC_UNIT="${RUNNERUP_SYNC_UNIT:-workout-sync.service}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
start_sync() {
|
||||||
|
if ! systemctl --user start "$SYNC_UNIT"; then
|
||||||
|
log "WARNING: $SYNC_UNIT failed after a RunnerUp upload — the run" \
|
||||||
|
"is NOT credited yet; see journalctl --user -u $SYNC_UNIT." \
|
||||||
|
"The 15-min timer retries it."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ingest() {
|
||||||
|
local name="$1" state
|
||||||
|
state="$(systemctl --user is-active "$SYNC_UNIT" || true)"
|
||||||
|
log "RunnerUp upload finished: $name — starting $SYNC_UNIT"
|
||||||
|
start_sync || return 0
|
||||||
|
if [[ $state == activating ]]; then
|
||||||
|
log "$SYNC_UNIT was already running when $name landed — running it again"
|
||||||
|
start_sync || return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_requirements() {
|
||||||
|
if ! command -v inotifywait >/dev/null 2>&1; then
|
||||||
|
log "ERROR: inotifywait missing (pacman -S inotify-tools) — RunnerUp" \
|
||||||
|
"uploads are only credited by the 15-min timer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d $WATCH_DIR ]]; then
|
||||||
|
log "ERROR: $WATCH_DIR does not exist — is dufs-cloud set up?" \
|
||||||
|
"RunnerUp uploads are only credited by the 15-min timer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
validate_requirements
|
||||||
|
log "Watching $WATCH_DIR for finished RunnerUp TCX uploads"
|
||||||
|
local name
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[[ $name == *.tcx ]] || continue
|
||||||
|
ingest "$name"
|
||||||
|
done < <(inotifywait -m -q -e close_write -e moved_to --format '%f' "$WATCH_DIR")
|
||||||
|
log "ERROR: inotifywait on $WATCH_DIR exited — restarting via systemd"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Loading…
Reference in New Issue
Block a user