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>
32 KiB
Game-mode integration & dedicated game sessions — design
Status: IMPLEMENTED (2026-07-05), Linux on-glass validation pending. Parts A (A1–A5) and B (B0–B2) landed;
cargo build/test --workspace/clippy -D warnings/fmtgreen, OpenAPI regenerated. It reconciles the shipped display-management layer (design/display-management.md, Stages 0–5 merged95b3496) with session-mobile hosts — Bazzite/SteamOS boxes that flip between Steam Game Mode (gamescope) and a KDE/GNOME desktop — and adds dedicated game sessions: agame_session=dedicatedpolicy 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}onVirtualOutput; the registry pools onlyOwnedoutputs on the default daemon. gamescope spawn =Owned, attach =External, managed/SteamOS =SessionManaged, KWin/Mutter/Windows =Owned, wlroots =Ownedbut gated out byremote_fd. PlusVirtualDisplay::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-
Ownedpool entry (as §4-A3 sketches) collides with its single-instance nature on a mode-change relaunch (twoManagedSessionHandledrops fighting over the sharedSESSION_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_sessionreadsrestore_delay()= thekeep_alivepolicy (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 instop_session(followups.md #1/#7). Not done:/display/releasefreeing aforever-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_backendon a compositor-instance change (kind change OR same-kind restart).take_expiredalso 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_scopedbyapplication.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 exactlydesign/gamescope-multiuser.mdscope. Noted there.- B0 (dedicated routing) — DONE.
GameSession {Auto, Dedicated}— a top-levelDisplayPolicyfield (NOT inEffectivePolicy, so presets don't clobber it).pick_gamescope_modegains a leadingdedicated_launchthat forcesSpawn(below explicit operator env, above managed-infra/foreign);wants_dedicated_game_session()(launch present ∧ policy dedicated ∧ gamescope available, else honestautofallback) →resolve_compositor(pref, dedicated)forces gamescope; threaded throughapply_input_env(chosen, dedicated_launch). Steam URIs are shapedsteam -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 newAPP_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_sessionaxis is in the API + OpenAPI now.Adversarial review pass (2026-07-05): a multi-agent review confirmed 11 findings, all fixed: (1)
registry::acquireheld the pool mutex across thepw-dumpliveness probe → the probe now runs OUTSIDE the lock (snapshot candidate under lock → probe → re-lock + re-find by gen); (2–5,7,10) the session epoch /invalidate_backendwrongly 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_instancenow acts ONLY on desktop compositor changes, andepoch_matchesexempts gamescope from the epoch (reuse + reap); (3) a dedicated-Steam reconnect didn't cancel the pending TV restore (reuse skipscreate) →cancel_pending_tv_restore()on every connect; (4,8) B2 game-exit used the UNSCOPED node scan → now scoped to the session's ownnode_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 --workspace360).Remaining before this is "shipped": Linux on-glass validation (
.116Bazzite-KWin game-mode reconnect under presets,.21/.116dedicated 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:
- The registry pools externally-owned outputs.
registry::linux::acquirepools every output withremote_fd == None. The gamescope managed, SteamOS-takeover, and attach paths all returnremote_fd: Nonewithkeepalive: 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. - 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 anykeep_alivepolicy 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 deadnode_id; capture fails "no PipeWire frame within 10s"; the capturer drop returns the entry to Lingering;build_pipeline_with_retryre-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. 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, soforeverpins an entry whose session the worker restores away regardless.- 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/statelies; 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. - Discovery is ambient and ambiguous. One shared spawn log (
/tmp/punktfunk-gamescope.log), name-based node discovery (find_gamescope_nodereturns anygamescopeVideo/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. - 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
VirtualOutputdeclares 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 1–3 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.
Entrygainslaunch: 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.
acquiremarks the returnedVirtualOutputas reused (a flag on the lease/gen); whenbuild_pipeline's first-frame fails on a reused display, it callsregistry::mark_failed(gen)before returning the error — the entry is torn down, so the retry loop's nextacquirecreates 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 aManagedSessionHandlewhoseDropperforms today'sdo_restore_tv_sessionduty: 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-rigfinally means on a Bazzite couch box what its story says, released via/display/releaseor the tray. A reconnect inside the linger is a registry reuse (the same warm-session fast pathPENDING_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 therestore_managed_session()call sites go away — the registry linger timer is the one timer.MANAGED_SESSIONsurvives only as the module's mode/unit cache.STOPPED_AUTOLOGIN/STEAMOS_TOOK_OVERstay 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.jsonat 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-failedto 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 justkeep_alive: forever.
A4. Session epoch & backend invalidation
vdisplay::session_epoch()— a host-global counter bumped whenever session detection observes a different compositor instance: anActiveKindchange 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 callsregistry::invalidate_backend(old)so/display/stateis 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_nodegrows a scope parameter instead of "first node named gamescope". - Per-instance EIS relay:
punktfunk-gamescope-ei-<gen>, path carried onVirtualOutput(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)
- 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). - Game↔Desktop switch mid-linger both directions; Desktop→Game→Desktop bounce (epoch test);
/display/statenever lists a display whose compositor is dead. gaming-rigon Bazzite: TV stays off until/display/release; a KDE switch mid-linger is not yanked back to game mode.- Kill -9 the host mid-takeover → restart → TV session restored (persisted-takeover test).
- 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 leadingdedicated_launch: boolinput that forcesSpawn, outranking managed-infra/foreign-attach (explicit operatorMANAGED/ATTACH/NODEenvs still win — they are debug/CI overrides).resolve_compositorcomputes it: launch id present ∧ policydedicated∧ gamescope available → chosen =Gamescope, spawn sub-mode;launch_is_nestedthen 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:- Command shape becomes
steam -silent steam://rungameid/<id>in dedicated mode —-silentsuppresses the Steam main window so the game is the gamescope focus. Empirical validation item (behavior of-silentunder a fresh nested Steam); fallback is the plain URI form (a briefly-visible Steam client, still no BPM navigation). - 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
launchis 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;dedicatedadds no new churn class.
- Command shape becomes
- 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 newAPP_EXITED_CLOSE_CODE, sibling ofQUIT_CLOSE_CODE0x51, so launchers can distinguish "game ended" from an error) withforce_immediaterelease. 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
Reconfigureon 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_alivethen 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_displaysbounds 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.mdscope, not re-solved here. - Admission (
mode_conflict) applies unchanged across clients; a second client asking for a different dedicated game underseparategets 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 (~10–25 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 | B0–B2 | — |
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 -silentinside 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/.116desktop mode.- Bare spawn vs session-plus environment — session-plus wraps Steam in MangoApp/runtime/
controller-config scaffolding a bare
gamescope -- steamlacks. 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.idavailability 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
Stopcan 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/procscan 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_sessionis Linux-only for now (launch_titleon Windows opens via the shell into the one desktop); the policy field documents that honestly rather than pretending.