steam-backlog-enforcer/steam_backlog_enforcer/_leisure_kill.py
Krzysztof kuhy Rudnicki e0cf931d0e leisure kill: sweep only after 30 s of continuous untrusted now.json
One stale moment (resume from sleep, a watcher restart) while spent no
longer kills every browser at once. A trusted or paused tick, a clock
step back, or a gap between ticks over 10 s (a suspend) restarts the
window; untrust is tracked while budget remains too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 17:04:38 +02:00

219 lines
8.1 KiB
Python

"""Phase 2 at 0: end every leisure app the watcher names (Steam is separate).
Runs every 3 s tick while the leisure budget is spent, beside the unchanged
Steam cutoff (:mod:`_playtime_cutoff`). Which pids:
* **Trusted** ``now.json``: every ``focus``/``media`` reason's pid unless it is
a browser (the extension redirects those to the "leisure spent" page), and
every ``failclosed`` reason's pid -- a browser that is not reporting (another
browser, an unreported private window) has nothing redirecting it.
``game`` reasons belong to the Steam path.
* **Untrusted** (stale, missing, badly signed, unreadable) for 30 s in a row:
every browser, media player and wsg-grabber reviewer
(:func:`_leisure_procs.sweep_targets`) until the watcher is fresh again. One
stale moment (resume from sleep, a watcher restart) kills nothing: a trusted
or paused tick, or a gap between ticks (a suspend), restarts the window.
* **Paused** (a locker grab is held): nothing; the lock is not a stale watcher.
Each process gets SIGTERM once, then SIGKILL every tick from 30 s after *its*
SIGTERM -- timed per process, so an app opened an hour into the block still
gets its 30 s. A process is its (pid, start time), so a reused pid starts
over. Root/system processes, lockers and the enforcer itself are never
signalled, nor -- whatever ``now.json`` says -- the desktop session
(:mod:`_leisure_never_kill`; each refusal logged once). Every SIGTERM and the
first SIGKILL go to ``budget.jsonl``.
"""
from __future__ import annotations
import logging
import math
import os
import signal
from typing import TYPE_CHECKING, Final, Protocol
from steam_backlog_enforcer._leisure_never_kill import never_kill
from steam_backlog_enforcer._leisure_procs import (
exe_name,
is_browser,
is_protected,
own_chain,
start_time,
sweep_targets,
)
if TYPE_CHECKING:
from leisure_watch.facts import Now
from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState
from steam_backlog_enforcer._shadow import ShadowVerdict
logger = logging.getLogger(__name__)
EVENT_LEISURE_KILL: Final = "leisure_kill"
EVENT_KILL_REFUSED: Final = "leisure_kill_refused"
SIGKILL_AFTER_SECONDS: Final = 30.0
UNTRUSTED_SWEEP_AFTER: Final = 30.0
# Ticks are 3 s apart; a longer gap (suspend, a stalled loop) is not "in a row".
_MAX_TICK_GAP: Final = 10.0
_KILLED_SOURCES: Final = frozenset({"focus", "media"})
_FAILCLOSED: Final = "failclosed"
_Ident = tuple[int, int]
class KillLog(Protocol):
"""Where kills are recorded (``BudgetLog``)."""
def record(self, event: str, **fields: object) -> None:
"""Append one record."""
def fact_targets(fact: Now) -> dict[int, str]:
"""Pids a trusted *fact* says must end, to the reason key that named them."""
found: dict[int, str] = {}
for reason in fact.reasons:
if reason.pid is None:
continue
failclosed = reason.source == _FAILCLOSED
if failclosed or (
reason.source in _KILLED_SOURCES and not is_browser(reason.pid)
):
found.setdefault(reason.pid, reason.key)
return found
class LeisureKiller:
"""The per-daemon kill state: when each process got its SIGTERM."""
def __init__(self, log: KillLog | None = None) -> None:
"""Start with nothing signalled.
Args:
log: The budget audit log; ``None`` records nowhere.
"""
self.log = log
self._termed: dict[_Ident, float] = {}
self._killed: set[_Ident] = set()
self._refused: set[_Ident] = set()
self._untrusted_since: float | None = None
self._last_stamp = -math.inf
self._last_error = ""
def enforce(
self,
state: PlaytimeState,
rules: PlaytimeRules,
*,
verdict: ShadowVerdict,
stamp: float,
) -> None:
"""Signal this tick's leisure targets if the leisure budget is spent.
Never raises: the enforce loop has no per-tick guard.
Args:
state: Accounting state after this tick's policy.
rules: Policy for this tick (``leisure`` set by the billing step).
verdict: This tick's shadow verdict, carrying the trusted fact.
stamp: Epoch seconds of this tick.
"""
try:
self._enforce(state, rules, verdict=verdict, stamp=stamp)
except Exception as exc: # the loop must outlive any kill bug
message = f"{type(exc).__name__}: {exc}"
if message != self._last_error:
self._last_error = message
logger.warning("Leisure kill failed (%s).", message, exc_info=True)
def _enforce(
self,
state: PlaytimeState,
rules: PlaytimeRules,
*,
verdict: ShadowVerdict,
stamp: float,
) -> None:
if not rules.leisure or rules.demo or not rules.enforcement:
return
untrusted_for = self._untrusted_for(verdict, stamp)
if state.seconds < rules.budget_seconds:
self._termed.clear()
self._killed.clear()
return
if verdict.paused_by is not None:
return
if verdict.fact is None:
if untrusted_for < UNTRUSTED_SWEEP_AFTER:
return
targets, why = sweep_targets(), "now.json untrusted"
else:
targets, why = fact_targets(verdict.fact), "now.json"
live = self._identify(targets)
self._termed = {i: t for i, t in self._termed.items() if i in live}
self._killed &= set(live)
base = {
"why": why,
"day_key": state.day_key,
"seconds": round(state.seconds, 3),
}
for ident, key in sorted(live.items()):
note = {"key": key, "name": exe_name(ident[0]), **base}
first = self._termed.get(ident)
if first is None:
self._termed[ident] = stamp
self._signal(ident[0], signal.SIGTERM, note)
elif stamp - first >= SIGKILL_AFTER_SECONDS:
quiet = ident in self._killed
if self._signal(ident[0], signal.SIGKILL, None if quiet else note):
self._killed.add(ident)
def _untrusted_for(self, verdict: ShadowVerdict, stamp: float) -> float:
"""Seconds ``now.json`` has been untrusted in a row, as of this tick."""
gap = stamp - self._last_stamp
self._last_stamp = stamp
if verdict.fact is not None or verdict.paused_by is not None:
self._untrusted_since = None
return 0.0
if self._untrusted_since is None or not 0.0 <= gap <= _MAX_TICK_GAP:
self._untrusted_since = stamp
return stamp - self._untrusted_since
def _identify(self, targets: dict[int, str]) -> dict[_Ident, str]:
chain = own_chain()
live: dict[_Ident, str] = {}
refused: set[_Ident] = set()
for pid, key in targets.items():
started = start_time(pid)
if started is None or is_protected(pid, chain):
continue
guard = never_kill(pid)
if guard is None:
live[pid, started] = key
continue
refused.add((pid, started))
if (pid, started) not in self._refused:
self._refuse(pid, key, guard)
self._refused = refused
return live
def _refuse(self, pid: int, key: str, name: str) -> None:
"""Log, once per process, a kill the never-kill set stopped."""
logger.warning(
"Refused leisure kill of pid %d (%s): %s is never killed.", pid, key, name
)
if self.log is not None:
self.log.record(EVENT_KILL_REFUSED, pid=pid, key=key, name=name)
def _signal(
self, pid: int, sig: signal.Signals, note: dict[str, object] | None
) -> bool:
"""Send *sig*; record it unless *note* is ``None``. True if delivered."""
try:
os.kill(pid, sig)
except ProcessLookupError, PermissionError:
return False
if note is not None and self.log is not None:
self.log.record(EVENT_LEISURE_KILL, pid=pid, signal=sig.name, **note)
return True