steam-backlog-enforcer/steam_backlog_enforcer/_controller_input.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

154 lines
5.1 KiB
Python

"""Noticing that a game controller is being used.
The X screensaver idle counter only tracks keyboards and pointers. A gamepad
session that goes five minutes without touching either would look idle, and
pausing the budget there would hand out free gaming — the single worst failure
mode of the engagement gate, because it rewards the behaviour it fails to see.
Presence is not a usable proxy for use. Measured on this machine, ``/dev/input/js0``
is an *ASRock LED Controller*: the kernel exposes the motherboard's RGB device as
a joystick. Treating "a js device exists" as "a controller is in play" would
disable the idle criterion permanently, on a machine with no gamepad attached.
So this watches for actual events instead. Each joystick device is opened
non-blocking and drained every tick; joydev gives every reader its own event
queue, so draining ours cannot steal input from the game. The synthetic burst
joydev replays on open is flagged ``JS_EVENT_INIT`` and is skipped — without that
filter, merely opening the device would look like a button press.
"""
from __future__ import annotations
import contextlib
import errno
import logging
import os
from pathlib import Path
from typing import Final
logger = logging.getLogger(__name__)
_DEVICE_DIR: Final = Path("/dev/input")
_DEVICE_PREFIX: Final = "js"
# struct js_event { __u32 time; __s16 value; __u8 type; __u8 number; }
_EVENT_SIZE: Final = 8
_TYPE_OFFSET: Final = 6
_JS_EVENT_INIT: Final = 0x80
# Cap per device per tick so a flood cannot stall the enforce loop.
_MAX_EVENTS_PER_POLL: Final = 64
class ControllerActivity:
"""Tracks when a joystick device last produced a real event."""
def __init__(self, *, device_dir: Path = _DEVICE_DIR) -> None:
"""Start with no devices open and no activity seen.
Args:
device_dir: Directory holding ``js*`` nodes.
"""
self._device_dir = device_dir
self._fds: dict[Path, int] = {}
self._last_event_at: float | None = None
def poll(self, *, now: float) -> None:
"""Drain pending controller events, recording any real activity.
Args:
now: Monotonic timestamp for this tick.
"""
self._sync_devices()
for path in list(self._fds):
if self._drain(path):
self._last_event_at = now
def seconds_since_activity(self, *, now: float) -> float | None:
"""Return seconds since the last real controller event.
Args:
now: Monotonic timestamp for this tick.
Returns:
The elapsed seconds, or ``None`` if no event has been seen at all —
which the caller must not read as "idle", only as "no evidence".
"""
if self._last_event_at is None:
return None
return now - self._last_event_at
def close(self) -> None:
"""Close every open device."""
for path in list(self._fds):
self._forget(path)
def _sync_devices(self) -> None:
"""Open newly appeared ``js*`` devices and forget vanished ones."""
try:
present = {
entry
for entry in self._device_dir.iterdir()
if entry.name.startswith(_DEVICE_PREFIX)
}
except OSError:
present = set()
for path in list(self._fds):
if path not in present:
self._forget(path)
for path in present - set(self._fds):
self._open(path)
def _open(self, path: Path) -> None:
"""Open *path* for reading, ignoring devices we may not read.
Args:
path: Device to open.
"""
try:
self._fds[path] = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
# A js node we cannot open tells us nothing either way; the idle
# criterion simply falls back to keyboard and pointer input.
logger.debug("Cannot open controller device %s", path)
def _drain(self, path: Path) -> bool:
"""Read pending events from *path*.
Args:
path: Device to drain.
Returns:
Whether at least one non-synthetic event was read.
"""
fd = self._fds[path]
seen = False
for _ in range(_MAX_EVENTS_PER_POLL):
try:
chunk = os.read(fd, _EVENT_SIZE)
except OSError as exc:
if exc.errno != errno.EAGAIN:
self._forget(path)
break
if len(chunk) < _EVENT_SIZE:
break
if not chunk[_TYPE_OFFSET] & _JS_EVENT_INIT:
seen = True
return seen
def _forget(self, path: Path) -> None:
"""Close and drop *path*.
Args:
path: Device to close.
"""
fd = self._fds.pop(path, None)
if fd is not None:
# The usual reason to forget a device is that reading it just
# failed, which often means the fd is already unusable; closing it
# again must not take the enforce loop down with it.
with contextlib.suppress(OSError):
os.close(fd)