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:
Krzysztof Rudnicki 2026-10-07 20:36:38 +02:00
parent 18d3789836
commit f77e601893
7 changed files with 215 additions and 0 deletions

1
.gitignore vendored
View File

@ -28,6 +28,7 @@ screen_locker/extra_benefits_state.json
screen_locker/scheduled_skips.json
screen_locker/shutdown_base.json
screen_locker/shutdown_base.lock
screen_locker/log.lock
screen_locker/sick_day_state.json
screen_locker/early_bird_pending.json

View File

@ -15,6 +15,7 @@ SYNC_SERVICE_NAME="workout-sync.service"
SYNC_TIMER_NAME="workout-sync.timer"
BONUS_SERVICE_NAME="earner-bonus.service"
BONUS_PATH_NAME="earner-bonus.path"
RUNNERUP_WATCH_NAME="runnerup-watch.service"
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
# the tkinter MODULE ships inside the `python` package, but the shared library
@ -37,6 +38,20 @@ ensure_runtime_deps() {
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
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
# 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"
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
REPO_ROOT="$SCRIPT_DIR"
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
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
# 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.

View File

@ -15,6 +15,7 @@ UNITS=(
"workout-sync.service"
"earner-bonus.path"
"earner-bonus.service"
"runnerup-watch.service"
)
for unit in "${UNITS[@]}"; do

13
runnerup-watch.service Normal file
View 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

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import ExitStack
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
@ -11,6 +12,7 @@ from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac
from screen_locker import _compliance_state
from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
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
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)
entries = logs.setdefault(date, [])
prior = list(entries)

View 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
View 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 "$@"