feat(host): game-mode integration + dedicated game sessions

Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.

- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
  registry pools only what it owns, so gamescope managed/attach outputs are no
  longer double-owned by the registry AND the gamescope restore worker (fixes
  the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
  liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
  forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
  (kill_unit, applied to our transient unit AND the autologin stop -- validated
  live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
  on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
  pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.

Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:26:11 +00:00
parent d76a42e0e9
commit 04309d0ad9
22 changed files with 1711 additions and 163 deletions
+439
View File
@@ -0,0 +1,439 @@
# Game-mode integration & dedicated game sessions — design
> **Status: IMPLEMENTED (2026-07-05), Linux on-glass validation pending.** Parts A (A1A5) and B
> (B0B2) landed; `cargo build`/`test --workspace`/`clippy -D warnings`/`fmt` green, OpenAPI
> regenerated. It reconciles the shipped display-management layer (`design/display-management.md`,
> Stages 05 merged `95b3496`) with **session-mobile hosts** — Bazzite/SteamOS boxes that flip
> between Steam Game Mode (gamescope) and a KDE/GNOME desktop — and adds **dedicated game sessions**:
> a `game_session=dedicated` policy that serves every library launch from its own host-spawned
> headless gamescope at the client's exact mode, booting straight into the game with no Steam Big
> Picture and no game mode.
>
> ## Implementation status (as-built — read the deviations)
>
> - **A1 (ownership classes) — DONE.** `DisplayOwnership {Owned, External, SessionManaged}` on
> `VirtualOutput`; the registry pools only `Owned` outputs on the default daemon. gamescope
> spawn = `Owned`, attach = `External`, managed/SteamOS = `SessionManaged`, KWin/Mutter/Windows =
> `Owned`, wlroots = `Owned` but gated out by `remote_fd`. Plus `VirtualDisplay::poolable_now()`
> so a managed/attach acquire never reuses a kept bare-spawn. **This alone removes the reported
> Bazzite game-mode-reconnect stale-node wedge** (managed sessions are no longer pooled).
> - **A2 (validated reuse) — DONE.** Reuse keys on `(backend, mode, launch, epoch)`;
> `VirtualDisplay::kept_display_alive()` (gamescope checks its node is still present) tears a dead
> kept display down and recreates; `VirtualOutput::reused_gen` + `registry::mark_failed(gen)` on a
> reused-display first-frame failure so the retry loop creates fresh instead of re-wedging.
> - **A3 (managed restore) — DONE, but DEVIATES from the plan below.** The managed/SteamOS session
> is a **single-instance box singleton** (one Steam per uid), so making it a registry-`Owned` pool
> entry (as §4-A3 sketches) collides with its single-instance nature on a mode-change relaunch
> (two `ManagedSessionHandle` drops fighting over the shared `SESSION_UNIT`/autologin). Since A1
> already fixed the wedge, A3 instead **keeps the managed session's own single-instance lifecycle**
> (`MANAGED_SESSION`/`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` + the restore worker) and makes only
> its **restore policy-driven**: `schedule_restore_tv_session` reads `restore_delay()` = the
> `keep_alive` policy (`off`→0s, `duration`→N s, **`forever`→never** = gaming-rig held, unconfigured
> →5s bit-for-bit). Plus **crash-restore persistence** (`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json`
> → `restore_takeover_on_startup`) and the **SIGKILL-teardown** experiment in `stop_session`
> (followups.md #1/#7). *Not done:* `/display/release` freeing a `forever`-held managed session
> (managed isn't a registry entry) — a forever managed box returns to gaming mode by manual switch
> or host restart (documented; acceptable for a dedicated couch appliance).
> - **A4 (session epoch) — DONE.** `session_epoch()`/`bump_session_epoch()` + `ActiveSession.compositor_pid`;
> `observe_session_instance()` (called from the per-connect resolve, the mid-stream watcher, the
> capture-loss re-detect, and the GameStream acquire) bumps the epoch + `registry::invalidate_backend`
> on a compositor-instance change (kind change OR same-kind restart). `take_expired` also reaps
> kept dead-epoch entries.
> - **A5 (addressed discovery) — PARTIAL.** Per-spawn **log** (`$XDG_RUNTIME_DIR/punktfunk-gamescope-<inst>.log`)
> + **scoped node discovery** (`find_gamescope_node_scoped` by `application.process.id` /
> `descends_from`) — fixes concurrent bare-spawn VIDEO-node ambiguity (the load-bearing correctness
> part). **DEFERRED:** the per-instance **EIS input relay** — the injector-coupling rework the map
> warned about ("EIS setup timed out"); input for concurrent gamescopes stays on the global relay,
> which is exactly `design/gamescope-multiuser.md` scope. Noted there.
> - **B0 (dedicated routing) — DONE.** `GameSession {Auto, Dedicated}` — a **top-level** `DisplayPolicy`
> field (NOT in `EffectivePolicy`, so presets don't clobber it). `pick_gamescope_mode` gains a leading
> `dedicated_launch` that forces `Spawn` (below explicit operator env, above managed-infra/foreign);
> `wants_dedicated_game_session()` (launch present ∧ policy dedicated ∧ gamescope available, else
> honest `auto` fallback) → `resolve_compositor(pref, dedicated)` forces gamescope; threaded through
> `apply_input_env(chosen, dedicated_launch)`. Steam URIs are shaped `steam -silent steam://…`
> (`shape_dedicated_command`). GameStream shares the dispatch.
> - **B1 (Steam single-instance takeover) — FOCUSED.** A dedicated **Steam** spawn frees the box's
> autologin/game-mode Steam first (`is_steam_launch` → `stop_autologin_sessions`, restored on session
> end via the A3 machinery). *Not done:* force-releasing a *kept dedicated Steam* session for a
> *different* Steam game (two concurrent dedicated Steam sessions) — rare; documented degradation.
> - **B2 (game-exit clean end) — DONE.** A dedicated gamescope session whose node vanishes
> (`dedicated_game_exited()` — gamescope is single-app, dies with its game) ends the session with the
> new `APP_EXITED_CLOSE_CODE` (0x52) so launcher clients return to their library, instead of the 40 s
> capture-loss rebuild timeout.
> - **B3 (docs/console) — this block + docs-site + CLAUDE.md; the web console toggle is the remaining
> surface.** The `game_session` axis is in the API + OpenAPI now.
>
> **Adversarial review pass (2026-07-05):** a multi-agent review confirmed 11 findings, all fixed:
> (1) `registry::acquire` held the pool mutex across the `pw-dump` liveness probe → the probe now runs
> OUTSIDE the lock (snapshot candidate under lock → probe → re-lock + re-find by gen); (25,7,10) the
> session epoch / `invalidate_backend` wrongly tore down **independent gamescope spawns** on any Gaming
> pid flap (the CRITICAL "dedicated Steam on a game-mode box self-destructs" — B1 stopping the autologin
> flipped the winning PID) → `observe_session_instance` now acts ONLY on **desktop** compositor changes,
> and `epoch_matches` exempts gamescope from the epoch (reuse + reap); (3) a dedicated-Steam reconnect
> didn't cancel the pending TV restore (reuse skips `create`) → `cancel_pending_tv_restore()` on every
> connect; (4,8) B2 game-exit used the UNSCOPED node scan → now scoped to the session's own `node_id`
> (threaded through the pipeline); (9) an unknown launch id → blank gamescope → dedicated now gated on
> the launch RESOLVING to a command. All green after fixes (`test --workspace` 360).
>
> **Remaining before this is "shipped":** Linux on-glass validation (`.116` Bazzite-KWin game-mode
> reconnect under presets, `.21`/`.116` dedicated launch, gaming-rig-forever held-managed, crash-restore,
> SIGKILL-teardown F44 check), and the two documented deferrals (A5 per-instance EIS relay →
> gamescope-multiuser; B1 concurrent-dedicated-Steam release).
>
> The **PLAN as originally written follows** — it still reads as "PLANNED"; the status block above is
> the authority on what actually shipped and where it deviates.
Companion docs: `design/display-management.md` (the policy/registry layer this reconciles — its
§7 capability matrix and §5.1 gamescope semantics are amended here),
`design/session-aware-host-followups.md` (open session-switch limitations #1/#7 are resolved by
Part A3 below), `design/gamescope-multiuser.md` (per-session input/audio isolation — adjacent;
Part B deliberately stops short of it and cross-references instead).
## 1. The two problems
**(A) The display-management rewrite does not compose with Bazzite game mode / KDE switching.**
The registry (`vdisplay/registry.rs`) assumes it owns every display it pools: it holds the
backend keepalive, decides linger/pin/teardown from the policy, and reuses kept displays by
`(backend, mode)`. On a session-mobile box that assumption is false twice over — the gamescope
managed/attach paths hand it outputs whose lifecycle is owned by *other* machinery
(`MANAGED_SESSION` + the debounced TV-restore worker), and the compositor under **every** Linux
backend can be killed and replaced at any moment by a Game↔Desktop switch. The result is pool
entries that lie: they linger or pin while the session behind them is torn down, then get reused
as dead nodes and wedge the whole retry budget.
**(B) Library launches deserve a first-class "just the game" mode.** Today a library launch on a
Bazzite box rides the managed `gamescope-session-plus` Steam session (Big Picture at the client's
mode, launch forwarded into the running Steam) — the game appears, but inside game mode's whole
UX, with Steam BPM boot time and game-mode ownership of the box. The bare-spawn path that nests
*just* the resolved command in a headless gamescope already exists and works — but the sub-mode
ladder makes it unreachable exactly where users want it most (any box with session-plus/SteamOS
infra picks managed). The ask: a host option so a library launch **always** gets a dedicated
gamescope session at the client's requested mode — game boots directly; non-Steam titles
instantly, Steam titles without any UI to navigate.
These are one design because both reduce to the same question: **who owns a gamescope session's
lifecycle, and how does the registry know?** Part A answers it for what exists; Part B builds the
new launch mode on the answer.
## 2. Failure inventory (code-anchored)
What actually goes wrong today, in dependency order:
1. **The registry pools externally-owned outputs.** `registry::linux::acquire` pools every output
with `remote_fd == None`. The gamescope **managed**, **SteamOS-takeover**, and **attach** paths
all return `remote_fd: None` with `keepalive: Box::new(())` (`gamescope.rs::create_managed_session`
/ `create_managed_session_steamos` / the attach arm) — a unit keepalive that keeps *nothing*
alive. The pool entry claims ownership of a display it cannot hold or tear down.
2. **Two lifecycle owners for the managed session → stale-node reuse wedge.** The real managed
lifecycle is `MANAGED_SESSION` + `PENDING_RESTORE` + `RESTORE_DEBOUNCE` (hardcoded 5 s) + the
restore worker, which stops the session and restarts the TV autologin. With any `keep_alive`
policy configured, a disconnect leaves a registry entry Lingering (e.g. 300 s) while the restore
worker kills the session at 5 s. A reconnect inside the linger window **reuses the dead
`node_id`**; capture fails "no PipeWire frame within 10s"; the capturer drop returns the entry to
Lingering; `build_pipeline_with_retry` re-acquires the **same corpse** on every attempt — all 8
retries wedge (~90 s). This is the `.41`-class "game-mode reconnect broken" symptom, now
*manufacturable on any Bazzite box by clicking a console preset*.
3. **`gaming-rig` (keep_alive=forever) is a lie on gamescope.** Design §5.1 promised "the policy
duration replaces the hardcoded 5 s debounce; forever = the TV session is never auto-restored".
Never implemented: the restore worker doesn't read the policy, so `forever` pins an entry whose
session the worker restores away regardless.
4. **A session switch leaves zombie entries.** The mid-stream watcher rebuild and the per-connect
re-detect drop the old backend's lease → the old entry lingers/pins **for a compositor that no
longer exists** (KWin dies on the switch to game mode; the game-mode gamescope dies on the switch
to desktop). `/display/state` lies; expiry drops keepalives into dead compositors; a
Desktop→Game→Desktop bounce brings up a *new* KWin whose node-id space has no relation to the
kept entry's — reuse hands out a number that now means nothing.
5. **Discovery is ambient and ambiguous.** One shared spawn log (`/tmp/punktfunk-gamescope.log`),
name-based node discovery (`find_gamescope_node` returns *any* `gamescope` `Video/Source`), and
one global EIS relay file (`punktfunk-gamescope-ei`). Correct only while at most one gamescope
exists per uid. A kept spawn + live game mode, two concurrent spawns, or (Part B) a dedicated
session next to game mode can capture the wrong node and inject into the wrong session.
6. **Stock Bazzite restarts the systemd user manager on every Game↔Desktop switch** (observed on
`.41`: user-manager PID churn), killing the host, the compositor, and the pool together. Inherent
— keep-alive **cannot** span a switch on a stock setup (the headless-appliance setup —
`enable-linger` + `multi-user.target` — keeps the host alive and can). Separately, the takeover
bookkeeping (`STOPPED_AUTOLOGIN`, `STEAMOS_TOOK_OVER`, the SteamOS drop-in) is process memory
only: a host crash mid-stream strands the box out of game mode with no restore on restart.
## 3. Design principles
- **One owner per display.** Every `VirtualOutput` declares who owns its lifecycle; the registry
pools only what it owns. This extends the CLAUDE.md invariant ("display lifecycle is owned by
the registry; sessions hold leases") with its honest converse: *what the registry does not own,
it must not pretend to keep.*
- **Keep-alive is an optimization, never a failure mode** (display-management §3, now enforced):
reuse validates liveness; a failed reuse invalidates and creates fresh; the retry budget is
never spent twice on the same corpse.
- **The compositor instance, not the host process, is the unit of session truth.** A session
epoch invalidates every display created under a previous compositor instance — kind changes
*and* same-kind restarts.
- **Addressed, not ambient, discovery.** Every spawned gamescope gets its own log, its own node
resolution, its own EIS relay. Global singletons are what break the moment two sessions coexist.
- **Launch identity is part of display identity.** A kept display running game A must never be
handed to a session asking for game B.
## 4. Part A — ownership & session mobility
### A1. Ownership classes (the structural fix — ship first)
`VirtualOutput` gains an ownership declaration:
```rust
pub enum DisplayOwnership {
/// The registry owns lifecycle: pool, linger, pin, tear down. (KWin, Mutter, gamescope
/// SPAWN, Windows manager-delegated.)
Owned,
/// Someone else's display, mirrored: no keep-alive, no topology, no reuse — codifies the
/// display-management §7 "attach = unmanaged pass-through" row the code never implemented.
/// (gamescope ATTACH; wlroots stays gated on remote_fd as today.)
External,
/// A box-level session the gamescope module manages (managed session-plus / SteamOS
/// takeover). Pass-through at A1 (capturer owns the lease as pre-Stage-1); A3 converts it
/// to Owned by giving the registry the real keepalive + restore duty.
SessionManaged,
}
```
The registry pools `Owned` only; `External`/`SessionManaged` pass through unchanged (today's
pre-registry behavior — teardown-on-capturer-drop is a no-op for their unit keepalives, and the
existing gamescope-module machinery keeps doing what it does). `remote_fd == Some` keeps its gate.
Effect: failures 13 stop being reachable from the console — a preset can no longer create lying
entries for game-mode sessions. Smallest possible diff; everything else layers on it. Until A3
lands, keep-alive on gamescope managed/attach honestly reports **unsupported** in
`/display/state` capabilities instead of pretending.
### A2. Validated reuse, invalidation, launch key
- **Launch key.** `Entry` gains `launch: Option<String>` (the resolved launch command the display
was created with; `None` = plain desktop). Reuse requires `(backend, mode, launch)` equality.
Without this, a kept spawn running game A is reused for a session that asked to launch game B —
latent today (unconfigured Linux lingers Immediate), live the moment keep-alive + launches
combine, load-bearing for Part B.
- **Liveness probe at reuse.** New trait method, default honest:
`fn kept_display_alive(&mut self, node_id: u32) -> bool { true }` — gamescope-spawn checks the
child (`try_wait`) *and* the node; KWin/Mutter check the node still exists on the default
daemon (one cheap PipeWire registry roundtrip — no capture attach). A dead entry is torn down
(its group restore handed off / run) and the acquire falls through to a fresh create.
- **Invalidation on reuse failure.** `acquire` marks the returned `VirtualOutput` as reused (a
flag on the lease/gen); when `build_pipeline`'s first-frame fails on a **reused** display, it
calls `registry::mark_failed(gen)` before returning the error — the entry is torn down, so the
retry loop's next `acquire` **creates fresh** instead of re-wedging on the same corpse. This is
the direct fix for failure 2's 8×10 s wedge shape, and it holds for every future way a kept
display can silently die.
### A3. One owner for the managed session (registry-owned restore)
The managed/SteamOS paths become `Owned` by giving the registry the two things it lacks:
- **A real keepalive.** `create_managed_session*` returns a `ManagedSessionHandle` whose `Drop`
performs today's `do_restore_tv_session` duty: stop the transient unit / remove the SteamOS
drop-in, then restart the autologin **iff no desktop session is active** (the existing guard
moves in verbatim — a user who switched to KDE mid-linger is never yanked back to game mode).
- **The policy as the debounce.** The registry linger *is* the restore delay: unconfigured
default = 5 s (bit-for-bit today's `RESTORE_DEBOUNCE`), a configured duration replaces it,
`forever` = never auto-restore — `gaming-rig` finally means on a Bazzite couch box what its
story says, released via `/display/release` or the tray. A reconnect inside the linger is a
registry **reuse** (the same warm-session fast path `PENDING_RESTORE`-cancel gives today, now
with A2 validation); a different requested mode tears down + relaunches through the registry
(gamescope's honest "reconfigure = recreate").
- **Retire the parallel machinery.** `PENDING_RESTORE`, `RESTORE_DEBOUNCE`,
`start_restore_worker`, and the `restore_managed_session()` call sites go away — the registry
linger timer is the one timer. `MANAGED_SESSION` survives only as the module's mode/unit cache.
`STOPPED_AUTOLOGIN`/`STEAMOS_TOOK_OVER` stay as the *mechanics* the handle's Drop consumes.
- **Persist the takeover.** The stopped-unit list + SteamOS-drop-in marker are written to
`$XDG_RUNTIME_DIR/punktfunk-session-takeover.json` at takeover, cleared at restore. On host
startup, a leftover file (crash / service restart mid-stream) schedules a restore after a short
reconnect grace — with the same active-desktop guard. A crashed host no longer strands the TV.
- **Teardown signal.** Adopt the parked follow-ups doc #1 experiment here: the handle's stop uses
`systemctl --user kill --signal=SIGKILL <unit>` (+ `stop`/`reset-failed` to clear unit state)
instead of plain SIGTERM stop, testing the hypothesis that skipping gamescope's crashy SIGTERM
teardown avoids the F44 GPU-context leak. A3's validation pass is the natural place to measure
it; fall back to SIGTERM if SIGKILL misbehaves. Follow-ups #7 (restore-guard/keep-warm
interaction) dissolves: "keep warm" is now just `keep_alive: forever`.
### A4. Session epoch & backend invalidation
- **`vdisplay::session_epoch()`** — a host-global counter bumped whenever session detection
observes a different compositor **instance**: an `ActiveKind` change *or* a new compositor PID
for the same kind (the Desktop→Game→Desktop bounce). Entries stamp their creation epoch; reuse
requires an epoch match; the linger timer reaps entries from dead epochs (their keepalive Drops
hit dead sockets and fail fast; the registry already drops outside the lock).
- **Watcher hook.** A `SessionSwitch` (and a per-connect re-detect that finds the previous
backend's compositor gone) additionally calls `registry::invalidate_backend(old)` so
`/display/state` is honest immediately rather than at the next expiry poll.
- **Stock-Bazzite user-manager restarts** stay inherent: the host dies with the switch, the pool
dies with the host, and that is documented ("keep-alive spans a Game↔Desktop switch only on the
headless-appliance setup"). The persisted takeover file (A3) is what survives.
### A5. Addressed discovery (Part-B prerequisite)
- **Per-spawn log**: `$XDG_RUNTIME_DIR/punktfunk-gamescope-<gen>.log`; the spawned instance's
node id is parsed from *its* log only.
- **Scoped node discovery**: PipeWire node props carry `application.process.id` — a spawn's node
must belong to our child's PID tree (`descends_from`, already written); managed/attach
discovery conversely **excludes** nodes owned by our spawned children. `find_gamescope_node`
grows a scope parameter instead of "first node named gamescope".
- **Per-instance EIS relay**: `punktfunk-gamescope-ei-<gen>`, path carried on `VirtualOutput`
(the gamescope-multiuser doc's item 1), with the injector service resolving the session's own
relay for gamescope sessions (a narrow slice of its item 2 — the shared injector stays for the
portal backends, where shared input is correct and where per-session churn caused the historic
"EIS setup timed out" wedge).
### Part A validation (on-glass, `.116` Bazzite KWin/AMD + Deck `.253`; `.21` for spawn-on-GNOME)
1. Game-mode reconnect: connect → disconnect → reconnect inside game mode, under *every* preset
incl. `gaming-rig` (the failure-2/3 repro — must reuse the warm session or cleanly recreate,
never wedge the retry budget).
2. Game↔Desktop switch mid-linger both directions; Desktop→Game→Desktop bounce (epoch test);
`/display/state` never lists a display whose compositor is dead.
3. `gaming-rig` on Bazzite: TV stays off until `/display/release`; a KDE switch mid-linger is not
yanked back to game mode.
4. Kill -9 the host mid-takeover → restart → TV session restored (persisted-takeover test).
5. Two gamescopes coexisting (kept spawn + game mode): capture and input land in the right one.
## 5. Part B — dedicated game sessions for library launches
### 5.1 What it is
A session that carries a launch id (native `Hello.launch`, the GTK `--browse`/`--launch` flows,
Decky pins, GameStream apps with a library id) can be served by a **dedicated gamescope
session**: a host-spawned headless gamescope at exactly the client's WxH@Hz whose nested command
is the resolved game. No Steam Big Picture, no game mode, no desktop involvement. Session end
returns the client to its launcher (already shipped behavior); the game survives disconnects per
`keep_alive` — the Apollo-style detach/reattach the design always wanted, now per-game.
### 5.2 Policy surface
One new axis in `display-settings.json` (same store/PUT/console pattern; serde-defaulted so
existing files are untouched):
```json5
// How a session that LAUNCHES a game is served:
// "auto" today's routing: the launch rides whatever session the box is in (managed
// Steam session on Bazzite/SteamOS, bare spawn on plain distros, spawned into
// the live desktop on KWin/Mutter/wlroots)
// "dedicated" a launching session always gets its own headless gamescope at the client's
// mode, nesting just the game
"game_session": "auto"
```
Sessions **without** a launch id are untouched by this axis — desktop streaming routes exactly as
today. `dedicated` degrades honestly: no gamescope binary → log + fall back to `auto`. Console: a
toggle on the Virtual displays card (it is a display-lifecycle decision) with one story line.
Deliberate non-options for v1: per-entry overrides (schema keys allow a later
`"per_entry": {"<id>": …}` overlay) and a client-requested Hello byte ("launch dedicated") — host
policy first, protocol growth when a client actually wants to differ per connect.
### 5.3 Routing & command shaping
- **Sub-mode ladder.** `pick_gamescope_mode` (pure, unit-tested) gains a leading
`dedicated_launch: bool` input that forces `Spawn`, outranking managed-infra/foreign-attach
(explicit operator `MANAGED`/`ATTACH`/`NODE` envs still win — they are debug/CI overrides).
`resolve_compositor` computes it: launch id present ∧ policy `dedicated` ∧ gamescope available
→ chosen = `Gamescope`, spawn sub-mode; `launch_is_nested` then routes the command into the
spawn as today.
- **Non-Steam entries** (custom / lutris / heroic): the resolved command nests directly — truly
instant (gamescope up in ~1 s, then the game's own boot).
- **Steam entries** (`steam steam://rungameid/<id>`): Steam is single-instance per uid, so:
1. Command shape becomes `steam -silent steam://rungameid/<id>` in dedicated mode — `-silent`
suppresses the Steam main window so the game is the gamescope focus. **Empirical validation
item** (behavior of `-silent` under a fresh nested Steam); fallback is the plain URI form
(a briefly-visible Steam client, still no BPM navigation).
2. If another same-uid Steam is live (game mode autologin, a kept managed session, a kept
dedicated Steam session): **take Steam over first** — force-release kept entries whose
`launch` is a Steam title, stop the autologin via the A3-owned takeover (persisted state,
policy-driven restore). This reuses the exact machinery game mode streaming already needs;
`dedicated` adds no new churn class.
- **Game exit ends the session.** When the nested command exits (user quits the game), the
gamescope child dies; today's capture-loss path would rebuild an empty session. Dedicated
sessions instead end cleanly: the rebuild path consults the keepalive child (`try_wait`) and a
confirmed child exit becomes a typed session end (host closes QUIC with a new
`APP_EXITED_CLOSE_CODE`, sibling of `QUIT_CLOSE_CODE` 0x51, so launchers can distinguish "game
ended" from an error) with `force_immediate` release. Clients need no change to *work* (they
already return to the launcher on session end); the typed code is polish they can adopt.
- **Mid-stream `Reconfigure`** on a dedicated session = teardown + respawn (gamescope cannot
live-resize its output; the game restarts) — the same honest §7 caveat as managed, documented.
### 5.4 Lifecycle composition (where Part A pays off)
- Dedicated outputs are `Owned` (A1) with the child as a real keepalive → they pool naturally.
- Reuse keys on `(backend, mode, launch)` (A2): reconnect to the same game re-attaches to the
still-running session instantly; a different game never falsely reuses. `keep_alive` then reads
exactly as the presets promise: `off` = game dies with the disconnect; a duration = the detach
window; `forever` = the game runs until released (`gaming-rig`, per-game).
- Each dedicated session is already its own registry **group** (`group_key` — no topology or
restore interaction with the desktop); `max_displays` bounds how many can be kept; Steam
titles are additionally bounded to one by the single-instance takeover above.
- Input rides the per-instance EIS relay (A5); uinput gamepads are per-session already. Audio and
mic stay the host-wide shared services — one *active* dedicated session is the designed case;
concurrent independent-audio sessions are exactly `design/gamescope-multiuser.md` scope, not
re-solved here.
- Admission (`mode_conflict`) applies unchanged across clients; a second client asking for a
different dedicated game under `separate` gets its own spawn (non-Steam) or the single-instance
takeover rules (Steam) — the honest per-backend gating pattern.
### 5.5 GameStream
Same dispatch (the launch path was unified in the 2026-07-01 rebuild): an apps.json/library-id
launch under `dedicated` spawns the same way; serverinfo/RTSP negotiate the client's mode as
today. Moonlight's quit-app (`h_cancel`) maps to a `force_immediate` release — killing the game
on explicit quit — which folds into the already-deferred "GameStream quit-code" follow-up from
display-management §5.1.
### 5.6 Client experience & latency honesty
No client changes are required: GTK `--browse`/`--launch`, Decky pins, and the Apple/Android
library grids just launch, and session end already returns to the launcher. Boot-time
expectations, stated plainly in docs: non-Steam titles are gamescope-spawn (~1 s) + game boot;
Steam titles pay the Steam client's own cold boot inside the fresh session (~1025 s class)
before the game process starts — "no UI to navigate", not "zero seconds". A pre-warmed parked
Steam (`steam -silent` held inside a background headless gamescope at host start) would close
that gap but fights game mode over the single instance — explicitly out of scope for v1, noted
as the one candidate v2 if Steam cold boot proves to be the complaint. **Stretch** (needs a small
mgmt surface, not v1): launchers showing "Resume" for a game the host reports as kept
(`/display/state` already exposes the entries; adding `launch` to `DisplayInfo` is the only
schema growth).
## 6. Staging & dependencies
| Stage | Contents | Depends on | Validation |
|---|---|---|---|
| **A1** | `DisplayOwnership`, pool `Owned` only, honest capabilities | — | unit + `.116` game-mode reconnect under presets no longer wedges |
| **A2** | launch key, `kept_display_alive`, `mark_failed` on reused-display capture failure | A1 | probe-driven: kill a kept display's session → reconnect creates fresh on attempt 2 |
| **A3** | `ManagedSessionHandle`, policy-as-debounce, retire restore worker, persisted takeover, SIGKILL-teardown experiment | A1 | `.116`/Deck: gaming-rig semantics, crash-restore, desktop-guard |
| **A4** | session epoch, `invalidate_backend` on switch | A1 | `.116`: switch matrix incl. same-kind bounce; `/display/state` honesty |
| **A5** | per-spawn log, scoped node discovery, per-instance EIS relay | — (parallel) | two-gamescope coexistence test |
| **B0** | `game_session` policy + ladder input + steam `-silent` shaping | A1 (correctness), A5 (if game mode may be live) | `.21`/plain box: non-Steam + Steam dedicated launch on glass |
| **B1** | Steam takeover integration (autologin stop / kept-Steam release) | A3 | `.116`/Deck: dedicated launch from game mode, TV restore per policy |
| **B2** | reuse-by-launch reattach + game-exit-ends-session (`APP_EXITED` close) | A2 | disconnect → game keeps running → reattach; quit game → launcher |
| **B3** | docs (virtual-displays + steamos-host pages), console toggle polish, "Resume" stretch | B0B2 | — |
Every stage lands green (`cargo test/clippy/fmt`, OpenAPI drift) and independently shippable,
per the display-management discipline. A1+A2 alone fix the user-visible breakage; A3 makes the
presets truthful; B0 is the first user-visible new capability.
## 7. Risks & open questions
- **`steam -silent` inside a fresh nested gamescope** — the load-bearing empirical unknown for
B0's Steam polish (the launch itself works either way; only the cosmetic Steam-window flash is
at stake). Validate first on `.21`/`.116` desktop mode.
- **Bare spawn vs session-plus environment** — session-plus wraps Steam in MangoApp/runtime/
controller-config scaffolding a bare `gamescope -- steam` lacks. The historic "nested Steam
crashes" finding was Steam-vs-Steam single-instance (both dying), *not* a missing-scaffolding
failure — with Steam taken over first, a bare spawn should hold, but this is exactly what B1's
on-glass pass must prove per box (Bazzite, Deck).
- **PipeWire `application.process.id` availability** across gamescope versions (A5's scoped
discovery) — fall back to log-derived ids (per-spawn logs make those unambiguous already).
- **Keepalive drops into dead compositors** (A4 reaping): Wayland conns fail fast; Mutter's D-Bus
`Stop` can block — the registry already drops outside the lock, keep it that way and bound the
damage to one reaper tick.
- **Epoch granularity**: detecting "new compositor instance, same kind" needs the compositor PID
in `ActiveSession` — cheap (the `/proc` scan already visits it), but the watcher must debounce
PID flaps during a switch (its existing 3 s debounce covers it).
- **Injector rework caution** (A5): per-session EIS injectors only for gamescope; the shared
service stays for portal backends — re-learning the "EIS setup timed out" lesson is the failure
mode to guard in review.
- **Windows**: entirely untouched — `game_session` is Linux-only for now (`launch_title` on
Windows opens via the shell into the one desktop); the policy field documents that honestly
rather than pretending.