feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.
- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
registry pools only what it owns, so gamescope managed/attach outputs are no
longer double-owned by the registry AND the gamescope restore worker (fixes
the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
(kill_unit, applied to our transient unit AND the autologin stop -- validated
live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.
Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
# 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`/`fmt` green, OpenAPI
|
||||
> regenerated. It reconciles the shipped display-management layer (`design/display-management.md`,
|
||||
> Stages 0–5 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); (2–5,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 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.** `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 (~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 -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.
|
||||
Reference in New Issue
Block a user