mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 17:13:50 +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
177 lines
5.2 KiB
Python
177 lines
5.2 KiB
Python
"""Resolving which process owns the focused X window.
|
|
|
|
``_NET_WM_PID`` is the obvious answer and the one every window-manager guide
|
|
names, but it is a *convention* toolkits opt into, not a guarantee. Measured on
|
|
this machine: Aseprite's window carries no ``_NET_WM_PID`` at all. Treating that
|
|
as "cannot tell" and billing the tick would make the focus criterion useless for
|
|
every such application — including, plausibly, games.
|
|
|
|
So the absent property falls through to the X-Resource extension, which asks the
|
|
*server* which client owns the window rather than trusting the client to have
|
|
announced itself. Verified against the same Aseprite window: ``XResQueryClientIds``
|
|
returned the correct PID where ``_NET_WM_PID`` returned nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
from typing import Final
|
|
|
|
from steam_backlog_enforcer._x_probe import Conn, XProbeError, root_window
|
|
|
|
# XGetWindowProperty's `req_type`: accept whatever type the property carries.
|
|
_ANY_PROPERTY_TYPE: Final = 0
|
|
_SUCCESS: Final = 0
|
|
|
|
# XInternAtom's `only_if_exists`: never create an atom just by asking for it.
|
|
# An int, not a bool: it is a C ``Bool`` argument, not a Python flag.
|
|
_ONLY_IF_EXISTS: Final = 1
|
|
|
|
# XRes client-id spec masks, from <X11/extensions/XRes.h>.
|
|
_XRES_PID_MASK: Final = 1 << 1
|
|
_XRES_PID_BYTES: Final = 4
|
|
|
|
|
|
class _XResSpec(ctypes.Structure):
|
|
"""Mirror of libXRes's ``XResClientIdSpec``."""
|
|
|
|
_fields_ = (("client", ctypes.c_ulong), ("mask", ctypes.c_uint))
|
|
|
|
|
|
class _XResValue(ctypes.Structure):
|
|
"""Mirror of libXRes's ``XResClientIdValue``."""
|
|
|
|
_fields_ = (
|
|
("spec", _XResSpec),
|
|
("length", ctypes.c_long),
|
|
("value", ctypes.c_void_p),
|
|
)
|
|
|
|
|
|
def focused_pid(conn: Conn) -> int | None:
|
|
"""Return the PID owning the focused window.
|
|
|
|
Args:
|
|
conn: Live connection.
|
|
|
|
Returns:
|
|
The PID, or ``None`` when nothing is focused — a real answer, not a
|
|
failure.
|
|
|
|
Raises:
|
|
XProbeError: If a window is focused but cannot be attributed to a
|
|
process, which the caller must treat as "cannot tell" and bill.
|
|
"""
|
|
active = read_cardinal(conn, root_window(conn), b"_NET_ACTIVE_WINDOW")
|
|
if not active:
|
|
return None
|
|
|
|
declared = read_cardinal(conn, active, b"_NET_WM_PID")
|
|
if declared is not None:
|
|
return declared
|
|
|
|
resolved = _xres_pid(conn, active)
|
|
if resolved is None:
|
|
msg = f"window {active:#x} has no _NET_WM_PID and XRes could not name it"
|
|
raise XProbeError(msg)
|
|
return resolved
|
|
|
|
|
|
def _xres_pid(conn: Conn, window: int) -> int | None:
|
|
"""Ask the X server which process owns *window*.
|
|
|
|
Args:
|
|
conn: Live connection.
|
|
window: Window to attribute.
|
|
|
|
Returns:
|
|
The owning PID, or ``None`` if XRes is unavailable or has no answer.
|
|
"""
|
|
if conn.xres is None:
|
|
return None
|
|
|
|
spec = _XResSpec(client=window, mask=_XRES_PID_MASK)
|
|
count = ctypes.c_long()
|
|
values = ctypes.POINTER(_XResValue)()
|
|
|
|
status = conn.xres.XResQueryClientIds(
|
|
conn.display,
|
|
ctypes.c_long(1),
|
|
ctypes.byref(spec),
|
|
ctypes.byref(count),
|
|
ctypes.byref(values),
|
|
)
|
|
if status != _SUCCESS or not values:
|
|
return None
|
|
|
|
try:
|
|
return _first_pid(values, count.value)
|
|
finally:
|
|
conn.xres.XResClientIdsDestroy(count, values)
|
|
|
|
|
|
def _first_pid(values: ctypes._Pointer[_XResValue], count: int) -> int | None:
|
|
"""Return the first PID among *count* XRes client-id values.
|
|
|
|
Args:
|
|
values: Array returned by ``XResQueryClientIds``.
|
|
count: Number of entries in *values*.
|
|
|
|
Returns:
|
|
The PID, or ``None`` if no entry carries one.
|
|
"""
|
|
for index in range(count):
|
|
entry = values[index]
|
|
if entry.spec.mask != _XRES_PID_MASK or entry.length < _XRES_PID_BYTES:
|
|
continue
|
|
if not entry.value:
|
|
continue
|
|
return int(ctypes.cast(entry.value, ctypes.POINTER(ctypes.c_uint32))[0])
|
|
return None
|
|
|
|
|
|
def read_cardinal(conn: Conn, window: int, name: bytes) -> int | None:
|
|
"""Read the first 32-bit value of window property *name*.
|
|
|
|
Args:
|
|
conn: Live connection.
|
|
window: Window to query.
|
|
name: Property name, e.g. ``b"_NET_WM_PID"``.
|
|
|
|
Returns:
|
|
The value, or ``None`` if the property is absent or empty.
|
|
"""
|
|
conn.x11.XInternAtom.restype = ctypes.c_ulong
|
|
atom = conn.x11.XInternAtom(conn.display, name, _ONLY_IF_EXISTS)
|
|
if not atom:
|
|
return None
|
|
|
|
actual_type = ctypes.c_ulong()
|
|
actual_format = ctypes.c_int()
|
|
nitems = ctypes.c_ulong()
|
|
bytes_after = ctypes.c_ulong()
|
|
prop = ctypes.POINTER(ctypes.c_ulong)()
|
|
|
|
status = conn.x11.XGetWindowProperty(
|
|
conn.display,
|
|
ctypes.c_ulong(window),
|
|
ctypes.c_ulong(atom),
|
|
ctypes.c_long(0),
|
|
ctypes.c_long(1),
|
|
ctypes.c_int(0),
|
|
ctypes.c_ulong(_ANY_PROPERTY_TYPE),
|
|
ctypes.byref(actual_type),
|
|
ctypes.byref(actual_format),
|
|
ctypes.byref(nitems),
|
|
ctypes.byref(bytes_after),
|
|
ctypes.byref(prop),
|
|
)
|
|
if status != _SUCCESS or not prop:
|
|
return None
|
|
try:
|
|
if nitems.value == 0:
|
|
return None
|
|
return int(prop[0])
|
|
finally:
|
|
conn.x11.XFree(prop)
|