The library round-trip — a quit game the host never noticed, and the loop back to the shelf #276

Merged
enricobuehler merged 1 commits from worktree-library-round-trip into main 2026-08-16 23:27:30 +00:00
Owner

From a field report (Discord, 2026-08-16 — Windows host 0.29.0 + iPad 0.30.0): quitting a game mid-stream left the session up and the web console showing it as running forever, with no setting that made any difference.

Ten work items came out of that conversation. All of them are here except cross-client parity (C7), deferred.

The bug was not a stale console

A lease with nothing to recognise its game by set its own state to Running, on the reasoning that the host had just launched it. That collapsed three different situations into one indistinguishable row — a game being watched, a game that quit and was never noticed, and a game the host cannot see at all — and session_on_game_exit can never fire for a lease nothing is watching, which is why no setting helped.

Windows reached that state far more easily than Linux, for a reason worth naming: the host holds no Child there, and the pid CreateProcessAsUserW hands back was logged and discarded. A title whose provider published no detect hint therefore had nothing identifying it, while the same title on Linux was fully tracked through its child.

Carrying that pid closes two further defects that were never separately reported:

  • with an empty spec the Windows terminate ladder found no pids, so "End" silently did nothing;
  • a launch that adopted nothing answers Liveness::Unknown, falls back to the 90-second in-flight window, and past it starts a second copy — which is why "click the game that is already running" resumed on Linux and relaunched on Windows.

Host

  • GameState::Untracked — a lease nothing is watching says so, in /status, the console card and the tray label. Keyed on is anything watching this rather than on the lease kind, so a nested gamescope lease (whose exit the capture loop catches) still correctly reads running.
  • LeaseRequest::spawned carries the pid Windows already knew, pinned to its start time by a new Scanner::resolve so a recycled pid cannot impersonate it. It takes the same lifetime rules the owned child gets — shim reclassification included — and feeds the Windows terminate ladder.
  • game_on_new_launch (keep | end, default keep) — close this client's previous game before starting a different one. Its own axis rather than a fourth game_on_session_end value: wanting a game to survive a disconnect says nothing about wanting it kept when you deliberately pick another. Four safety rules, made pure and unit-tested — never another client's game, never one the player started themselves, never the title being launched, and never a record whose liveness is merely Unknown.

Apple client — the loop the report was really about

Browse → play → quit → browse:

  • tapping a paired host opens its library; "Stream the Desktop" moves to the card menu
  • the catalog is cached per host and rendered immediately, marked stale, so a sleeping host still shows its titles (only art was cached before; the catalog was fetched live every visit)
  • opening the library wakes the host and retries across the boot window, so it is warm by the time a title is picked — waking was bound to connecting, which is too late to help
  • titles already up are badged Resume and sorted first, read from /status, which was already on the paired-cert lane — no new host API
  • the grid returns to where you were, remembered as the last title opened rather than a pixel offset, so it survives a rotation, a resize and a host gaining titles

Verification

Lane Result
punktfunk-host (Linux container) fmt, clippy --all-targets -D warnings, plain build, 597 tests green
OpenAPI regenerated; drift test re-run green. Diff is purely additive
Web console tsc --noEmit clean, 740 messages en + de
Apple swift build + 347 tests on macOS, iOS and tvOS typechecked (CI compiles neither)

Two calls worth a second opinion

  • game_on_new_launch ships default-off. The lease's documented posture is that nothing is ended unless the operator asked, and ending a game costs unsaved progress — so I did not reverse that on my own. One line if you want end as the default.
  • No client confirmation prompt. The client cannot read /session/settings (admin lane only), so it cannot know whether to warn; an honest prompt needs a launch-intent flag on the wire, and that ABI change collides with the in-flight ex10/ABI-21 work.

Not in scope

C7, cross-client parity — Android, the shared console UI and webOS need the same treatment once the Apple interaction settles. The shared console UI has no running-game concept in its model at all, so C2/C3 are new surface there rather than a port.

⚠ Unrelated, but it will bite anyone building the Apple client: the PunktfunkCore.xcframework checked into working trees is stale (its header stops at punktfunk_connect_ex9; the sources need ex10/ex11), so swift build fails on any branch until it is rebuilt with CMAKE_POLICY_VERSION_MINIMUM=3.5 IPHONEOS_DEPLOYMENT_TARGET=17.0 BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh.

