diff --git a/docs/explanations/2026-07-18-explanation-defer-steam-restart-during-update.html b/docs/explanations/2026-07-18-explanation-defer-steam-restart-during-update.html new file mode 100644 index 0000000..29a899d --- /dev/null +++ b/docs/explanations/2026-07-18-explanation-defer-steam-restart-during-update.html @@ -0,0 +1,459 @@ + + +
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.
+ +1 explanation · interactive walkthroughs of notable changes, newest first.
+2 explanations · interactive walkthroughs of notable changes, newest first.