mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
90 lines
3.1 KiB
Python
90 lines
3.1 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 _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:
|
|
RESTART_EXIT_FILE.unlink(missing_ok=True)
|
|
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).
|
|
"""
|
|
exited = _take_exit_time()
|
|
if demo or 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
|
|
)
|