Files
punktfunk/design/gamemode-and-dedicated-sessions.md
T
enricobuehler 1271fd5ea2 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 10:33:43 +00:00

32 KiB
Raw Blame History

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.jsonrestore_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_launchstop_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:

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):

// 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.