From a field report (Discord, 2026-08-16 — Windows host 0.29.0 + iPad 0.30.0): quitting a game mid-stream left the session up and the web console showing it as **running** forever, with no setting that made any difference. Ten work items came out of that conversation. All of them are here except cross-client parity (C7), deferred. ## The bug was not a stale console A lease with nothing to recognise its game by set its **own** state to `Running`, on the reasoning that the host had just launched it. That collapsed three different situations into one indistinguishable row — a game being watched, a game that quit and was never noticed, and a game the host cannot see at all — and `session_on_game_exit` can never fire for a lease nothing is watching, which is why no setting helped. Windows reached that state far more easily than Linux, for a reason worth naming: the host holds no `Child` there, and the pid `CreateProcessAsUserW` hands back was **logged and discarded**. A title whose provider published no detect hint therefore had *nothing* identifying it, while the same title on Linux was fully tracked through its child. Carrying that pid closes two further defects that were never separately reported: - with an empty spec the Windows terminate ladder found no pids, so **"End" silently did nothing**; - a launch that adopted nothing answers `Liveness::Unknown`, falls back to the 90-second in-flight window, and past it starts a **second copy** — which is why "click the game that is already running" resumed on Linux and relaunched on Windows. ## Host - **`GameState::Untracked`** — a lease nothing is watching says so, in `/status`, the console card and the tray label. Keyed on *is anything watching this* rather than on the lease kind, so a nested gamescope lease (whose exit the capture loop catches) still correctly reads `running`. - **`LeaseRequest::spawned`** carries the pid Windows already knew, pinned to its start time by a new `Scanner::resolve` so a recycled pid cannot impersonate it. It takes the same lifetime rules the owned child gets — shim reclassification included — and feeds the Windows terminate ladder. - **`game_on_new_launch` (`keep` | `end`, default `keep`)** — close this client's previous game before starting a different one. Its own axis rather than a fourth `game_on_session_end` value: wanting a game to survive a disconnect says nothing about wanting it kept when you deliberately pick another. Four safety rules, made pure and unit-tested — never another client's game, never one the player started themselves, never the title being launched, and never a record whose liveness is merely `Unknown`. ## Apple client — the loop the report was really about Browse → play → quit → browse: - tapping a paired host **opens its library**; "Stream the Desktop" moves to the card menu - the **catalog is cached** per host and rendered immediately, marked stale, so a sleeping host still shows its titles (only *art* was cached before; the catalog was fetched live every visit) - opening the library **wakes the host** and retries across the boot window, so it is warm by the time a title is picked — waking was bound to *connecting*, which is too late to help - titles already up are badged **Resume** and sorted first, read from `/status`, which was already on the paired-cert lane — **no new host API** - the grid **returns to where you were**, remembered as the last title opened rather than a pixel offset, so it survives a rotation, a resize and a host gaining titles ## Verification | Lane | Result | |---|---| | `punktfunk-host` (Linux container) | fmt, clippy `--all-targets -D warnings`, plain build, **597 tests** green | | OpenAPI | regenerated; drift test re-run green. Diff is purely additive | | Web console | `tsc --noEmit` clean, 740 messages en + de | | Apple | `swift build` + **347 tests** on macOS, **iOS and tvOS typechecked** (CI compiles neither) | ## Two calls worth a second opinion - **`game_on_new_launch` ships default-off.** The lease's documented posture is that nothing is ended unless the operator asked, and ending a game costs unsaved progress — so I did not reverse that on my own. One line if you want `end` as the default. - **No client confirmation prompt.** The client cannot read `/session/settings` (admin lane only), so it cannot know whether to warn; an honest prompt needs a launch-intent flag on the wire, and that ABI change collides with the in-flight ex10/ABI-21 work. ## Not in scope C7, cross-client parity — Android, the shared console UI and webOS need the same treatment once the Apple interaction settles. The shared console UI has no running-game concept in its model at all, so C2/C3 are new surface there rather than a port. ⚠ Unrelated, but it will bite anyone building the Apple client: the `PunktfunkCore.xcframework` checked into working trees is **stale** (its header stops at `punktfunk_connect_ex9`; the sources need `ex10`/`ex11`), so `swift build` fails on any branch until it is rebuilt with `CMAKE_POLICY_VERSION_MINIMUM=3.5 IPHONEOS_DEPLOYMENT_TARGET=17.0 BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh`.
enricobuehler added 1 commit 2026-08-16 23:26:17 +00:00
feat(host,clients/apple,console): the library round-trip — a quit game the host never noticed, and the loop back to the shelf
ci / bun-nix (pull_request) Successful in 1m28s
apple / swift (pull_request) Failing after 1m33s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m37s
ci / web (pull_request) Successful in 4m43s
ci / rust (pull_request) Successful in 6m0s
ci / rust-arm64 (pull_request) Successful in 6m19s
android / android (pull_request) Successful in 6m40s
22fdea66ff
From a field report (Discord, 2026-08-16, Windows host 0.29.0 + iPad 0.30.0): quitting a game
mid-stream left the session up and the web console showing it as "running" forever, with no
setting that made any difference.

