steam-backlog-enforcer/steam_backlog_enforcer/_ctl_pending.py
2026-10-10 16:07:42 +02:00

183 lines
6.3 KiB
Python

"""The two-phase gaming reset: arm, keep alive for 300 s, then commit.
The friction a typed phrase cannot give is *time*. Arming starts a countdown;
the caller must prove it is still there by heartbeating at least every
``HEARTBEAT_INTERVAL_SECONDS`` (the daemon tolerates ``LAPSE_AFTER_SECONDS``
of silence); only after ``ready_at`` may the reset commit. A closed tab, a dead
server or a crashed browser therefore cancels the reset instead of leaving it
armed for whoever shows up next.
Held in daemon memory on purpose: a daemon restart drops every pending reset,
which fails closed -- the caller just arms again and waits the full countdown.
Intervals use the monotonic clock so a wall-clock step cannot shorten the wait.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import os
import secrets
import threading
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_protocol import (
COUNTDOWN_RUNNING,
PENDING_LAPSED,
CtlError,
)
from steam_backlog_enforcer._friction import GAMING_RESET_COUNTDOWN_SECONDS
if TYPE_CHECKING:
from collections.abc import Callable
HEARTBEAT_INTERVAL_SECONDS: Final = 10
LAPSE_AFTER_SECONDS: Final = 15
# TEST ONLY. Lets the disposable-VM verification run the flow in seconds. It can
# only SHORTEN the countdown (1..299 s), never lengthen it, and an unset or
# invalid value means the real 300 s. It lives in the root-owned unit's
# environment; the unprivileged caller cannot set it.
TEST_COUNTDOWN_ENV: Final = "SBE_CTL_TEST_COUNTDOWN_SECONDS"
def countdown_seconds() -> int:
"""The countdown to enforce: 300 s unless the VM-only override shortens it."""
raw = os.environ.get(TEST_COUNTDOWN_ENV, "")
if raw.isdecimal() and 0 < int(raw) < GAMING_RESET_COUNTDOWN_SECONDS:
return int(raw)
return GAMING_RESET_COUNTDOWN_SECONDS
def _same(left: str, right: str) -> bool:
"""Constant-time id comparison (bytes, so a non-ASCII id cannot raise)."""
return secrets.compare_digest(left.encode(), right.encode())
@dataclass(frozen=True)
class Pending:
"""One armed reset.
Attributes:
pending_id: Unguessable handle the caller presents on every later call.
armed_at: Wall-clock arm time, for display.
ready_at: Wall-clock time the commit becomes allowed, for display.
ready_mono: Monotonic deadline the commit is actually checked against.
last_beat_mono: Monotonic time of the latest heartbeat (arm counts).
"""
pending_id: str
armed_at: datetime
ready_at: datetime
ready_mono: float
last_beat_mono: float
class PendingResets:
"""At most one armed reset, with its countdown and heartbeat bookkeeping."""
def __init__(
self,
clock: Callable[[], float] = time.monotonic,
wall: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
"""Start with nothing armed.
Args:
clock: Monotonic seconds source (injectable for tests).
wall: Wall-clock source for the display timestamps.
"""
self._clock = clock
self._wall = wall
self._lock = threading.Lock()
self._current: Pending | None = None
def arm(self) -> Pending:
"""Arm a reset, or return the live one without restarting its countdown.
Returning the existing one (rather than refusing or restarting) means a
second arm -- a reloaded page, a retry -- can neither shorten nor
extend the wait.
"""
with self._lock:
live = self._live()
if live is not None:
return live
now = self._clock()
wall = self._wall()
wait = countdown_seconds()
self._current = Pending(
pending_id=secrets.token_hex(16),
armed_at=wall,
ready_at=wall + timedelta(seconds=wait),
ready_mono=now + wait,
last_beat_mono=now,
)
return self._current
def beat(self, pending_id: str) -> Pending:
"""Record a heartbeat.
Raises:
CtlError: ``pending_lapsed`` if the id is unknown or already lapsed.
"""
with self._lock:
current = self._require(pending_id)
self._current = Pending(
current.pending_id,
current.armed_at,
current.ready_at,
current.ready_mono,
self._clock(),
)
return self._current
def check_ready(self, pending_id: str) -> Pending:
"""Return the pending reset if it may commit now.
Raises:
CtlError: ``pending_lapsed`` if unknown or lapsed, or
``countdown_running`` (with ``retry_after_seconds``) if early.
"""
with self._lock:
current = self._require(pending_id)
remaining = current.ready_mono - self._clock()
if remaining > 0:
raise CtlError(
COUNTDOWN_RUNNING,
f"the countdown has {remaining:.0f} s left",
{"retry_after_seconds": round(remaining, 1)},
)
return current
def finish(self, pending_id: str) -> None:
"""Drop the pending reset after it committed or was cancelled.
Idempotent: an id that is not (or no longer) armed is not an error.
"""
with self._lock:
current = self._current
if current is not None and _same(current.pending_id, pending_id):
self._current = None
def _live(self) -> Pending | None:
"""The armed reset, or ``None`` -- dropping it first if it has lapsed."""
current = self._current
if current is not None and self._clock() - current.last_beat_mono > (
LAPSE_AFTER_SECONDS
):
self._current = None
return None
return current
def _require(self, pending_id: str) -> Pending:
"""The live reset for *pending_id*; the lock must be held."""
current = self._live()
if current is None or not _same(current.pending_id, pending_id):
raise CtlError(
PENDING_LAPSED,
"no such pending reset, or it lapsed: arm it again",
)
return current