mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:07:05 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Raise the pylint hook to --fail-under=10.0 and make it lint the whole
package on every run (pass_filenames: false): over the staged subset a
9.99 tree passed at commit time.
Ports de2aea3 (9b9342ba-1) onto main, minus its web/ and requirements.txt
bumps and the iptables dedupe f1822f1 already landed; _hltb_confidence and
_hltb_search keep main's 429 handling. _search_batch splits out
_acquire_auth / _search_headers, and _stats.py is back to 250 lines.
Drops the min-similarity-lines = 8 floor (0 duplicate-code hits at the
default) and sets max-attributes = 15 for the flat persisted records, as
kuhy decided. Tests stay outside pylint, as at every site already at 10.
Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.9 KiB
Python
188 lines
6.9 KiB
Python
"""Shadow leisure billing (contract C4): measured every tick, never enforced.
|
|
|
|
Phase 1 of the leisure rollout counts every non-productive minute on the PC
|
|
beside the enforced gaming count, so a week of real data exists before the
|
|
leisure budget is switched on. Nothing here feeds a warning, a cutoff or a
|
|
kill, and nothing here writes an enforced field (``seconds``, ``per_game``,
|
|
``last_tick_at``, ...): the shadow is computed from the incoming tick stamp
|
|
*before* :func:`~steam_backlog_enforcer._playtime_budget.accumulate` moves it.
|
|
|
|
Per tick, in order:
|
|
|
|
1. A locker whose X grab is actually held, or i3lock -> paused: the delta
|
|
goes to ``shadow_paused_seconds`` and nothing bills, games included (Q7).
|
|
2. Any qualifying game process (the enforcer's own scan) -> leisure, billed
|
|
to the game keys.
|
|
3. Else the watcher's signed ``now.json``: ``leisure`` bills its reason keys;
|
|
a missing, stale or badly signed file fails closed (Q8) and bills
|
|
``failclosed:watcher``.
|
|
|
|
Overlapping sources bill the wall clock once and split it evenly, so
|
|
``sum(shadow_per_source) == shadow_seconds`` up to float rounding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field, replace
|
|
import logging
|
|
from pathlib import Path
|
|
import time
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from leisure_watch.facts import read_now
|
|
from leisure_watch.lockstate import paused_by
|
|
|
|
from steam_backlog_enforcer._playtime_state import _DELTA_CLAMP_FACTOR
|
|
|
|
if TYPE_CHECKING:
|
|
from leisure_watch.facts import Now
|
|
|
|
from steam_backlog_enforcer._playtime_state import PlaytimeState
|
|
|
|
# Explicit, because the daemon is root: these are the desktop user's (uid 1000).
|
|
NOW_PATH: Final = Path("/run/user/1000/leisure-watch/now.json")
|
|
CLAIMS_DIR: Final = Path("/run/user/1000/gatelock/claims")
|
|
# now.json older than this is not trusted (contract C3/Q8).
|
|
_MAX_AGE_SECONDS: Final = 15
|
|
FAILCLOSED_KEY: Final = "failclosed:watcher"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# A broken input fails every 3 s tick: one warning per distinct error per
|
|
# window, not 1200 identical journal lines an hour.
|
|
WARN_EVERY_SECONDS: Final = 600.0
|
|
_last_warned: Final[dict[str, float]] = {}
|
|
|
|
|
|
def reset_warnings() -> None:
|
|
"""Forget which errors were logged (tests; a long-lived daemon never)."""
|
|
_last_warned.clear()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ShadowVerdict:
|
|
"""What one tick means for the shadow budget."""
|
|
|
|
paused_by: str | None
|
|
"""``gatelock:<app>`` / ``i3lock`` while paused, else ``None``."""
|
|
keys: tuple[str, ...]
|
|
"""Sources billed this tick; empty = free (or paused)."""
|
|
fact: Now | None = field(default=None, compare=False, repr=False)
|
|
"""The trusted watcher fact (Phase 2 kills); ``None`` if paused or untrusted.
|
|
|
|
Out of equality: ts/seq change every 5 s and the journal dedups on it.
|
|
"""
|
|
|
|
@property
|
|
def state(self) -> str:
|
|
"""``paused``, ``leisure`` or ``free``."""
|
|
if self.paused_by is not None:
|
|
return "paused"
|
|
return "leisure" if self.keys else "free"
|
|
|
|
|
|
def shadow_keys(owners: dict[int, str], fact: Now | None) -> tuple[str, ...]:
|
|
"""Return the sources this unpaused tick bills, or ``()`` when it is free.
|
|
|
|
Args:
|
|
owners: Qualifying game PIDs to attribution keys (``app:<id>`` ...).
|
|
fact: The verified watcher fact, or ``None`` if it cannot be trusted.
|
|
|
|
Returns:
|
|
Game keys (sorted) if a game runs; else the watcher's reason keys in
|
|
its order; ``(FAILCLOSED_KEY,)`` when the watcher cannot be trusted.
|
|
"""
|
|
if owners:
|
|
return tuple(sorted(set(owners.values())))
|
|
if fact is None:
|
|
return (FAILCLOSED_KEY,)
|
|
if fact.state != "leisure":
|
|
return ()
|
|
return tuple(dict.fromkeys(r.key for r in fact.reasons)) or (FAILCLOSED_KEY,)
|
|
|
|
|
|
def observe_shadow(owners: dict[int, str], *, stamp: float) -> ShadowVerdict:
|
|
"""Read the lock state and (unless paused) ``now.json`` once for this tick.
|
|
|
|
Args:
|
|
owners: This tick's qualifying game PIDs and their keys.
|
|
stamp: Epoch seconds of this tick, the freshness reference.
|
|
|
|
Returns:
|
|
The tick's shadow verdict; the fail-closed one if an input raised.
|
|
"""
|
|
# The enforce loop has no per-tick guard, so a raise here -- of *any* kind:
|
|
# leisure_watch has raised RecursionError -- would crash-loop the daemon
|
|
# and stop enforced billing. An unreadable input bills, unpaused, unless
|
|
# the pause check itself answered "paused".
|
|
try:
|
|
paused = paused_by(CLAIMS_DIR)
|
|
if paused is not None:
|
|
return ShadowVerdict(paused_by=paused, keys=())
|
|
fact = read_now(NOW_PATH, stamp, max_age=_MAX_AGE_SECONDS)
|
|
# Catch-all on purpose: the loop must outlive any shadow input bug.
|
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
if _should_warn(exc):
|
|
logger.warning(
|
|
"Shadow inputs unreadable (%r); failing closed.", exc, exc_info=True
|
|
)
|
|
fact = None
|
|
return ShadowVerdict(paused_by=None, keys=shadow_keys(owners, fact), fact=fact)
|
|
|
|
|
|
def _should_warn(exc: BaseException) -> bool:
|
|
"""Whether *exc* was not already logged within :data:`WARN_EVERY_SECONDS`."""
|
|
key = f"{type(exc).__name__}: {exc}"
|
|
now = time.monotonic()
|
|
# Forget expired errors first: a message that varies per tick (a JSON
|
|
# offset, say) must not grow a long-lived daemon's memory without bound.
|
|
expired = [k for k, at in _last_warned.items() if now - at >= WARN_EVERY_SECONDS]
|
|
for old in expired:
|
|
del _last_warned[old]
|
|
if key in _last_warned:
|
|
return False
|
|
_last_warned[key] = now
|
|
return True
|
|
|
|
|
|
def shadow_accumulate(
|
|
state: PlaytimeState,
|
|
*,
|
|
stamp: float,
|
|
interval: float,
|
|
verdict: ShadowVerdict,
|
|
) -> PlaytimeState:
|
|
"""Add this tick's shadow time to *state*; pure, enforced fields untouched.
|
|
|
|
Measured from the same incoming ``last_tick_at`` and clamped by the same
|
|
factor as the enforced ``accumulate``, so a first tick, a backward clock
|
|
step or a suspend behave identically in both counts.
|
|
|
|
Args:
|
|
state: Accounting state *before* this tick's enforced accumulation.
|
|
stamp: Epoch seconds of this tick.
|
|
interval: Nominal seconds between ticks.
|
|
verdict: This tick's shadow verdict.
|
|
|
|
Returns:
|
|
The updated state.
|
|
"""
|
|
delta = stamp - state.last_tick_at
|
|
if state.last_tick_at <= 0.0 or delta <= 0.0:
|
|
return state
|
|
credited = min(delta, interval * _DELTA_CLAMP_FACTOR)
|
|
if verdict.paused_by is not None:
|
|
paused = state.shadow_paused_seconds + credited
|
|
return replace(state, shadow_paused_seconds=paused)
|
|
if not verdict.keys:
|
|
return state
|
|
share = credited / len(verdict.keys)
|
|
per_source = dict(state.shadow_per_source)
|
|
for key in verdict.keys:
|
|
per_source[key] = per_source.get(key, 0.0) + share
|
|
return replace(
|
|
state,
|
|
shadow_seconds=state.shadow_seconds + credited,
|
|
shadow_per_source=per_source,
|
|
)
|