Files
punktfunk/design/gamemode-and-dedicated-sessions.md
T
enricobuehler 04309d0ad9 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>
2026-07-06 06:26:22 +00:00

440 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.