From be2b7c7b93a033d08cb224607898f1a107e6845d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 18 Jul 2026 16:15:54 +0200 Subject: [PATCH] Add change explanation: defer-steam-restart-during-update Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Vw1sLrSjDksvH5zjU2RMYB --- ...ion-defer-steam-restart-during-update.html | 459 ++++++++++++++++++ docs/explanations/index.html | 6 +- 2 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 docs/explanations/2026-07-18-explanation-defer-steam-restart-during-update.html 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 @@ + + +Defer Steam restart while a game update is in progress + + + + +
+

Never restart Steam mid-update

+

How one guard fixed a deterministic game-crash bug in the Steam Backlog Enforcer · commit dbc3242 · 2026-07-18

+ + + +

1 · Background

+

+ 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. +

+
+ A one-off suspend-and-resume is usually harmless — Steam is built to resume downloads. The danger is + repetition. A daemon that bounces Steam every time it wants the CDP port, while a big update is + trying to finish, interrupts that update over and over — including mid-commit. +
+ +

2 · Intuition

+

+ 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: +

+
+ 06:29update canceled : Disabled (Suspended)  ← steam -shutdown + 07:15update canceled : Disabled (Suspended) + 07:21App update changed : Running Update, Committing  ← writing files + 09:22update canceled : Priority (Suspended) + 10:00update canceled : Restart (Suspended)  ← Steam bounced again +
+

+ 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. +

+ +

Before vs after — the decision

+
+ enforcer wants CDP port + → + Steam bounced mid-update + → + install corrupted → crash +
+
+ enforcer wants CDP port + → + update in progress? → defer + → + retry in 3s, restart once idle +
+

+ 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. +

+ +

How "mid-transfer" is detected

+

+ 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: +

+
+

+ +
+

▶ Play: the manifest scan

+

Drag the progress values. The verdict updates with the exact + logic of _manifest_transfer_active(): active when ToDownload>Downloaded + or ToStage>Staged.

+
+
+
+ +
+

▶ Play: should the enforcer restart Steam?

+

Toggle the world state. The verdict runs the new + ensure_steam_debug_port() decision tree.

+
+
+
+ +

3 · Code

+ +

A. The scan — a new leaf module

+

+ 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. +

+ +

B. The guard — defer instead of bounce

+

+ 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. +

+ +

C. Wiring & tests

+

+ 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. +

+
+ Verified live: restarting the real daemon while a runtime update was mid-flight produced + Deferring Steam restart: a game update is in progress in the journal — the enforcer chose + not to bounce Steam, exactly as designed. +
+ +

4 · Quiz

+

Five questions on the reasoning. Click to reveal.

+
+
Score: 0 / 0
+
+ + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 3bbf89f..21cad16 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,12 +30,16 @@
steam-backlog-enforcer

Change explanations

-

1 explanation · interactive walkthroughs of notable changes, newest first.

+

2 explanations · interactive walkthroughs of notable changes, newest first.