steam-backlog-enforcer/steam_backlog_enforcer/_steam_errors.py
Krzysztof kuhy Rudnicki b3e629e3f0 Bill gaming time only while the user is actually playing
A running game process was treated as proof of play. On 2026-08-28 the budget
billed ~2h23m while a gatelock screen locker covered the display and the
machine could not be touched at all -- the game was simply still resident.

A tick now bills only when all three hold, evaluated together rather than
short-circuited so the audit log can answer "was the screen ALSO locked?":

  * no gatelock guard owns the screen  (_screen_hold, via /proc/locks)
  * the focused window belongs to a qualifying process or its ancestry
  * some input source has been used within idle_grace_seconds (default 300)

Any probe that cannot answer BILLS the tick and is recorded in `degraded`.
A broken detector must never become an unlimited-gaming exploit.

Three findings from measuring this machine rather than assuming:

  * /dev/input/js0 here is an *ASRock LED Controller* -- the kernel exposes
    the motherboard RGB device as a joystick. A presence check would have
    disabled the idle criterion permanently, so _controller_input watches for
    real events instead (skipping joydev's synthetic open burst).
  * Aseprite's window carries no _NET_WM_PID at all, so focus attribution
    falls back to XRes, which asks the server who owns the window rather than
    trusting the client to have announced itself.
  * /proc/locks reports gatelock's hold without taking it; gatelock's own
    read_claim_if_held detects held-ness by acquiring, which would race a
    guard every 3 seconds.

Idle pauses also refund the grace period once per walk-away, on the
engaged->paused edge only, never across a day rollover, and never on the first
tick after a restart -- the only place the counter moves backwards.

Everything is recorded to /var/log/steam-backlog-enforcer/budget.jsonl, on
change plus a heartbeat rather than one line per 3s tick. Records name the
qualifying PIDs: reconstructing this incident from journald alone was only
possible by inferring accrual from the spacing of two warning notifications.

Also: refuse to bounce Steam while a game is running. ensure_steam_debug_port
relaunches Steam when the CDP port is closed, which kills the running game --
that is how restarting the daemon to deploy this change ended a live session.
It now defers exactly as it already did for in-flight game updates.

Talks to X through ctypes over one cached connection: at a 3s tick, forking
xprintidle and xprop would cost ~40 forks a minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CEsG6BzHtYR9cHcdxPZqG
2026-08-28 19:24:14 +02:00

53 lines
2.2 KiB
Python

"""Errors raised when Steam cannot be driven.
A leaf module so both :mod:`steam_backlog_enforcer.library_hider` and
:mod:`steam_backlog_enforcer._steam_launch` can raise and catch these without
importing each other. Split out to keep both under the 250-line cap.
"""
from __future__ import annotations
class SteamUnavailableError(RuntimeError):
"""Raised when Steam cannot be driven over CDP.
Covers both "Steam is not installed" and "Steam is installed but never
opened its debug port". Callers are expected to degrade gracefully rather
than abort: an unreachable Steam means there is no library to hide, which
is not a fatal condition for the enforcer.
"""
class SteamUpdateInProgressError(SteamUnavailableError):
"""Raised to defer a Steam restart while a game update is in flight.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Restarting Steam
mid-update suspends and can corrupt the transfer, so the enforcer waits
for updates to finish before bouncing Steam to open the CDP port.
"""
class DesktopSessionNotReadyError(SteamUnavailableError):
"""Raised to defer a Steam launch until the user's session exists.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Launching Steam
before ``/run/user/<uid>`` exists hands every Wine child a runtime dir
that is not there, so it falls back from winepulse to winealsa and stays
that way for the whole session — the Kingdom Come: Deliverance II startup
crash. Waiting a pass costs 3s; getting it wrong costs a reboot.
"""
class GameInProgressError(SteamUnavailableError):
"""Raised to defer a Steam restart while a game is actually being played.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Bouncing Steam to
open the CDP port takes every running game down with it, losing unsaved
progress — observed on 2026-08-28, when restarting the daemon to deploy an
unrelated change killed a live session. Library hiding can wait; a player's
afternoon cannot be given back.
"""