mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:47:04 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""What every control-socket op shares: the daemon's state and the guards.
|
|
|
|
The ops run on connection threads, the enforce loop on the main thread. The
|
|
``tick_lock`` is how they stay out of each other's way: the loop holds it for
|
|
one whole iteration and a mutating op holds it for its whole body, so an op
|
|
never sees (or clobbers) a half-finished tick. The loop only holds it while
|
|
working, never while sleeping between ticks, so an op waits at most one tick.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from steam_backlog_enforcer._actions import is_manual_pick_locked
|
|
from steam_backlog_enforcer._command_locks import (
|
|
_MANUAL_LOCK_EXEMPT_COMMANDS,
|
|
_TOTAL_BLOCK_EXEMPT_COMMANDS,
|
|
)
|
|
from steam_backlog_enforcer._ctl_pending import PendingResets
|
|
from steam_backlog_enforcer._ctl_protocol import (
|
|
BUSY,
|
|
INVALID_PARAMS,
|
|
LOCKED,
|
|
WRONG_PHRASE,
|
|
CtlError,
|
|
)
|
|
from steam_backlog_enforcer._friction import expected_phrase, phrase_matches
|
|
from steam_backlog_enforcer._total_block import is_total_block_active
|
|
from steam_backlog_enforcer.config import State
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from datetime import datetime
|
|
import threading
|
|
|
|
from steam_backlog_enforcer.config import Config
|
|
|
|
# Longer than any single enforce iteration should take; an op that cannot get
|
|
# the lock by then reports ``busy`` instead of hanging its connection.
|
|
LOCK_WAIT_SECONDS: Final = 60.0
|
|
|
|
|
|
@dataclass
|
|
class CtlContext:
|
|
"""Shared state for the control socket's ops.
|
|
|
|
Attributes:
|
|
config: The daemon's loaded configuration.
|
|
tick_lock: Held by the enforce loop for each iteration.
|
|
restart_event: Set by the ``restart`` op; the loop exits once it sees it.
|
|
started_at: When the daemon process started (UTC).
|
|
supervised: Whether systemd will bring the daemon back after an exit.
|
|
pending: Armed two-phase gaming resets.
|
|
"""
|
|
|
|
config: Config
|
|
tick_lock: threading.Lock
|
|
restart_event: threading.Event
|
|
started_at: datetime
|
|
supervised: bool
|
|
pending: PendingResets = field(default_factory=PendingResets)
|
|
|
|
|
|
@contextmanager
|
|
def hold_tick(ctx: CtlContext) -> Iterator[None]:
|
|
"""Hold the loop's lock for the body of a mutating op.
|
|
|
|
Raises:
|
|
CtlError: ``busy`` if the loop does not release the lock in time.
|
|
"""
|
|
if not ctx.tick_lock.acquire(timeout=LOCK_WAIT_SECONDS):
|
|
raise CtlError(BUSY, "the enforcer is mid-pass; try again in a moment")
|
|
try:
|
|
yield
|
|
finally:
|
|
ctx.tick_lock.release()
|
|
|
|
|
|
def require_phrase(command: str, typed: object, **fields: object) -> None:
|
|
"""Check the typed confirmation phrase for *command*.
|
|
|
|
Raises:
|
|
CtlError: ``invalid_params`` if *typed* is not text, ``wrong_phrase``
|
|
if it differs from the one ``_friction`` demands.
|
|
"""
|
|
if not isinstance(typed, str):
|
|
raise CtlError(INVALID_PARAMS, "phrase must be a string")
|
|
expected = expected_phrase(command, **fields)
|
|
if expected is None or not phrase_matches(expected, typed):
|
|
raise CtlError(WRONG_PHRASE, "the typed phrase does not match")
|
|
|
|
|
|
def int_arg(args: dict[str, object], name: str, low: int, high: int) -> int:
|
|
"""Read an integer argument within ``[low, high]``.
|
|
|
|
``bool`` is rejected explicitly: ``True`` is an ``int`` in Python and would
|
|
otherwise pass as ``1``.
|
|
|
|
Raises:
|
|
CtlError: ``invalid_params`` if absent, not an integer, or out of range.
|
|
"""
|
|
value = args.get(name)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise CtlError(INVALID_PARAMS, f"{name} must be an integer")
|
|
if not low <= value <= high:
|
|
raise CtlError(INVALID_PARAMS, f"{name} must be between {low} and {high}")
|
|
return value
|
|
|
|
|
|
def str_arg(args: dict[str, object], name: str) -> str:
|
|
"""Read a non-empty string argument.
|
|
|
|
Raises:
|
|
CtlError: ``invalid_params`` if absent or not a non-empty string.
|
|
"""
|
|
value = args.get(name)
|
|
if not isinstance(value, str) or not value:
|
|
raise CtlError(INVALID_PARAMS, f"{name} must be a non-empty string")
|
|
return value
|
|
|
|
|
|
def check_locks(command: str) -> None:
|
|
"""Refuse *command* while a lock the CLI would honour is in force.
|
|
|
|
Mirrors ``main._shared._enforce_total_block_lock`` and
|
|
``_enforce_manual_pick_lock`` using the same tables, because the socket
|
|
would otherwise be the one door those locks do not cover.
|
|
|
|
Args:
|
|
command: The CLI command name the op corresponds to.
|
|
|
|
Raises:
|
|
CtlError: ``locked`` if a total block or the manual-pick lock forbids it.
|
|
"""
|
|
if is_total_block_active() and command not in _TOTAL_BLOCK_EXEMPT_COMMANDS:
|
|
raise CtlError(LOCKED, "a total gaming block is active")
|
|
if command not in _MANUAL_LOCK_EXEMPT_COMMANDS and is_manual_pick_locked(
|
|
State.load()
|
|
):
|
|
raise CtlError(LOCKED, "a manual pick lock is active")
|