Why the enforcer could never actually restart Steam

Explaining commit 00a08de in steam-backlog-enforcer — two independent, silent bugs that together made the CDP restart path a no-op.

Background

Modern Steam clients don't store your library's "hidden games" list in a config file you can just edit — it lives inside an in-memory JavaScript object called collectionStore, running inside Steam's embedded Chromium browser (CEF). The only way in is CDP, the Chrome DevTools Protocol: the same debugging channel a browser dev-tools panel uses, exposed locally as a small HTTP + WebSocket server.

library_hider.py is the module that speaks this protocol. At a high level:

Enforcer process (often running as root, for other privileged jobs like editing /etc/hosts)
↓ needs Steam started with special flags
Steam client, launched with -cef-enable-debugging -devtools-port=<PORT>
↓ opens a local debug endpoint
CDP HTTP server: GET http://127.0.0.1:<PORT>/json → list of debuggable pages
↓ find the page titled "SharedJSContext"
WebSocket → evaluate JS → collectionStore.SetAppsAsHidden(...)

The function that stitches this together is ensure_steam_debug_port(): if the CDP port isn't reachable, it shuts Steam down and relaunches it with the right flags, then polls for up to 45 seconds waiting for the port (and later the collection store) to come up.

There's a wrinkle: Steam is a desktop app — it needs a real X11 display, and it will not behave sensibly if launched as root with no graphical session attached. But the enforcer sometimes is root, because other parts of it (blocking the Steam store in /etc/hosts, killing unauthorized processes) genuinely need root. So library_hider.py has a helper, _run_as_user(), whose whole job is: "if I'm root, drop down to the real desktop user before launching Steam."

This is the piece that turned out to be broken — not because its logic was wrong, but because the information it depended on was never there in the first place.

Intuition

This fix addresses two separate bugs that each independently guaranteed Steam's CDP port would never be reachable. Fixing only one would not have been enough — both had to go.

Bug 1 — nobody told the systemd service who the desktop user was

_run_as_user() decides whether to drop root privileges by checking two environment variables: SUDO_USER and USER. Both are only ever populated by one specific launch path — running the command interactively through sudo. The systemd service takes an entirely different path: systemd forks the process directly as root, with no sudo involved anywhere.

Path A — interactive, via sudo

sudo python3 -m ... enforce

sudo sets SUDO_USER=kuhy
↓
geteuid()==0 and user="kuhy" → drop privileges
↓
Steam launched as kuhy, with DISPLAY/XAUTHORITY/DBUS
CDP opens
Path B — systemd service (before this fix)

ExecStart=python3 -m ... enforce

No sudo anywhere → SUDO_USER, USER both unset
↓
geteuid()==0 but user=None → "user and user != root" is falsy
↓
Steam launched as bare root, no display session
CDP never opens

Live evidence this was really happening: journalctl showed the service crash-looping on exactly this failure — over 1000 restarts — each cycle waiting the full 45 seconds, timing out, and trying again.

Bug 2 — the CDP port itself was already taken, just not obviously

Even after fixing bug 1, a live check turned up a second, independent problem: the code hardcoded port 8080 for CDP, and this machine already had a different local service — Open WebUI — bound to that exact port number. Yet neither process failed to start with an "address already in use" error, which made this bug invisible until directly observed.

Open WebUI (pre-existing)
binds 0.0.0.0:8080

All IPv4 interfaces, including 127.0.0.1

steamwebhelper (CDP)
binds [::1]:8080

IPv6 loopback only — a different address family

Both binds succeed, because they're on different address families. But the enforcer's request is IPv4-only:

GET http://127.0.0.1:8080/json

Every single request from _get_shared_js_ws_url() resolved to Open WebUI's page, never to Steam's actual CDP target list — so the code always parsed the wrong HTML and concluded "CDP not available," even while Steam's CDP endpoint was healthy.

The fix moves _CDP_PORT to 9222 — the conventional Chrome DevTools debug port, confirmed unused on this machine — so there's no ambiguity about which service answers the enforcer's request.

Play with it: who does _resolve_desktop_user() pick?

