A Windows launch is a hand-off, and 0.30 read its exit as the game's #288

Merged
enricobuehler merged 1 commits from worktree-win-game-launch-untracked into main 2026-08-18 08:19:52 +00:00
1 Commits
Author SHA1 Message Date
enricobuehler 6352ff629d fix(host/library): a Windows launch is a hand-off, and 0.30 read its exit as the game's
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 12m3s
ci / rust (pull_request) Successful in 16m5s
A user launched a title on a 0.30 Windows host and watched the game come up while
the stream dropped, with the console reporting nothing running. Two changes from
0.30 meet here, and the second turns the first into a disconnect.

`LeaseRequest::spawned` (0.30) started carrying the pid `CreateProcessAsUserW`
hands back, so a Windows launch could finally be a `LeaseKind::Child` instead of
degrading to `Untracked`. But `windows_launch_for` builds a protocol hand-off for
almost every kind — `explorer.exe "playnite://…"`, `Steam.exe "steam://…"`,
`explorer.exe "shell:AppsFolder\…"` — so that pid is a forwarder's, not the
game's, and it says nothing about the game in either direction: it quits a second
after launch when the launcher was already running, and it *becomes* the launcher
when it was not.

The watcher's shim window exists for exactly this, and both callers of the pid
path documented it as their protection — but it was skipped whenever the title
had no detect signals, which is the one shape that needs it. So a hint-less title
(a Playnite entry whose install dir Playnite does not know, a custom entry with no
hint) reported `running` on its first poll because the forwarder was alive, and
`exited` a second later when the forwarder quit: `finish` fired `on_exit`, which
closes the connection with `APP_EXITED`. In 0.29 the same title was `Untracked`,
nothing watched it, and the session stayed up — which is the "it used to just show
as running" the report describes.

The forwarder pid is not only a false liveness signal, it is a termination target:
`windows_term_ladder` adds `shared.spawned` to the pids it `WM_CLOSE`s and then
terminates. A Steam title launched while Steam was closed makes that pid the Steam
*client*, so `POST /game/end`, a grace expiry or `game_on_session_end` would ask
the player's whole Steam client to close rather than the game.

* The Windows recipe table now says which lines start the game and which only
  forward it (`WinRecipe::owns_game`); a forwarder's pid is dropped rather than
  carried, so the lease falls back to detect signals exactly as it did before.
  Only `gog` (the resolved exe), `command` and a plugin's own recipe (`cmd.exe /c`
  blocks on them) own their game.
* The shim window now applies to a bare child or pid whatever the spec holds. An
  empty spec is fewer reasons to trust the child, not more.
* Giving up on tracking lands the lease on `GameState::Untracked` instead of
  leaving the console on `launching` forever — the same honest answer `open`
  reaches when it starts no watcher at all.

`a_pid_only_launch_reports_its_exit` used a 4-second fixture, inside the shim
window; it passed only because of the bug above. It is 8 seconds now, and a new
ignored test drives the field report itself: a pid that hands off inside the
window with no signals must not end the session, and must leave the row
`untracked`. Backing the phase-1 change out fails that test with `on_exit` fired
once — the disconnect, reproduced.

Gates. Windows .173: `clippy --all-targets -D warnings` clean (non-vacuous), and
`library::launch` 11 + `gamelease` 12 tests pass. Linux (punktfunk-rust-ci): fmt
clean, `clippy --all-targets -D warnings` clean, `gamelease` 14 pass, and all
three `--ignored` live-process tests pass.
2026-08-18 09:30:57 +02:00