Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch #372

Merged
enricobuehler merged 1 commits from worktree-steam-prelaunch-latch into main 2026-08-22 18:32:34 +00:00
Owner

A player had to launch Rocket League twice: the first launch streamed the "Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host did that to itself.

What the log shows

From a field report on 2026-08-22 (Linux, KDE/kwin, H265/NVENC), times in 18:39:

t event
24.816 host spawns steam steam://rungameid/252950
25.822 that command hands off and exits — lease falls back to recognizing the game by scan (kind=matched)
26.832 the launched game is running … procs=1 — the scan matched one process, 2 s in
~31.9 that process disappears
34.873 the launched game exited reason=the game exitedAPP_EXITED, stream torn down, sent=864

reason=the game exited pins the branch exactly: the phase-2 arm that fires when the process scan has seen nothing for EXIT_CONFIRM (3 s). Not the child-exit path, not a provider report.

Root cause

DetectSpec::steam_appid asserted that reaper SteamLaunch AppId=<appid> has a lifetime "exactly the game's". It does not — that reaper belongs to the appid, not the game. Steam wraps its pre-launch work for a title in one too (shader pre-caching most visibly), so a launch is a chain of appid-tagged trees and only the last of them is the game.

That is fatal because the two watcher phases are patient in opposite directions:

  • Phase 1 (game not yet seen) waits START_GRACE = 300 s, and expiring ends nothing.
  • Phase 2 (game seen once) waits EXIT_CONFIRM = 3 s, and expiring ends the session.

A single sighting on a single poll latched the lease from the first into the second, permanently. When that tree exited with the game still starting, phase 2 called it the game exiting.

Linux has nothing to catch it: procscan::running_hint is Windows-only (returns None unconditionally), and no provider reports runstate for Steam — so an appid scan with three seconds of slack is the entire liveness signal. The second launch works because cached shaders mean there is no pre-launch tree to latch onto.

Steam's registry.vdf was considered as the Linux equivalent of the Windows veto and rejected: RunningAppID is no longer set on modern Steam Linux (ValveSoftware/steam-for-linux#9672) and the per-app Running key is unreliable.

The change

Two layers, because only one of them can be certain:

  1. procscan/linux.rs — the appid recipe now rejects a SteamLaunch AppId= reaper whose payload is fossilize_replay. Steam's shader replayer is never a game, and it is the one pre-launch tree that can be identified positively.
  2. gamelease.rs — a scan match must be seen continuously for SHIM_WINDOW before it latches out of phase 1. This is the rule already applied to a spawned child ("a launcher about to hand off looks exactly like the game for its first few seconds"); the scan side never had it. It bounds the pre-launch trees nobody has named yet, at the cost of a few seconds of GameRunning latency. Exit detection is untouched, and a provider report still latches immediately — that is the launcher's own statement, not an inference from a lookalike process.
  3. procscan::names() — the "game is running" line now logs which processes were adopted. procs=1 is precisely why this report could not be closed from its log.

DetectSpec's doc comment is corrected, since that assumption is what the code was built on.

Known ceiling

Named in the code: a pre-launch tree that outlives SHIM_WINDOW still latches. Layer 1 handles the shader case specifically; if a longer-lived one turns up, the new names= field will identify it.

The log records procs=1 without naming the process, so it cannot be proven from the log alone that the match was the shader job specifically. Both layers are built to hold either way.

