A launcher tile's session stops depending on invisible state #76

Merged
enricobuehler merged 1 commits from worktree-launcher-lease into main 2026-08-06 17:25:45 +00:00
Owner

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::launcherLeaseRequest::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.
enricobuehler added 1 commit 2026-08-06 16:35:44 +00:00
feat(host/gamelease): a launcher tile's session stops depending on invisible state
ci / web (pull_request) Failing after 2s
ci / rust-arm64 (pull_request) Failing after 3s
ci / bun-nix (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 4m56s
ci / rust (pull_request) Successful in 9m1s
e9da37aaf6
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.
enricobuehler merged commit d167e90ea1 into main 2026-08-06 17:25:45 +00:00
enricobuehler deleted branch worktree-launcher-lease 2026-08-06 17:25:46 +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#76