From f77e6018930fcd8d743135e6e034a694530b0273 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 7 Oct 2026 20:36:38 +0200 Subject: [PATCH] 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) --- .gitignore | 1 + install_systemd.sh | 23 +++++++ remove_systemd.sh | 1 + runnerup-watch.service | 13 ++++ screen_locker/_log_mixin.py | 25 +++++++ screen_locker/tests/test_log_mixin_lock.py | 77 ++++++++++++++++++++++ scripts/runnerup_watch.sh | 75 +++++++++++++++++++++ 7 files changed, 215 insertions(+) create mode 100644 runnerup-watch.service create mode 100644 screen_locker/tests/test_log_mixin_lock.py create mode 100755 scripts/runnerup_watch.sh diff --git a/.gitignore b/.gitignore index 60d34a9..38d2c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/install_systemd.sh b/install_systemd.sh index 0799d26..bf8e1e2 100755 --- a/install_systemd.sh +++ b/install_systemd.sh @@ -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. diff --git a/remove_systemd.sh b/remove_systemd.sh index f253470..d1f6d4a 100755 --- a/remove_systemd.sh +++ b/remove_systemd.sh @@ -15,6 +15,7 @@ UNITS=( "workout-sync.service" "earner-bonus.path" "earner-bonus.service" + "runnerup-watch.service" ) for unit in "${UNITS[@]}"; do diff --git a/runnerup-watch.service b/runnerup-watch.service new file mode 100644 index 0000000..63ddc9b --- /dev/null +++ b/runnerup-watch.service @@ -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 diff --git a/screen_locker/_log_mixin.py b/screen_locker/_log_mixin.py index d655b03..0c2419e 100644 --- a/screen_locker/_log_mixin.py +++ b/screen_locker/_log_mixin.py @@ -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) diff --git a/screen_locker/tests/test_log_mixin_lock.py b/screen_locker/tests/test_log_mixin_lock.py new file mode 100644 index 0000000..ff99317 --- /dev/null +++ b/screen_locker/tests/test_log_mixin_lock.py @@ -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 diff --git a/scripts/runnerup_watch.sh b/scripts/runnerup_watch.sh new file mode 100755 index 0000000..1cdd4c9 --- /dev/null +++ b/scripts/runnerup_watch.sh @@ -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 "$@"