The new helper resolves the desktop user with a strict priority order. Try clearing and setting the fields below (empty = unset) to see how the systemd fix and the old interactive path both resolve correctly now — and what happens if all three are empty.

_resolve_desktop_user() → None
STEAM_ENFORCER_DESKTOP_USER SUDO_USER USER

Code walkthrough

Grouped by concern, not by file — the four pieces below only work as a set.

1. A single source of truth for "who's the desktop user"

New helper in library_hider.py, with an explicit priority order and a comment explaining why each fallback exists:

+def _resolve_desktop_user() -> str | None:
+    """Resolve which desktop user owns the Steam/X11 session.
+
+    Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd
+    unit, which has no SUDO_USER/USER of its own since it is started
+    directly by systemd rather than via `sudo`), then falls back to
+    SUDO_USER/USER for interactive `sudo` invocations.
+    """
+    return (
+        os.environ.get("STEAM_ENFORCER_DESKTOP_USER")
+        or os.environ.get("SUDO_USER")
+        or os.environ.get("USER")
+    )

Both call sites that used to inline the (broken, under systemd) lookup now go through it:

 def _shutdown_steam() -> None:
     """Send ``steam -shutdown`` and wait for the process to exit."""
-    real_user = os.environ.get("SUDO_USER") or os.environ.get("USER")
+    real_user = _resolve_desktop_user()
     try:
         _run_as_user(["steam", "-shutdown"], real_user)

 def _launch_steam_with_debug() -> None:
     """Launch Steam with CEF debugging enabled."""
-    real_user = os.environ.get("SUDO_USER") or os.environ.get("USER")
+    real_user = _resolve_desktop_user()
     _run_as_user(

Note what didn't change: _run_as_user() itself is untouched. It already knew how to drop privileges correctly — it just never received a usable username to drop to.

2. Wiring the new variable end-to-end

The systemd unit gets the explicit variable, plus a hardened, explicit DISPLAY:

 Environment=HOME=/home/kuhy
+Environment=STEAM_ENFORCER_DESKTOP_USER=__DESKTOP_USER__
+Environment=DISPLAY=:0

__DESKTOP_USER__ is a placeholder, not a literal value — the checked-in .service file stays a reusable template. install.sh already had a sed pipeline substituting WorkingDirectory/PYTHONPATH at install time; this extends the same pipeline:

-    sed "s|WorkingDirectory=.*|WorkingDirectory=$SCRIPT_DIR|; s|PYTHONPATH=.*|PYTHONPATH=$SCRIPT_DIR|" \
+    DESKTOP_USER="${SUDO_USER:-$USER}"
+    sed "s|WorkingDirectory=.*|WorkingDirectory=$SCRIPT_DIR|; \
+         s|PYTHONPATH=.*|PYTHONPATH=$SCRIPT_DIR|; \
+         s|__DESKTOP_USER__|$DESKTOP_USER|" \
         "$SERVICE_SRC" > "$SERVICE_DST"

This is the one place SUDO_USER is used correctly by design: install.sh itself is required to run under sudo (it checks EUID earlier in the script), so at install time, SUDO_USER reliably holds the real invoking user — it's only the long-running service, started later by systemd with no sudo in the picture, where that variable goes missing.

3. Moving off the colliding port

-_CDP_PORT = 8080
+_CDP_PORT = 9222
+# NOTE: was 8080, which collided with a different local service (Open WebUI)
+# already bound to 0.0.0.0:8080. requests to 127.0.0.1:8080 resolved to that
+# service instead of steamwebhelper's CDP endpoint (which only bound
+# [::1]:8080), so CDP detection silently never worked. 9222 is the
+# conventional Chrome DevTools debug port and was confirmed free.

One constant, but it flows into both the launch flag (-devtools-port={_CDP_PORT}) and the detection query, so a single change fixes both sides consistently.

4. Tests for the new priority chain

Four cases covering the exact fallback order, so a future change can't silently reorder or drop a branch: explicit var wins, then SUDO_USER, then USER, then None when nothing is set — the same four states the playground above lets you trigger by hand.

Quiz

Click an option to reveal whether it's correct.