Follow-up to #70 and #75. This is WP-B1, the last open correctness question in the launcher-tile work — settled before the first tile is ever clicked.
The problem
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was still deciding its lifetime from whatever happened to be true at launch — and the two outcomes disagree:
at launch
lease
result
launcher not already running
the spawned child stays alive → Child
quitting the launcher ends the session
launcher already running
the command forwards to the live instance and exits inside SHIM_WINDOW, no detect signals behind it → Untracked
the session persists
Same tile, two lifetimes, chosen by something the user cannot see.
Why "untracked" is the right one, not just the safe one
Steam settles it: Big Picture is a mode of an already-running Steam client, not a process — and on a Deck or SteamOS host Steam is always running. So no process-level signal can express "the launcher's window closed"; the tracked branch isn't a better answer that's merely harder to reach, it's unreachable in the case that matters most. Heroic has the same shape for a different reason (single-instance Electron: a second invocation forwards and exits).
The change
A launcher entry is now LeaseKind::Untracked unconditionally. The check sits ahead ofnested/child/spec, and that ordering is the fix rather than an implementation detail: a launcher the host just started leaves a live child behind, and tracking that child is precisely the inconsistency being removed.
Untracked already meant the right things downstream, so nothing else had to change:
no watcher thread;
terminate() is a no-op — closing a session must not kill the user's Steam;
no GameExited event, so a client does not bounce back to its library when the launcher closes (which matters now that main has "a game exiting takes you back to its library").
Threaded as LaunchTarget::launcher → LeaseRequest::launcher from the entry's role.
The trap in the threading
Three call sites, and one was a genuine trap: the GameStream path doesn't build its lease from a LaunchTarget at all — it goes through a GsApp intermediate that silently dropped the new field. My first patch put launcher: t.launcher on the wrong type and only surfaced when compiling on Linux, where those arms live. An operator-typed apps.json command has no library entry behind it and is never a launcher tile, so it passes false explicitly.
Gates
Gate
Where
Result
cargo test -p punktfunk-host
.21
439 passed / 0 failed (+1)
cargo check session + GTK clients
.21
clean
cargo clippy -D warnings (4 client crates)
.21
clean
cargo fmt --all --check
.21
clean
The test pins both rows of the table above, plus the same request without the flag still being Matched — so the assertions are the flag's doing and not an artifact of the fixture.
Scope
No live exposure was being fixed: nothing publishes launcher tiles until the plugins are released. This makes the semantics deliberate rather than emergent, ahead of the first on-glass run.
Follow-up to #70 and #75. This is **WP-B1**, the last open correctness question in the launcher-tile work — settled before the first tile is ever clicked.
## The problem
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was still deciding its lifetime from whatever happened to be true at launch — and the two outcomes disagree:
| at launch | lease | result |
|---|---|---|
| launcher **not** already running | the spawned child stays alive → `Child` | quitting the launcher **ends the session** |
| launcher **already** running | the command forwards to the live instance and exits inside `SHIM_WINDOW`, no detect signals behind it → `Untracked` | the session **persists** |
Same tile, two lifetimes, chosen by something the user cannot see.
## Why "untracked" is the right one, not just the safe one
Steam settles it: **Big Picture is a *mode* of an already-running Steam client, not a process** — and on a Deck or SteamOS host Steam is *always* running. So no process-level signal can express "the launcher's window closed"; the tracked branch isn't a better answer that's merely harder to reach, it's unreachable in the case that matters most. Heroic has the same shape for a different reason (single-instance Electron: a second invocation forwards and exits).
## The change
A launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits **ahead of** `nested`/`child`/`spec`, and that ordering *is* the fix rather than an implementation detail: a launcher the host just started leaves a live child behind, and tracking that child is precisely the inconsistency being removed.
`Untracked` already meant the right things downstream, so nothing else had to change:
- no watcher thread;
- `terminate()` is a no-op — closing a session must not kill the user's Steam;
- no `GameExited` event, so a client does **not** bounce back to its library when the launcher closes (which matters now that main has "a game exiting takes you back to its library").
Threaded as `LaunchTarget::launcher` → `LeaseRequest::launcher` from the entry's `role`.
## The trap in the threading
Three call sites, and one was a genuine trap: **the GameStream path doesn't build its lease from a `LaunchTarget` at all** — it goes through a `GsApp` intermediate that silently dropped the new field. My first patch put `launcher: t.launcher` on the wrong type and only surfaced when compiling on Linux, where those arms live. An operator-typed `apps.json` command has no library entry behind it and is never a launcher tile, so it passes `false` explicitly.
## Gates
| Gate | Where | Result |
|---|---|---|
| `cargo test -p punktfunk-host` | `.21` | **439 passed / 0 failed** (+1) |
| `cargo check` session + GTK clients | `.21` | clean |
| `cargo clippy -D warnings` (4 client crates) | `.21` | clean |
| `cargo fmt --all --check` | `.21` | clean |
The test pins **both** rows of the table above, plus the same request *without* the flag still being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.
## Scope
No live exposure was being fixed: nothing publishes launcher tiles until the plugins are released. This makes the semantics deliberate rather than emergent, ahead of the first on-glass run.
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was
still deciding its lifetime from whatever happened to be true at launch — and the two
outcomes disagree:
launcher NOT already running the spawned child stays alive -> `Child` lease
-> quitting the launcher ends the session
launcher ALREADY running the command forwards to the live instance and exits
inside SHIM_WINDOW, with no detect signals behind it
-> `Untracked` -> the session persists
Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that
settles which one is right: Big Picture is a *mode* of an already-running Steam client, not
a process — and on a Deck or SteamOS host Steam is always running — so no process signal can
express "the launcher's window closed". Heroic has the same shape for a different reason
(single-instance Electron: a second invocation forwards and exits).
So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of
`nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail:
a launcher the host just started leaves a live child behind, and tracking that child is
precisely the inconsistency being removed.
`Untracked` already meant the right things downstream, so nothing else had to change: no
watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill
the user's Steam), and no `GameExited` event, so a client does not bounce back to its library
when the launcher closes.
Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`.
Three call sites, one of which was the actual trap: the GameStream path does not build its
lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently
dropped the field. An operator-typed `apps.json` command has no library entry behind it and
is never a launcher tile, so it passes `false` explicitly.
The test pins BOTH cases from the table above, plus the same request without the flag still
being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture.
Gates: punktfunk-host 439 passed / 0 failed on .21 (+1).
Not covered here: nothing publishes launcher tiles until the plugins are released, so there
was no live exposure to fix — this is the semantics being made deliberate before the first
tile is ever clicked.
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.
Follow-up to #70 and #75. This is WP-B1, the last open correctness question in the launcher-tile work — settled before the first tile is ever clicked.
The problem
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was still deciding its lifetime from whatever happened to be true at launch — and the two outcomes disagree:
ChildSHIM_WINDOW, no detect signals behind it →UntrackedSame tile, two lifetimes, chosen by something the user cannot see.
Why "untracked" is the right one, not just the safe one
Steam settles it: Big Picture is a mode of an already-running Steam client, not a process — and on a Deck or SteamOS host Steam is always running. So no process-level signal can express "the launcher's window closed"; the tracked branch isn't a better answer that's merely harder to reach, it's unreachable in the case that matters most. Heroic has the same shape for a different reason (single-instance Electron: a second invocation forwards and exits).
The change
A launcher entry is now
LeaseKind::Untrackedunconditionally. The check sits ahead ofnested/child/spec, and that ordering is the fix rather than an implementation detail: a launcher the host just started leaves a live child behind, and tracking that child is precisely the inconsistency being removed.Untrackedalready meant the right things downstream, so nothing else had to change:terminate()is a no-op — closing a session must not kill the user's Steam;GameExitedevent, so a client does not bounce back to its library when the launcher closes (which matters now that main has "a game exiting takes you back to its library").Threaded as
LaunchTarget::launcher→LeaseRequest::launcherfrom the entry'srole.The trap in the threading
Three call sites, and one was a genuine trap: the GameStream path doesn't build its lease from a
LaunchTargetat all — it goes through aGsAppintermediate that silently dropped the new field. My first patch putlauncher: t.launcheron the wrong type and only surfaced when compiling on Linux, where those arms live. An operator-typedapps.jsoncommand has no library entry behind it and is never a launcher tile, so it passesfalseexplicitly.Gates
cargo test -p punktfunk-host.21cargo checksession + GTK clients.21cargo clippy -D warnings(4 client crates).21cargo fmt --all --check.21The test pins both rows of the table above, plus the same request without the flag still being
Matched— so the assertions are the flag's doing and not an artifact of the fixture.Scope
No live exposure was being fixed: nothing publishes launcher tiles until the plugins are released. This makes the semantics deliberate rather than emergent, ahead of the first on-glass run.
A launcher entry (design D4) has no "the game exited" moment to detect, but the lease was still deciding its lifetime from whatever happened to be true at launch — and the two outcomes disagree: launcher NOT already running the spawned child stays alive -> `Child` lease -> quitting the launcher ends the session launcher ALREADY running the command forwards to the live instance and exits inside SHIM_WINDOW, with no detect signals behind it -> `Untracked` -> the session persists Same tile, two lifetimes, chosen by something the user cannot see. Steam is the case that settles which one is right: Big Picture is a *mode* of an already-running Steam client, not a process — and on a Deck or SteamOS host Steam is always running — so no process signal can express "the launcher's window closed". Heroic has the same shape for a different reason (single-instance Electron: a second invocation forwards and exits). So a launcher entry is now `LeaseKind::Untracked` unconditionally. The check sits AHEAD of `nested`/`child`/`spec`, and that ordering is the fix rather than an implementation detail: a launcher the host just started leaves a live child behind, and tracking that child is precisely the inconsistency being removed. `Untracked` already meant the right things downstream, so nothing else had to change: no watcher thread, `terminate()` is a no-op ("nothing to end" — closing a session must not kill the user's Steam), and no `GameExited` event, so a client does not bounce back to its library when the launcher closes. Threaded as `LaunchTarget::launcher` -> `LeaseRequest::launcher` from the entry's `role`. Three call sites, one of which was the actual trap: the GameStream path does not build its lease from a `LaunchTarget` at all, it goes through a `GsApp` intermediate that silently dropped the field. An operator-typed `apps.json` command has no library entry behind it and is never a launcher tile, so it passes `false` explicitly. The test pins BOTH cases from the table above, plus the same request without the flag still being `Matched` — so the assertions are the flag's doing and not an artifact of the fixture. Gates: punktfunk-host 439 passed / 0 failed on .21 (+1). Not covered here: nothing publishes launcher tiles until the plugins are released, so there was no live exposure to fix — this is the semantics being made deliberate before the first tile is ever clicked.