Explaining commit 00a08de in steam-backlog-enforcer — two independent, silent bugs that together made the CDP restart path a no-op.
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:
/etc/hosts)-cef-enable-debugging -devtools-port=<PORT>GET http://127.0.0.1:<PORT>/json → list of debuggable pagescollectionStore.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.
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.
_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.
sudo
sudo python3 -m ... enforce
SUDO_USER=kuhy
ExecStart=python3 -m ... enforce
SUDO_USER, USER both unset
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.
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.
All IPv4 interfaces, including 127.0.0.1
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:
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.
_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.
Grouped by concern, not by file — the four pieces below only work as a set.
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.
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.
-_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.
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.
Click an option to reveal whether it's correct.