Never restart Steam mid-update

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

Contents
  1. Background — the enforcer and why it restarts Steam
  2. Intuition — a restart mid-download is a shredder
  3. Code — the guard, the scan, and one leaf module
  4. Quiz

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