Tests

  • procscan: the shader reaper and the game reaper — same appid, same wrapper — resolve to only the game.
  • gamelease: a Linux regression test (#[ignore]d like its siblings, ~11 s) drives a real short-lived matching process and asserts the session-ending action never fires.

Verification status

rustfmt --check passes on all five files under the CI-identical toolchain, and both changed rules were verified natively against verbatim copies (including a replay of the incident's second-by-second sequence, which no longer latches). The full cargo test -p punktfunk-host --lib in the Linux CI image did not converge locally — the emulated Docker VM was saturated — so this PR's ci / rust run is the first real compile. The Windows name_of helper is not compiled by PR CI at all; windows-host.yml should be dispatched on this branch.

No API surface is touched, so no OpenAPI regeneration.

A player had to launch Rocket League twice: the first launch streamed the "Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host did that to itself. ## What the log shows From a field report on 2026-08-22 (Linux, KDE/kwin, H265/NVENC), times in `18:39`: | t | event | |---|---| | `24.816` | host spawns `steam steam://rungameid/252950` | | `25.822` | that command hands off and exits — lease falls back to recognizing the game by scan (`kind=matched`) | | `26.832` | `the launched game is running … procs=1` — the scan matched **one** process, 2 s in | | `~31.9` | that process disappears | | `34.873` | `the launched game exited reason=the game exited` → `APP_EXITED`, stream torn down, `sent=864` | `reason=the game exited` pins the branch exactly: the phase-2 arm that fires when the process scan has seen nothing for `EXIT_CONFIRM` (3 s). Not the child-exit path, not a provider report. ## Root cause `DetectSpec::steam_appid` asserted that `reaper SteamLaunch AppId=<appid>` has a lifetime "exactly the game's". It does not — that reaper belongs to the **appid**, not the game. Steam wraps its *pre-launch* work for a title in one too (shader pre-caching most visibly), so a launch is a chain of appid-tagged trees and only the last of them is the game. That is fatal because the two watcher phases are patient in opposite directions: - **Phase 1** (game not yet seen) waits `START_GRACE` = 300 s, and expiring ends *nothing*. - **Phase 2** (game seen once) waits `EXIT_CONFIRM` = 3 s, and expiring **ends the session**. A single sighting on a single poll latched the lease from the first into the second, permanently. When that tree exited with the game still starting, phase 2 called it the game exiting. Linux has nothing to catch it: `procscan::running_hint` is Windows-only (returns `None` unconditionally), and no provider reports runstate for Steam — so an appid scan with three seconds of slack is the entire liveness signal. The second launch works because cached shaders mean there is no pre-launch tree to latch onto. Steam's `registry.vdf` was considered as the Linux equivalent of the Windows veto and rejected: `RunningAppID` is no longer set on modern Steam Linux (ValveSoftware/steam-for-linux#9672) and the per-app `Running` key is unreliable. ## The change Two layers, because only one of them can be certain: 1. **`procscan/linux.rs`** — the appid recipe now rejects a `SteamLaunch AppId=` reaper whose payload is `fossilize_replay`. Steam's shader replayer is never a game, and it is the one pre-launch tree that can be identified positively. 2. **`gamelease.rs`** — a scan match must be seen *continuously* for `SHIM_WINDOW` before it latches out of phase 1. This is the rule already applied to a spawned child ("a launcher about to hand off looks *exactly* like the game for its first few seconds"); the scan side never had it. It bounds the pre-launch trees nobody has named yet, at the cost of a few seconds of `GameRunning` latency. Exit detection is untouched, and a provider report still latches immediately — that is the launcher's own statement, not an inference from a lookalike process. 3. **`procscan::names()`** — the "game is running" line now logs *which* processes were adopted. `procs=1` is precisely why this report could not be closed from its log. `DetectSpec`'s doc comment is corrected, since that assumption is what the code was built on. ## Known ceiling Named in the code: a pre-launch tree that outlives `SHIM_WINDOW` still latches. Layer 1 handles the shader case specifically; if a longer-lived one turns up, the new `names=` field will identify it. The log records `procs=1` without naming the process, so it cannot be proven from the log alone that the match was the shader job specifically. Both layers are built to hold either way. ## Tests - `procscan`: the shader reaper and the game reaper — same appid, same wrapper — resolve to only the game. - `gamelease`: a Linux regression test (`#[ignore]`d like its siblings, ~11 s) drives a real short-lived matching process and asserts the session-ending action never fires. ## Verification status `rustfmt --check` passes on all five files under the CI-identical toolchain, and both changed rules were verified natively against verbatim copies (including a replay of the incident's second-by-second sequence, which no longer latches). **The full `cargo test -p punktfunk-host --lib` in the Linux CI image did not converge locally** — the emulated Docker VM was saturated — so this PR's `ci / rust` run is the first real compile. The Windows `name_of` helper is not compiled by PR CI at all; `windows-host.yml` should be dispatched on this branch. No API surface is touched, so no OpenAPI regeneration.
enricobuehler added 1 commit 2026-08-22 18:22:20 +00:00
fix(host): Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
8670b412c7
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.

`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.

Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)

Two layers, because only one of them can be certain:

* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
  `fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
  This is the rule already applied to a spawned child ("a launcher about to
  hand off looks exactly like the game for its first few seconds"); the scan
  side never had it. It bounds the pre-launch trees nobody has named yet, at
  the cost of a few seconds of GameRunning latency. Exit detection is
  untouched, and a provider report still latches immediately — that is the
  launcher's own statement, not an inference from a lookalike.

The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
enricobuehler merged commit 34a02fdac5 into main 2026-08-22 18:32:34 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#372