mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:33:49 +01:00
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
181 lines
5.2 KiB
Python
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
|