mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 12:07:32 +01:00
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
53 lines
2.2 KiB
Python
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.
|
|
"""
|