How one guard fixed a deterministic game-crash bug in the Steam Backlog Enforcer · commit dbc3242 · 2026-07-18
The Steam Backlog Enforcer is a small systemd daemon whose one job is discipline: out of a library of ~1,370 games, exactly one — the assigned game — may be visible and installed. Everything else is hidden and, optionally, uninstalled. It runs an enforce loop every 3 seconds.
To hide games it can't just edit a file — Steam's library is a live web app. So the enforcer drives
Steam over the Chrome DevTools Protocol (CDP): it talks to the Steam client's embedded browser
on a debug port. That port only exists if Steam was launched with a special flag. So the enforcer has
a helper, ensure_steam_debug_port(), that guarantees the port is open — and if Steam is
running without it, the helper restarts Steam: steam -shutdown, then relaunch
with the debug flag.
Here is the piece that matters, before the change. If Steam is up but the port is closed, it is bounced — unconditionally:
def ensure_steam_debug_port() -> None: if _steam_has_debug_port(): return # port already open — nothing to do ... if _is_steam_running(): _shutdown_steam() # <-- bounce Steam, whatever it's doing _launch_steam_with_debug()
The trouble: Steam is also a download manager. At any moment it may be pulling a multi-gigabyte
game update — first downloading chunks, then committing them to disk (the staging
phase). steam -shutdown suspends whatever transfer is in flight.
Picture the assigned game (Age of Empires II DE) receiving a ~16 GB update the same morning the enforcer's restart logic started actually working. The two fight for hours. Steam's own log tells the story — the update is cancelled and restarted again and again, several times while committing files to disk:
Interrupt a commit halfway and the install is left inconsistent — some files from the new build, some
from the old, some half-written. The cruel part: Steam's "Verify integrity" reported all 350k
files "OK", so the corruption was invisible. But the game ships with Arxan anti-tamper, which
checks the code against itself at startup, spots the mismatch, and kills the process — a deterministic
0xc0000005 null-dereference crash at the exact same address, every launch. Environment,
Proton version, GPU, kernel, prefix — none of it mattered, because the broken bytes were the one
constant. Only a full reinstall fixed it.
The fix is a single question asked before the restart: is any game mid-transfer right now? If yes, don't touch Steam — skip this pass and try again in 3 seconds. The library stays un-hidden a little longer; the install stays intact. That trade is the whole change.
Steam records live progress inside each appmanifest_<id>.acf. A game is still working
if it has bytes left to download or bytes left to stage (commit). When both totals equal their
progress, it's idle. That's the entire rule:
Drag the progress values. The verdict updates with the exact
logic of _manifest_transfer_active(): active when ToDownload>Downloaded
or ToStage>Staged.
Toggle the world state. The verdict runs the new
ensure_steam_debug_port() decision tree.
The detector needs the Steam library path, which lived in game_install.py. But the guard
needs to run inside library_hider.py — and game_install already imports
library_hider. Importing the other way would form a cycle. So the scan and the
STEAMAPPS_PATH constant moved to a dependency-free leaf module,
_steam_state.py, that both can import safely:
# _steam_state.py — imports only the stdlib, so no import cycle STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() def _manifest_transfer_active(manifest: Path) -> bool: # BytesTo* are totals; *Downloaded/*Staged are progress. return ( _field("BytesToDownload") > _field("BytesDownloaded") or _field("BytesToStage") > _field("BytesStaged") ) def steam_update_in_progress() -> bool: return any(_manifest_transfer_active(m) for m in STEAMAPPS_PATH.glob("appmanifest_*.acf"))
Using the byte totals (not StateFlags) is deliberate: BytesToStage > BytesStaged
is exactly the commit-to-disk phase — the most dangerous moment to interrupt.
In the daemon path, the check goes right before the shutdown. It raises a new
SteamUpdateInProgressError — deliberately a subclass of the existing
SteamUnavailableError, so the enforce loop's current handler already treats it as
"skip this pass, retry next tick." No new error-handling was needed anywhere.
if _is_steam_running(): if steam_update_in_progress(): logger.info("Deferring Steam restart: a game update is in progress...") raise SteamUpdateInProgressError(msg) # caught -> skip + retry in 3s _shutdown_steam() _launch_steam_with_debug()
The interactive "buy DLC → re-block store → restart Steam" path (restart_steam()) got the
same guard, but since it isn't inside the retry loop it simply logs a warning and skips the
restart rather than raising.
game_install.py now imports STEAMAPPS_PATH from the leaf (single source of
truth). The test conftest.py redirects both bindings of the path to a temp dir —
the repo's established pattern, since each module holds its own import. A new
test_update_restart_guard.py adds 13 tests (manifest active/idle/complete/unreadable, the
scan, and both guard paths), keeping the suite at 988 passing / 100% coverage.
Deferring Steam restart: a game update is in progress in the journal — the enforcer chose
not to bounce Steam, exactly as designed.
Five questions on the reasoning. Click to reveal.