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

105 lines
3.6 KiB
Python

"""The two-phase gaming reset as control-socket ops.
``gaming_reset_arm`` -> (``gaming_reset_heartbeat`` every <= 10 s for the whole
countdown) -> ``gaming_reset_commit``. The phrase is checked at arm *and*
again at commit, the countdown and heartbeat bookkeeping live in
:mod:`steam_backlog_enforcer._ctl_pending`, and the reset itself is the same
core the CLI runs (:func:`steam_backlog_enforcer._gaming_reset.reset_today`).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_context import (
check_locks,
hold_tick,
require_phrase,
str_arg,
)
from steam_backlog_enforcer._ctl_pending import (
HEARTBEAT_INTERVAL_SECONDS,
LAPSE_AFTER_SECONDS,
)
from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError
from steam_backlog_enforcer._friction import expected_phrase
from steam_backlog_enforcer._gaming_reset import reset_today
if TYPE_CHECKING:
from steam_backlog_enforcer._ctl_context import CtlContext
from steam_backlog_enforcer._ctl_pending import Pending
logger = logging.getLogger(__name__)
_COMMAND: Final = "gaming-reset"
def _view(pending: Pending) -> dict[str, object]:
"""The wire form of a pending reset (the contract's ``PendingAction``)."""
return {
"pending_id": pending.pending_id,
"phrase": expected_phrase(_COMMAND),
"armed_at": pending.armed_at.isoformat(),
"ready_at": pending.ready_at.isoformat(),
"heartbeat_interval_seconds": HEARTBEAT_INTERVAL_SECONDS,
"lapse_after_seconds": LAPSE_AFTER_SECONDS,
}
def op_arm(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Start the countdown (or return the one already running)."""
require_phrase(_COMMAND, args.get("phrase"))
check_locks(_COMMAND)
pending = ctx.pending.arm()
logger.warning("Gaming reset armed; ready at %s", pending.ready_at.isoformat())
return _view(pending)
def op_heartbeat(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Keep the armed reset alive."""
return _view(ctx.pending.beat(str_arg(args, "pending_id")))
def op_commit(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Run the reset, if the countdown finished and the phrase still matches."""
pending_id = str_arg(args, "pending_id")
ctx.pending.check_ready(pending_id)
require_phrase(_COMMAND, args.get("phrase"))
with hold_tick(ctx):
# Authoritative re-check under the lock: of two racing commits only
# the first finds the pending reset still there.
ctx.pending.check_ready(pending_id)
check_locks(_COMMAND)
try:
outcome = reset_today(source="ctl")
except OSError as exc:
logger.exception("Gaming reset failed")
raise CtlError(OP_FAILED, f"the reset failed: {exc}") from exc
ctx.pending.finish(pending_id)
logger.warning(
"Gaming reset committed: %.0f s billed before, %d mount(s) released",
outcome.seconds_before,
len(outcome.released),
)
return {
"day_key": outcome.day_key,
"seconds_before": round(outcome.seconds_before, 3),
"was_blocked": outcome.was_blocked,
"released": [str(path) for path in outcome.released],
}
def op_cancel(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Abandon the armed reset (idempotent)."""
ctx.pending.finish(str_arg(args, "pending_id"))
return {"cancelled": True}
RESET_OPS: Final = {
"gaming_reset_arm": op_arm,
"gaming_reset_heartbeat": op_heartbeat,
"gaming_reset_commit": op_commit,
"gaming_reset_cancel": op_cancel,
}