steam-backlog-enforcer/steam_backlog_enforcer/_ctl_gap.py
2026-10-10 19:15:11 +02:00

102 lines
3.5 KiB
Python

"""Bill the downtime of a requested restart, so restarting is not free play.
The budget measures wall-clock deltas between ticks and clamps each one to two
tick intervals (so a suspend cannot inflate the count). A restart is the one
gap that clamp would wrongly forgive: ``RestartSec`` plus interpreter start plus
the startup pass can be tens of seconds, and the first tick would bill only 6 s
of it -- repeatable free play every ten minutes.
So the exiting daemon leaves a root-only marker with its exit time, and the
next daemon spends it on its first tick by widening that one tick's interval to
cover the gap. The marker is deleted before it is used, expires after
``MAX_GAP_SECONDS`` and only a *requested* restart writes one, so a crash or a
long outage never over-bills and the clamp still protects against suspends.
A tick with no qualifying process bills nothing whatever its interval.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._playtime import playtime_tick
from steam_backlog_enforcer.config import _atomic_write
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_session import PlaytimeSession
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
# Beside the restart rate limit (StateDirectory=, root only).
RESTART_EXIT_FILE: Final = Path("/var/lib/steam-backlog-enforcer/ctl_restart_exit.json")
MAX_GAP_SECONDS: Final = 300.0
_MARKER_MODE: Final = 0o600
def record_exit() -> None:
"""Leave the exit-time marker for the next daemon. Never raises."""
try:
_atomic_write(
RESTART_EXIT_FILE,
json.dumps({"exited_at": time.time()}) + "\n",
mode=_MARKER_MODE,
)
except OSError:
logger.warning("Cannot record the restart exit time; the gap goes unbilled")
def _drop_marker() -> None:
"""Delete the marker; a caller without root rights just leaves it."""
try:
RESTART_EXIT_FILE.unlink(missing_ok=True)
except OSError:
logger.debug("Cannot delete the restart exit marker", exc_info=True)
def _take_exit_time() -> float | None:
"""Read and delete the marker; ``None`` if absent or unusable."""
try:
raw = json.loads(RESTART_EXIT_FILE.read_text(encoding="utf-8"))
exited = raw["exited_at"]
except OSError, ValueError, KeyError, TypeError:
return None
finally:
_drop_marker()
return float(exited) if isinstance(exited, int | float) else None
def settle_restart_gap(
config: Config,
session: PlaytimeSession,
*,
base_interval: float,
demo: bool,
) -> None:
"""Bill the time since a requested restart's exit, once.
Args:
config: Enforcer configuration.
session: The new daemon's logging session.
base_interval: The normal tick interval (lower bound for the widened one).
demo: Whether this is a demo run (never settles).
"""
# A demo run (often the desktop user, via the web UI) must not touch the
# daemon's root-only marker at all, let alone consume it.
if demo:
return
exited = _take_exit_time()
if exited is None:
return
gap = time.time() - exited
if not 0 < gap <= MAX_GAP_SECONDS:
return
logger.info("Billing %.0f s of restart downtime.", gap)
# accumulate() clamps a delta to 2 x interval; this admits exactly the gap.
playtime_tick(
config, interval=max(base_interval, gap / 2 + 1), session=session, demo=demo
)