The console was not merely out of date — it was asserting something the host had no way to know.
A lease with nothing to recognise its game by set its OWN state to `Running`, on the reasoning
that the host had just launched it. That made three different situations indistinguishable: a
game being watched, a game that quit and was never noticed, and a game the host cannot see at
all. `session_on_game_exit` can never fire for a lease nothing is watching, which is why no
setting helped.

Windows reached that state far more easily than Linux, and for a reason worth naming: the host
never holds a `Child` there, and the pid `CreateProcessAsUserW` hands back was logged and
discarded. So a title whose provider published no detect hint had *nothing* identifying it — its
exit went unseen and `POST /game/end` had no pid to signal, while the same title on Linux was
fully tracked through its child.

Host:
* `GameState::Untracked` — a lease that nothing is watching says so, in `/status`, the console
  card and the tray label. Keyed on "is anything watching this" rather than on the lease kind, so
  a nested gamescope lease (whose exit the capture loop catches) still correctly reads `running`.
* `LeaseRequest::spawned` carries the pid Windows already knew, pinned to its start time by a new
  `Scanner::resolve` so a recycled pid cannot impersonate it. It takes the same lifetime rules the
  owned child gets, shim reclassification included, and feeds the Windows terminate ladder.
* `game_on_new_launch` (keep|end, default keep): close this client's previous game before starting
  a different one. Its own axis rather than a fourth `game_on_session_end` value — wanting a game
  to survive a disconnect says nothing about wanting it kept when you deliberately pick another.
  Four safety rules, made pure and unit-tested: never another client's game, never one the player
  started themselves, never the title being launched, and never a record whose liveness is merely
  Unknown.

The same pid fix closes a second defect: a launch that adopted nothing answers `Unknown`, falls
back to the 90-second in-flight window, and past it starts a SECOND copy. That is why "click the
game that is already running" resumed on Linux and relaunched on Windows.

Apple client — the loop the report was really about (browse, play, quit, browse):
* tapping a paired host opens its library; "Stream the Desktop" moves to the card menu
* the catalog is cached per host and rendered immediately, marked stale, so a sleeping host still
  shows its titles (only art was cached before; the catalog was fetched live every visit)
* opening the library wakes the host and retries across the boot window, so it is warm by the time
  a title is picked — waking was bound to CONNECTING, which is too late to help
* titles already up are badged Resume and sorted first, read from `/status` (already on the
  paired-cert lane — no new host API)
* the grid returns to where you were, remembered as the last title opened rather than a pixel
  offset, which survives a rotation, a resize and a host gaining titles

Verified: Linux container gate over punktfunk-host — fmt, clippy --all-targets -D warnings, a
plain build and 597 tests green, openapi regenerated and its drift test passing. Web console tsc
clean, 740 messages en+de. Apple swift build + 347 tests on macOS, and iOS + tvOS typechecked
(CI compiles neither).
enricobuehler merged commit d0c0e8cc47 into main 2026-08-16 23:27:30 +00:00
enricobuehler deleted branch worktree-library-round-trip 2026-08-16 23:27:37 +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#276