Field report on a 0.30 Windows host: the game launches, the stream drops, and the console reports nothing running.
What actually happens
Not the GameState::Untracked rename — that only changed what a row says. The disconnect is LeaseRequest::spawned meeting windows_launch_for.
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\…" — and its own doc-comments say so. That pid is a forwarder's, not the game's, and it says nothing about the game in either direction.
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 most. So a hint-less title reported running on its first poll (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.
Second door on the same path: when the launcher was not already running, Steam.exe <uri>becomes the Steam client and outlives every game, so the lease claims running forever. And windows_term_ladder adds shared.spawned to the pids it WM_CLOSEs — so POST /game/end or a grace expiry would ask the player's whole Steam client to close.
The change
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 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.
Log line that identifies this in the field
the launched game is running kind="child" procs=0, followed within ~1 s by the launched game exited reason="the launched process exited".
That discriminates it from the two 0.29 shapes: this title exposes nothing the host can use to recognize its process (untracked) and the launched game never appeared (matcher miss).
Tests
a_pid_only_launch_reports_its_exit used a 4-second fixture — inside the 5 s shim window. It passed only because of this bug; it is 8 seconds now. 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.
A/B: backing out only the phase-1 line fails that test with on_exit fired once — the disconnect, reproduced.
Linux (punktfunk-rust-ci): cargo fmt --check clean, clippy --all-targets -D warnings clean, gamelease 14 tests pass, and all three --ignored live-process tests pass.
Not done: on-glass on .173 — it would replace the canary installed there, and that box runs PUNKTFUNK_HOST_CMD=serve (GameStream off), so a real session needs setup first.
Companion: unom/punktfunk-plugin-playnite#7 makes hint-less Playnite titles trackable in the first place, which is the population that hit this.
Field report on a 0.30 Windows host: the game launches, **the stream drops**, and the console reports nothing running.
## What actually happens
Not the `GameState::Untracked` rename — that only changed what a row *says*. The disconnect is `LeaseRequest::spawned` meeting `windows_launch_for`.
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\…"` — and its own doc-comments say so. That pid is a forwarder's, not the game's, and it says nothing about the game in either direction.
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 most. So a hint-less title reported `running` on its first poll (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.
Second door on the same path: when the launcher was **not** already running, `Steam.exe <uri>` *becomes* the Steam client and outlives every game, so the lease claims `running` forever. And `windows_term_ladder` adds `shared.spawned` to the pids it `WM_CLOSE`s — so `POST /game/end` or a grace expiry would ask the player's whole Steam client to close.
## The change
- 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 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.
## Log line that identifies this in the field
`the launched game is running kind="child" procs=0`, followed within ~1 s by `the launched game exited reason="the launched process exited"`.
That discriminates it from the two 0.29 shapes: `this title exposes nothing the host can use to recognize its process` (untracked) and `the launched game never appeared` (matcher miss).
## Tests
`a_pid_only_launch_reports_its_exit` used a **4-second** fixture — inside the 5 s shim window. It passed only because of this bug; it is 8 seconds now. 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`.
**A/B:** backing out only the phase-1 line fails that test with `on_exit` fired once — the disconnect, reproduced.
## Gates
- **Windows .173** (canary 0.31.14308): `cargo clippy --all-targets -p punktfunk-host -- -D warnings` clean and non-vacuous; `library::launch` 11 tests, `gamelease` 12 tests pass.
- **Linux** (`punktfunk-rust-ci`): `cargo fmt --check` clean, `clippy --all-targets -D warnings` clean, `gamelease` 14 tests pass, and all three `--ignored` live-process tests pass.
Not done: on-glass on .173 — it would replace the canary installed there, and that box runs `PUNKTFUNK_HOST_CMD=serve` (GameStream off), so a real session needs setup first.
Companion: **unom/punktfunk-plugin-playnite#7** makes hint-less Playnite titles trackable in the first place, which is the population that hit this.
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.
Windows 11 + RTX 4090, Moonlight 6.1.0 from the Mac over the GameStream plane.
Reproduction (no Playnite needed)
A custom library entry with no detect and launch = {kind:"command", value:'start "" "C:\Windows\System32\notepad.exe"'}. Two properties make it the exact field shape: spec_from_command infers nothing (the first token is start, not an absolute exe) so the DetectSpec is empty, and cmd.exe /c start "" returns in ~1 s while notepad keeps running — so the spawned pid is a forwarder and the "game" outlives it.
Before — canary 0.31.14308, verbatim
07:55:51.953 launched library title ... cmdline=cmd.exe /c start "" "...notepad.exe" pid=19340
07:55:51.955 watching the launched game kind="child"
07:55:51.955 the launched game is running kind="child" procs=0
07:55:52.955 the launched game exited reason="the launched process exited"
07:55:52.955 the launched game exited - ending the session
07:55:52.956 gamestream: session ended reason="game exited" was_streaming=true
Moonlight dropped 1.0 s after the launch, and notepad was still running afterwards — the field report exactly: the game starts on the host, the stream dies.
After — this branch
08:01:29.593 launched library title ... pid=8628 owns_game=true
08:01:29.593 watching the launched game kind="child"
08:01:30.594 the launch command exited immediately (a launcher handing off) and this title
has no detect signals - stopping game tracking for it
Stream still up at 45 s. GET /status while streaming:
steam:2835570 with Steam already running: owns_game=false on the launch line and the lease opens kind="matched" rather than kind="child" — the forwarder pid is dropped, which is the whole point of the recipe-table change.
⚠ The game itself never started on that box (it likely wants an update; there is no display attached), so the matched → running → exited → session ends leg was not re-verified here. That path is untouched by this change and was verified on glass on 2026-07-26.
Unrelated bug found while doing this
The host aborts on GameStream session teardown: panicked at library\std\src\thread\local.rs:428:25 (TLS access during/after destruction), then thread panicked while processing panic. aborting. — the process dies and the SCM restarts it, ~6 s down.
Seen on the canary, not this branch, and twice in that log: 2026-08-17 18:59 after a display-topology restore, and 2026-08-18 07:55 after video stream stopped. So it is a session-teardown abort, not tied to the game-exit path, and out of scope here. Not filed yet.
Box state
Restored: probe entry deleted, host.env back to serve (GameStream off again), canary 0.31.14308 back in place, test pairing removed.
## On glass on .173 — reproduced, then fixed
Windows 11 + RTX 4090, Moonlight 6.1.0 from the Mac over the GameStream plane.
### Reproduction (no Playnite needed)
A custom library entry with **no `detect`** and `launch = {kind:"command", value:'start "" "C:\Windows\System32\notepad.exe"'}`. Two properties make it the exact field shape: `spec_from_command` infers nothing (the first token is `start`, not an absolute exe) so the `DetectSpec` is empty, and `cmd.exe /c start ""` returns in ~1 s while notepad keeps running — so the spawned pid is a forwarder and the "game" outlives it.
### Before — canary 0.31.14308, verbatim
```
07:55:51.953 launched library title ... cmdline=cmd.exe /c start "" "...notepad.exe" pid=19340
07:55:51.955 watching the launched game kind="child"
07:55:51.955 the launched game is running kind="child" procs=0
07:55:52.955 the launched game exited reason="the launched process exited"
07:55:52.955 the launched game exited - ending the session
07:55:52.956 gamestream: session ended reason="game exited" was_streaming=true
```
Moonlight dropped **1.0 s** after the launch, and notepad was still running afterwards — the field report exactly: the game starts on the host, the stream dies.
### After — this branch
```
08:01:29.593 launched library title ... pid=8628 owns_game=true
08:01:29.593 watching the launched game kind="child"
08:01:30.594 the launch command exited immediately (a launcher handing off) and this title
has no detect signals - stopping game tracking for it
```
Stream still up at 45 s. `GET /status` while streaming:
```json
{"app_id":"custom:d93d3d89f8c1","title":"PF Handoff Probe","state":"untracked"}
```
`video_streaming: true`. Honest row, live session.
### Steam leg
`steam:2835570` with Steam already running: `owns_game=false` on the launch line and the lease opens `kind="matched"` rather than `kind="child"` — the forwarder pid is dropped, which is the whole point of the recipe-table change.
⚠ The game itself never started on that box (it likely wants an update; there is no display attached), so the `matched → running → exited → session ends` leg was **not** re-verified here. That path is untouched by this change and was verified on glass on 2026-07-26.
### Unrelated bug found while doing this
The host **aborts** on GameStream session teardown: `panicked at library\std\src\thread\local.rs:428:25` (TLS access during/after destruction), then `thread panicked while processing panic. aborting.` — the process dies and the SCM restarts it, ~6 s down.
Seen on the **canary, not this branch**, and twice in that log: 2026-08-17 18:59 after a display-topology restore, and 2026-08-18 07:55 after `video stream stopped`. So it is a session-teardown abort, not tied to the game-exit path, and out of scope here. Not filed yet.
### Box state
Restored: probe entry deleted, `host.env` back to `serve` (GameStream off again), canary 0.31.14308 back in place, test pairing removed.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Field report on a 0.30 Windows host: the game launches, the stream drops, and the console reports nothing running.
What actually happens
Not the
GameState::Untrackedrename — that only changed what a row says. The disconnect isLeaseRequest::spawnedmeetingwindows_launch_for.0.30 started carrying the pid
CreateProcessAsUserWhands back, so a Windows launch could finally be aLeaseKind::Childinstead of degrading toUntracked. Butwindows_launch_forbuilds a protocol hand-off for almost every kind —explorer.exe "playnite://…",Steam.exe "steam://…",explorer.exe "shell:AppsFolder\…"— and its own doc-comments say so. That pid is a forwarder's, not the game's, and it says nothing about the game in either direction.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 most. So a hint-less title reported
runningon its first poll (the forwarder was alive) andexiteda second later when the forwarder quit:finishfiredon_exit, which closes the connection withAPP_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.Second door on the same path: when the launcher was not already running,
Steam.exe <uri>becomes the Steam client and outlives every game, so the lease claimsrunningforever. Andwindows_term_ladderaddsshared.spawnedto the pids itWM_CLOSEs — soPOST /game/endor a grace expiry would ask the player's whole Steam client to close.The change
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. Onlygog(the resolved exe),commandand a plugin's own recipe (cmd.exe /cblocks on them) own their game.GameState::Untrackedinstead of leaving the console onlaunchingforever — the same honest answeropenreaches when it starts no watcher at all.Log line that identifies this in the field
the launched game is running kind="child" procs=0, followed within ~1 s bythe launched game exited reason="the launched process exited".That discriminates it from the two 0.29 shapes:
this title exposes nothing the host can use to recognize its process(untracked) andthe launched game never appeared(matcher miss).Tests
a_pid_only_launch_reports_its_exitused a 4-second fixture — inside the 5 s shim window. It passed only because of this bug; it is 8 seconds now. 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 rowuntracked.A/B: backing out only the phase-1 line fails that test with
on_exitfired once — the disconnect, reproduced.Gates
cargo clippy --all-targets -p punktfunk-host -- -D warningsclean and non-vacuous;library::launch11 tests,gamelease12 tests pass.punktfunk-rust-ci):cargo fmt --checkclean,clippy --all-targets -D warningsclean,gamelease14 tests pass, and all three--ignoredlive-process tests pass.Not done: on-glass on .173 — it would replace the canary installed there, and that box runs
PUNKTFUNK_HOST_CMD=serve(GameStream off), so a real session needs setup first.Companion: unom/punktfunk-plugin-playnite#7 makes hint-less Playnite titles trackable in the first place, which is the population that hit this.
On glass on .173 — reproduced, then fixed
Windows 11 + RTX 4090, Moonlight 6.1.0 from the Mac over the GameStream plane.
Reproduction (no Playnite needed)
A custom library entry with no
detectandlaunch = {kind:"command", value:'start "" "C:\Windows\System32\notepad.exe"'}. Two properties make it the exact field shape:spec_from_commandinfers nothing (the first token isstart, not an absolute exe) so theDetectSpecis empty, andcmd.exe /c start ""returns in ~1 s while notepad keeps running — so the spawned pid is a forwarder and the "game" outlives it.Before — canary 0.31.14308, verbatim
Moonlight dropped 1.0 s after the launch, and notepad was still running afterwards — the field report exactly: the game starts on the host, the stream dies.
After — this branch
Stream still up at 45 s.
GET /statuswhile streaming:video_streaming: true. Honest row, live session.Steam leg
steam:2835570with Steam already running:owns_game=falseon the launch line and the lease openskind="matched"rather thankind="child"— the forwarder pid is dropped, which is the whole point of the recipe-table change.⚠ The game itself never started on that box (it likely wants an update; there is no display attached), so the
matched → running → exited → session endsleg was not re-verified here. That path is untouched by this change and was verified on glass on 2026-07-26.Unrelated bug found while doing this
The host aborts on GameStream session teardown:
panicked at library\std\src\thread\local.rs:428:25(TLS access during/after destruction), thenthread panicked while processing panic. aborting.— the process dies and the SCM restarts it, ~6 s down.Seen on the canary, not this branch, and twice in that log: 2026-08-17 18:59 after a display-topology restore, and 2026-08-18 07:55 after
video stream stopped. So it is a session-teardown abort, not tied to the game-exit path, and out of scope here. Not filed yet.Box state
Restored: probe entry deleted,
host.envback toserve(GameStream off again), canary 0.31.14308 back in place, test pairing removed.