steam-backlog-enforcer/steam_backlog_enforcer/_playtime_kill.py
Krzysztof kuhy Rudnicki 1faf211767 Show which games make up the gaming budget, and bill osu!lazer
The budget tab answered "how much did I play" but not "what did I play":
playtime_state.json and playtime_history.json each stored one aggregate
seconds figure per gaming day, so the 14-day chart was a row of featureless
bars. Separately, qualifying_pids had exactly two sources — the SteamAppId
env var and a hardcoded 16-launcher frozenset — so osu!lazer, which sets no
SteamAppId and matches no launcher name, was never billed at all.

qualifying_pids now returns pid -> attribution key instead of a bare pid set.
The app id was already in hand and thrown away on its first line; it now
survives into PlaytimeState.per_game, alongside a user-editable
counted_processes list in config.json seeded with osu!lazer.

Two things the parts/whole relationship forces:

  - per_game only ever sums to AT MOST seconds, never equals it. backdate
    refunds the idle grace on every engaged-to-paused edge, and
    _state_or_recover synthesises a full budget with no breakdown. The
    refund is debited from last_credited_key, and the remainder is rendered
    as "Unattributed" rather than stored — one path then covers legacy days,
    focus-probe failures and drift alike.

  - counted_processes is kept apart from LAUNCHER_PROCESS_NAMES. That
    frozenset is also the total-block kill list, and it routes through
    kill_and_uninstall_launchers, which pacman -R's the owning package.
    Counted entries are killed on total block, never uninstalled.

Both storage formats stay at schema 1: load_state already filters to
dataclass fields, so a record without per_game migrates for free, and the
history reader has to accept both a bare float and an object regardless, so
a version bump would distinguish nothing.

Chart segments use the shared design system's categorical ramp (--cat-1..6),
capped at six games plus "Other" because a seventh distinguishable hue does
not exist under simulated CVD. The red over-budget fill is gone: colour now
encodes which game, and the dashed budget line alone says over or under.

Snapshot persistence moves to _snapshot.py to keep config.py under the
250-line cap. Three modules bound config paths at import time, which
bypasses the conftest redirect — they now reach them through the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:32:03 +02:00

181 lines
5.2 KiB
Python

"""Finding and stopping the processes a playtime cutoff must end.
Killing Steam's own PIDs is not enough: a running game is a *descendant* of
the client, so the tree is walked from the launcher roots downwards. The
enforcer's own process chain is excluded, since it is itself a child of the
same session on a desktop install.
"""
from __future__ import annotations
import contextlib
import logging
import os
from pathlib import Path
import shutil
import signal
from steam_backlog_enforcer._counted_procs import kill_target_names
from steam_backlog_enforcer._playtime_run import _run
from steam_backlog_enforcer._total_block_launchers import (
LAUNCHER_PROCESS_NAMES,
STEAM_CLIENT_PROCESS_NAMES,
)
from steam_backlog_enforcer.enforcer import get_pids_by_process_names
logger = logging.getLogger(__name__)
_PROC = Path("/proc")
_STAT_PPID_INDEX = 1
_MAX_PROCESS_TREE_DEPTH = 32
# Walking past pid 1 is walking off the top of the process tree.
_INIT_PID = 1
def request_steam_shutdown() -> None:
"""Ask Steam to close cleanly, without waiting for it.
``library_hider._shutdown_steam`` polls for up to 30 seconds, which would
stall the 3-second enforce loop for ten ticks. The cutoff sequence gives
Steam its grace period across ticks instead.
"""
steam = shutil.which("steam")
if steam is None:
logger.info("Steam binary not on PATH; skipping clean shutdown.")
return
_run([steam, "-shutdown"])
def steam_and_launcher_pids() -> set[int]:
"""Return PIDs of the Steam client and every known game launcher.
Returns:
The launcher and client PIDs.
"""
names = STEAM_CLIENT_PROCESS_NAMES | LAUNCHER_PROCESS_NAMES | kill_target_names()
return set(get_pids_by_process_names(names))
def _read_ppid(pid: int) -> int | None:
"""Return the parent PID of *pid*, or ``None`` if it cannot be read.
``/proc/<pid>/stat`` embeds ``comm`` in parentheses and ``comm`` may contain
spaces and parentheses, so the fields after it are found by splitting on the
last ``") "`` rather than by whitespace position.
Args:
pid: Process to inspect.
Returns:
The parent PID, or ``None``.
"""
try:
raw = (_PROC / str(pid) / "stat").read_text(encoding="utf-8")
except OSError, ValueError:
return None
_, _, rest = raw.rpartition(") ")
fields = rest.split()
if len(fields) <= _STAT_PPID_INDEX:
return None
try:
return int(fields[_STAT_PPID_INDEX])
except ValueError:
return None
def _child_map() -> dict[int, list[int]]:
"""Build a parent-PID to child-PIDs map from ``/proc``.
Returns:
Mapping of parent PID to its direct children.
"""
children: dict[int, list[int]] = {}
try:
entries = list(_PROC.iterdir())
except OSError:
return children
for entry in entries:
if not entry.name.isdigit():
continue
pid = int(entry.name)
ppid = _read_ppid(pid)
if ppid is not None:
children.setdefault(ppid, []).append(pid)
return children
def descendant_pids(roots: set[int]) -> set[int]:
"""Return every process descended from *roots*.
A Lutris-launched Wine game carries no ``SteamAppId`` and its ``comm`` is
the game's own binary, so neither of the budget matchers can see it. Walking
down from the launcher is the only thing that does.
The daemon's own process and its ancestors are excluded, so a misidentified
root cannot make the enforcer kill itself.
Args:
roots: Processes to walk down from.
Returns:
The transitive descendants, excluding *roots* themselves.
"""
children = _child_map()
protected = _own_process_chain()
found: set[int] = set()
frontier = [pid for pid in roots if pid not in protected]
for _ in range(_MAX_PROCESS_TREE_DEPTH):
if not frontier:
break
nxt: list[int] = []
for pid in frontier:
for child in children.get(pid, []):
if child in found or child in protected:
continue
found.add(child)
nxt.append(child)
frontier = nxt
return found
def _own_process_chain() -> set[int]:
"""Return this process and its ancestors.
Returns:
PIDs that must never be signalled.
"""
chain: set[int] = set()
pid: int | None = os.getpid()
for _ in range(_MAX_PROCESS_TREE_DEPTH):
if pid is None or pid <= 0 or pid in chain:
break
chain.add(pid)
pid = _read_ppid(pid)
return chain
def kill_gaming_processes(pids: set[int], *, force: bool) -> list[int]:
"""Signal every process in *pids*, plus everything descended from them.
Args:
pids: Processes to terminate.
force: Send ``SIGKILL`` instead of ``SIGTERM``.
Returns:
The PIDs actually signalled.
"""
protected = _own_process_chain()
targets = (pids | descendant_pids(pids)) - protected
sig = signal.SIGKILL if force else signal.SIGTERM
signalled: list[int] = []
for pid in sorted(targets):
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, sig)
signalled.append(pid)
return signalled