mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 13:07:33 +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
154 lines
5.1 KiB
Python
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)
|