diff --git a/api/openapi.json b/api/openapi.json index 0abb080..ea96b07 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.7.4" + "version": "0.8.0" }, "paths": { "/api/v1/clients": { @@ -2240,6 +2240,10 @@ "type": "object", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "properties": { + "game_session": { + "$ref": "#/components/schemas/GameSession", + "description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched." + }, "identity": { "$ref": "#/components/schemas/Identity" }, @@ -2399,6 +2403,14 @@ } } }, + "GameSession": { + "type": "string", + "description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).", + "enum": [ + "auto", + "dedicated" + ] + }, "GpuState": { "type": "object", "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.", diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 998e9a4..47be77f 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -1090,7 +1090,7 @@ async fn session(args: Args) -> Result<()> { break; } if started.elapsed() > std::time::Duration::from_secs(cap_secs) - || last_rx.elapsed() > std::time::Duration::from_secs(8) + || last_rx.elapsed() > std::time::Duration::from_secs(45) { break; } diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index ff89707..95523d2 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -129,6 +129,14 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08; /// reconnect can resume. Shared so host + every client agree on the code. pub const QUIT_CLOSE_CODE: u32 = 0x51; +/// QUIC application error code the **host** closes the control connection with when a **dedicated game +/// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher +/// client can distinguish "the game ended" from an error and return to its library cleanly rather than +/// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of +/// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client +/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. +pub const APP_EXITED_CLOSE_CODE: u32 = 0x52; + /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// advertise this. diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index aa51259..0b25880 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -246,14 +246,33 @@ fn open_gs_virtual_source( } #[cfg(not(target_os = "windows"))] { + // A client is (re)connecting → cancel any pending TV-session restore (review #3). + crate::vdisplay::cancel_pending_tv_restore(); let active = crate::vdisplay::detect_active_session(); + // A4: fold any compositor-instance change (idle-time Game↔Desktop switch) into the epoch + // before acquiring, so a GameStream reconnect never reuses a dead-instance node. + crate::vdisplay::observe_session_instance(&active); crate::vdisplay::apply_session_env(&active); - let c = crate::vdisplay::compositor_for_kind(active.kind) - .map(Ok) - .unwrap_or_else(crate::vdisplay::detect) - .context("detect compositor")?; - crate::vdisplay::apply_input_env(c); - c + // Dedicated game session (B0): a GameStream app whose launch RESOLVES to a command (library + // id / apps.json command), under `game_session=dedicated` with gamescope available, gets its + // own headless gamescope spawn at the client mode — same routing as the native plane. Gate on + // the resolved command so an unresolvable entry falls back to auto routing (review #9). + let has_launch = crate::library::resolve_session_launch( + app.and_then(|a| a.library_id.as_deref()), + app.and_then(|a| a.cmd.as_deref()), + ) + .is_some(); + if crate::vdisplay::wants_dedicated_game_session(has_launch) { + crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true); + crate::vdisplay::Compositor::Gamescope + } else { + let c = crate::vdisplay::compositor_for_kind(active.kind) + .map(Ok) + .unwrap_or_else(crate::vdisplay::detect) + .context("detect compositor")?; + crate::vdisplay::apply_input_env(c, false); + c + } } }; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 59cca65..81bc239 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -1043,6 +1043,7 @@ fn display_settings_state() -> DisplaySettingsState { "mode_conflict".into(), "identity".into(), "layout".into(), + "game_session".into(), ], } } @@ -1248,7 +1249,10 @@ async fn set_display_layout(ApiJson(req): ApiJson) -> Resp // Lock the current effective behavior into explicit fields + set the manual arrangement (pure // transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy // axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.) - let policy = store.get().effective().with_manual_layout(req.positions); + let policy = store + .get() + .effective() + .with_manual_layout(req.positions, store.game_session()); if let Err(e) = store.set(policy) { return api_error( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 1c5cdaa..393d3e6 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -285,6 +285,9 @@ pub(crate) async fn serve( // restores the box's autologin gaming session on idle, not per-disconnect — see // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. let _restore_worker = crate::vdisplay::start_restore_worker(); + // A3: recover a TV takeover stranded by a crashed previous host instance (persisted to + // $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start. + crate::vdisplay::restore_takeover_on_startup(); // Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com / // displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never // blocks on the network. A no-op on a host whose stores all carry their own art. @@ -826,8 +829,23 @@ async fn serve_session( let compositor = match source { Punktfunk1Source::Virtual => { let pref = hello.compositor; + // Dedicated game session (B0): a launching client under `game_session=dedicated` + // (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on + // whether the launch id actually RESOLVES to a command in the host's library — an unknown + // id must fall back to normal auto routing, not a blank "sleep infinity" gamescope + // (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.) + #[cfg(not(target_os = "windows"))] + let has_resolvable_launch = hello + .launch + .as_deref() + .and_then(crate::library::launch_command) + .is_some(); + #[cfg(target_os = "windows")] + let has_resolvable_launch = false; + let dedicated = + crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch); Some( - tokio::task::spawn_blocking(move || resolve_compositor(pref)) + tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated)) .await .context("resolve compositor task")??, ) @@ -2223,17 +2241,24 @@ fn pick_compositor( /// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log /// whether the explicit request was honored or fell back. Runs blocking probes — call off the /// async reactor (`spawn_blocking`). -fn resolve_compositor(pref: CompositorPref) -> Result { +fn resolve_compositor( + pref: CompositorPref, + dedicated_launch: bool, +) -> Result { use crate::vdisplay::Compositor; // Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor // arg there, so short-circuit the Linux session-detection state machine with a placeholder. #[cfg(target_os = "windows")] { - let _ = pref; + let _ = (pref, dedicated_launch); Ok(Compositor::Kwin) } #[cfg(not(target_os = "windows"))] { + // A client is (re)connecting → cancel any pending TV-session restore so the box stays in the + // streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's + // own cancel — review #3). No-op when nothing is pending. + crate::vdisplay::cancel_pending_tv_restore(); // Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed // to come with a hand-set env — don't retarget the process env in that case. let overridden = crate::config::config().compositor.is_some(); @@ -2244,6 +2269,10 @@ fn resolve_compositor(pref: CompositorPref) -> Result Result Result = available.iter().map(|c| c.id()).collect(); match Compositor::from_pref(pref) { @@ -2886,6 +2927,11 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender, stop: Arc Result<()> { #[cfg(target_os = "windows")] let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); - let (mut capturer, mut enc, mut frame, mut interval) = + let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) = build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?; // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). #[cfg(target_os = "windows")] @@ -3196,8 +3242,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession { kind: sw.kind, env: sw.env, + compositor_pid: None, }); - crate::vdisplay::apply_input_env(sw.compositor); + // A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the + // switched-to backend's normal sub-mode. + crate::vdisplay::apply_input_env(sw.compositor, false); // Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still // point at the old session, so input would silently not land until a reconnect. // Settle it (env push + KWin portal restart) before the injector reopens against it. @@ -3223,13 +3272,14 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { Ok((new_vd, pipe)) })(); match rebuilt { - Ok((new_vd, (new_cap, new_enc, new_frame, new_interval))) => { + Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id))) => { // Replace the pipeline first (drops the old capturer → old PipeWire stream + // virtual output), then the factory (drops e.g. the old KWin connection). capturer = new_cap; enc = new_enc; frame = new_frame; interval = new_interval; + cur_node_id = new_node_id; vd = new_vd; compositor = sw.compositor; next = std::time::Instant::now(); @@ -3264,7 +3314,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // healthy session — keep streaming the current mode and log instead. match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) { Ok(next_pipe) => { - (capturer, enc, frame, interval) = next_pipe; + (capturer, enc, frame, interval, cur_node_id) = next_pipe; cur_mode = new_mode; next = std::time::Instant::now(); } @@ -3331,6 +3381,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // bounded retry is exhausted; the consecutive cap stops a flapping source from looping the // client through endless cold IDRs. Err(e) => { + // B2: a DEDICATED gamescope game session whose gamescope node is gone = the game + // exited (gamescope is a single-app compositor — it dies with its app). End the session + // CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its + // library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s + // timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal + // Bazzite/desktop capture loss still rebuilds in place. + #[cfg(target_os = "linux")] + if launch.is_some() + && crate::vdisplay::launch_is_nested(compositor) + && crate::vdisplay::dedicated_game_exited(cur_node_id) + { + tracing::info!( + "dedicated game session: the game exited — ending the session cleanly" + ); + quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone + conn.close( + punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(), + b"game exited", + ); + break; + } capture_rebuilds += 1; if capture_rebuilds > MAX_CAPTURE_REBUILDS { return Err(e).context("capture lost — rebuild attempts exhausted"); @@ -3348,14 +3419,18 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // appears — no reconnect. const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; - let (new_cap, new_enc, new_frame, new_interval) = loop { + let (new_cap, new_enc, new_frame, new_interval, new_node_id) = loop { // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids // retargeting (then we stick to the pinned backend and just rebuild it). if crate::config::config().compositor.is_none() { let active = crate::vdisplay::detect_active_session(); + // A4: fold any compositor-instance change into the epoch/invalidation before we + // rebuild, so the rebuild's acquire won't reuse a dead-instance node. + crate::vdisplay::observe_session_instance(&active); if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) { crate::vdisplay::apply_session_env(&active); - crate::vdisplay::apply_input_env(c); + // Capture-loss rebuild follows the live box session, not a fresh dedicated launch. + crate::vdisplay::apply_input_env(c, false); if c != compositor { if matches!( c, @@ -3402,6 +3477,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { enc = new_enc; frame = new_frame; interval = new_interval; + cur_node_id = new_node_id; enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway next = std::time::Instant::now(); tracing::info!( @@ -3592,6 +3668,10 @@ type Pipeline = ( Box, crate::capture::CapturedFrame, std::time::Duration, + // The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS + // session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node + // (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway. + u32, ); /// Build the pipeline, retrying *transient* failures with bounded exponential backoff. @@ -3709,6 +3789,14 @@ fn build_pipeline( // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) .context("create virtual output")?; + // A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down + // so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the + // gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.) + #[cfg(target_os = "linux")] + let reused_gen = vout.reused_gen; + // The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to + // this session's own node). Read before `capture_virtual_output` consumes `vout`. + let node_id = vout.node_id; // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a // virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a @@ -3733,7 +3821,17 @@ fn build_pipeline( crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) .context("capture virtual output")?; capturer.set_active(true); - let frame = capturer.next_frame().context("first frame")?; + let frame = match capturer.next_frame().context("first frame") { + Ok(f) => f, + Err(e) => { + // A reused kept display was dead — invalidate it so the next attempt creates fresh (A2). + #[cfg(target_os = "linux")] + if let Some(g) = reused_gen { + crate::vdisplay::registry::mark_failed(g); + } + return Err(e); + } + }; // `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client // advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome. let enc = crate::encode::open_video( @@ -3760,7 +3858,7 @@ fn build_pipeline( ); } let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); - Ok((capturer, enc, frame, interval)) + Ok((capturer, enc, frame, interval, node_id)) } #[cfg(test)] diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 728095b..0881368 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -21,6 +21,29 @@ pub use punktfunk_core::Mode; #[cfg(target_os = "linux")] use std::os::fd::OwnedFd; +/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry +/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of +/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which +/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant +/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must +/// not pretend to keep. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DisplayOwnership { + /// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin, + /// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The + /// default — a backend that says nothing is registry-owned. + #[default] + Owned, + /// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope + /// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through" + /// row. + External, + /// A box-level session the gamescope module manages (the managed `gamescope-session-plus` / + /// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope + /// module's until Part A3 hands the registry a real keepalive + restore duty). + SessionManaged, +} + /// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop /// tears the output down (releases the compositor-side resource). /// @@ -44,6 +67,41 @@ pub struct VirtualOutput { pub win_capture: Option, /// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown. pub keepalive: Box, + /// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The + /// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged` + /// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`. + pub ownership: DisplayOwnership, + /// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a + /// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder + /// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame + /// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of + /// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the + /// keep-alive pool is Linux). + #[cfg(target_os = "linux")] + pub reused_gen: Option, +} + +impl VirtualOutput { + /// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots, + /// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields. + pub fn owned( + node_id: u32, + preferred_mode: Option<(u32, u32, u32)>, + keepalive: Box, + ) -> VirtualOutput { + VirtualOutput { + node_id, + #[cfg(target_os = "linux")] + remote_fd: None, + preferred_mode, + #[cfg(target_os = "windows")] + win_capture: None, + keepalive, + ownership: DisplayOwnership::Owned, + #[cfg(target_os = "linux")] + reused_gen: None, + } + } } /// Pluggable virtual-output creation, per compositor. @@ -101,6 +159,110 @@ pub trait VirtualDisplay: Send { /// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display /// backends never have a sibling. fn set_first_in_group(&mut self, _first: bool) {} + /// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable + /// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before** + /// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of + /// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn** + /// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it, + /// returning `false` when the env selects attach/managed (consistent with the `ownership` its + /// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1. + fn poolable_now(&self) -> bool { + true + } + /// The resolved launch command carried on this backend instance (set via + /// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse + /// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display + /// running game A must never be handed to a session that asked to launch game B. Default `None` + /// (backends that never nest a command); only gamescope reports its `cmd`. + fn launch_command(&self) -> Option { + None + } + /// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a + /// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear + /// the dead entry down and create fresh instead of handing back a corpse (which would then fail + /// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the + /// backstop for a display that dies between this check and first frame). Only gamescope overrides + /// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter + /// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps. + /// + /// [`mark_failed`]: crate::vdisplay::registry::mark_failed + fn kept_display_alive(&mut self, _node_id: u32) -> bool { + true + } +} + +/// The **session epoch** — 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 that brings up a fresh KWin/gamescope with an unrelated node-id space). +/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still +/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a +/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4). +static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries +/// and gate reuse) and by its linger timer (to reap dead-epoch zombies). +pub fn session_epoch() -> u64 { + SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor +/// instance (kind change, or same-kind new PID). Returns the new value. +pub fn bump_session_epoch() -> u64 { + SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 +} + +/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a +/// genuine instance change from a stable re-detect. +static LAST_INSTANCE: std::sync::Mutex)>> = + std::sync::Mutex::new(None); + +/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor +/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind +/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session +/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept +/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per +/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex +/// read); the registry lock is taken only on an actual change. Call from every site that detects the +/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect). +pub fn observe_session_instance(active: &ActiveSession) { + let cur = (active.kind, active.compositor_pid); + let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(prev) = *last { + // Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch + + // invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope** + // session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed + // gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes + // outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID + // flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set + // change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review + // findings #6/#7/#10). Gate the whole action on a desktop kind being involved. + if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) { + // Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope). + if is_desktop_kind(prev.0) { + if let Some(old) = compositor_for_kind(prev.0) { + registry::invalidate_backend(old.id()); + } + } + let epoch = bump_session_epoch(); + tracing::info!( + from = ?prev.0, + to = ?cur.0, + epoch, + "desktop compositor instance changed — session epoch bumped" + ); + } + } + *last = Some(cur); +} + +/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die +/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are +/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]). +fn is_desktop_kind(kind: ActiveKind) -> bool { + matches!( + kind, + ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots + ) } /// Compositors punktfunk knows how to drive (plan §6). @@ -241,6 +403,10 @@ pub struct SessionEnv { pub struct ActiveSession { pub kind: ActiveKind, pub env: SessionEnv, + /// PID of the winning compositor process (`None` when nothing live). The session watcher compares + /// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session + /// epoch — a fresh instance's node-id space is unrelated to the old one's (A4). + pub compositor_pid: Option, } impl ActiveSession { @@ -253,6 +419,7 @@ impl ActiveSession { dbus_session_bus_address: default_bus(&default_runtime_dir()), ..Default::default() }, + compositor_pid: None, } } } @@ -304,6 +471,9 @@ pub fn detect_active_session() -> ActiveSession { // `pkill -x` discipline (exact, ≤15 chars so untruncated). let mut kind = ActiveKind::None; let mut best = 0u8; + // The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the + // session epoch (A4), not just a kind change. + let mut winning_pid: Option = None; if let Ok(entries) = std::fs::read_dir("/proc") { for e in entries.flatten() { let name = e.file_name(); @@ -328,9 +498,22 @@ pub fn detect_active_session() -> ActiveSession { "sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4), _ => continue, }; + let pid = name.parse::().ok(); if prio > best { best = prio; kind = k; + winning_pid = pid; + } else if prio == best { + // Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a + // duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with + // `/proc` enumeration order — which `observe_session_instance` would misread as a + // compositor restart and tear a live display down (re-review low-severity note). + if let (Some(p), Some(w)) = (pid, winning_pid) { + if p < w { + kind = k; + winning_pid = Some(p); + } + } } } } @@ -358,6 +541,7 @@ pub fn detect_active_session() -> ActiveSession { dbus_session_bus_address: dbus, xdg_current_desktop, }, + compositor_pid: winning_pid, } } @@ -518,6 +702,7 @@ pub enum GamescopeMode { /// default is a per-session bare spawn — the path that nests the client's launch command. #[cfg(target_os = "linux")] fn pick_gamescope_mode( + dedicated_launch: bool, force_managed: bool, attach_env: bool, node_env: bool, @@ -529,6 +714,11 @@ fn pick_gamescope_mode( GamescopeMode::Managed } else if attach_env || node_env { GamescopeMode::Attach + } else if dedicated_launch { + // A dedicated game session always spawns its own headless gamescope at the client's mode, + // nesting just the game — outranking managed-infra / foreign-attach, but not the explicit + // operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3) + GamescopeMode::Spawn } else if session_env || managed_infra { GamescopeMode::Managed } else if foreign_gamescope { @@ -548,7 +738,7 @@ fn pick_gamescope_mode( /// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED` /// forces managed over all of it. #[cfg(target_os = "linux")] -pub fn apply_input_env(chosen: Compositor) { +pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) { let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let backend = match chosen { Compositor::Gamescope => "gamescope", @@ -562,6 +752,7 @@ pub fn apply_input_env(chosen: Compositor) { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend); if chosen == Compositor::Gamescope { let mode = pick_gamescope_mode( + dedicated_launch, std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(), @@ -593,7 +784,34 @@ pub fn apply_input_env(chosen: Compositor) { } } #[cfg(not(target_os = "linux"))] -pub fn apply_input_env(_chosen: Compositor) {} +pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {} + +/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated` +/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a +/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to +/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] / +/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline). +pub fn wants_dedicated_game_session(has_launch: bool) -> bool { + use policy::GameSession; + if !has_launch || policy::prefs().game_session() != GameSession::Dedicated { + return false; + } + #[cfg(target_os = "linux")] + { + if gamescope::is_available() { + true + } else { + tracing::info!( + "game_session=dedicated but gamescope is unavailable — falling back to auto routing" + ); + false + } + } + #[cfg(not(target_os = "linux"))] + { + false // Windows: a launching session opens into the one desktop (no gamescope) + } +} /// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare /// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command @@ -616,6 +834,27 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result { gamescope::launch_into_session(cmd) } +/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a +/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the +/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the +/// exit (review #4/#8). Always `false` off Linux. +#[cfg(target_os = "linux")] +pub fn dedicated_game_exited(node_id: u32) -> bool { + gamescope::game_session_exited(node_id) +} +#[cfg(not(target_os = "linux"))] +pub fn dedicated_game_exited(_node_id: u32) -> bool { + false +} + +/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux. +#[cfg(target_os = "linux")] +pub fn cancel_pending_tv_restore() { + gamescope::cancel_pending_restore(); +} +#[cfg(not(target_os = "linux"))] +pub fn cancel_pending_tv_restore() {} + /// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing /// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. @@ -750,6 +989,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> { std::sync::Arc::new(()) } +/// Recover a stranded TV takeover from a crashed previous host instance +/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside +/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start). +#[cfg(target_os = "linux")] +pub fn restore_takeover_on_startup() { + gamescope::restore_takeover_on_startup(); +} +#[cfg(not(target_os = "linux"))] +pub fn restore_takeover_on_startup() {} + // The user-configurable management policy (keep-alive / topology / conflict / identity / layout), // layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read // it), so no cfg gate. See `design/display-management.md`. @@ -878,21 +1127,33 @@ mod tests { fn gamescope_mode_ladder() { use GamescopeMode::*; let pick = pick_gamescope_mode; - // (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope) + // (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope) // Plain distro, nothing running: bare spawn — the path that nests the launch command. - assert_eq!(pick(false, false, false, false, false, false), Spawn); + assert_eq!(pick(false, false, false, false, false, false, false), Spawn); // Bazzite/SteamOS (session infra present): managed, as validated live. - assert_eq!(pick(false, false, false, false, true, false), Managed); - assert_eq!(pick(false, false, false, false, true, true), Managed); + assert_eq!( + pick(false, false, false, false, false, true, false), + Managed + ); + assert_eq!(pick(false, false, false, false, false, true, true), Managed); // Foreign gamescope on an infra-less box: attach and mirror it. - assert_eq!(pick(false, false, false, false, false, true), Attach); + assert_eq!(pick(false, false, false, false, false, false, true), Attach); // Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra. - assert_eq!(pick(false, false, false, true, false, false), Managed); + assert_eq!( + pick(false, false, false, false, true, false, false), + Managed + ); // Explicit attach/node wins over infra… - assert_eq!(pick(false, true, false, false, true, false), Attach); - assert_eq!(pick(false, false, true, true, true, false), Attach); + assert_eq!(pick(false, false, true, false, false, true, false), Attach); + assert_eq!(pick(false, false, false, true, true, true, false), Attach); // …and force-managed wins over everything. - assert_eq!(pick(true, true, true, false, false, false), Managed); + assert_eq!(pick(false, true, true, true, false, false, false), Managed); + // A dedicated launch forces Spawn, outranking managed-infra + foreign-attach… + assert_eq!(pick(true, false, false, false, false, true, true), Spawn); + // …but the explicit operator overrides still win over dedicated. + assert_eq!(pick(true, true, false, false, false, true, false), Managed); + assert_eq!(pick(true, false, true, false, false, false, false), Attach); + assert_eq!(pick(true, false, false, true, false, false, false), Attach); } #[test] diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 9880452..952effb 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -14,7 +14,7 @@ //! Input uses gamescope's own libei/EIS socket (`LIBEI_SOCKET`), relayed to the libei backend (see //! `inject/libei.rs`) — wired and live-validated. -use super::{Mode, VirtualDisplay, VirtualOutput}; +use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput}; use anyhow::{anyhow, bail, Context, Result}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -62,6 +62,18 @@ static PENDING_RESTORE: std::sync::Mutex> = std::sync::Mutex::ne /// instead of triggering a stop/relaunch. const RESTORE_DEBOUNCE: Duration = Duration::from_secs(5); +/// Per-spawn instance counter (A5): each bare-spawn gets a unique id addressing its own log so two +/// coexisting gamescopes (a kept lingering spawn + a fresh one) never parse each other's node id. +static SPAWN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// This spawn instance's log path, under `$XDG_RUNTIME_DIR` (per-user, tmpfs; falls back to `/tmp` +/// only if unset). Replaces the shared `/tmp/punktfunk-gamescope.log` so concurrent spawns don't +/// clobber each other's `stream available on node ID:` line. +fn spawn_log_path(inst: u64) -> std::path::PathBuf { + let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + std::path::Path::new(&base).join(format!("punktfunk-gamescope-{inst}.log")) +} + /// systemd --user transient unit name for the host-managed gamescope-session-plus session. const SESSION_UNIT: &str = "punktfunk-gamescope"; /// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts). @@ -82,6 +94,80 @@ const STEAMOS_SESSION_TARGET: &str = "gamescope-session.target"; /// restart the physical session. static STEAMOS_TOOK_OVER: std::sync::Mutex = std::sync::Mutex::new(false); +/// Persisted takeover state (`design/gamemode-and-dedicated-sessions.md` A3): the takeover mechanics +/// ([`STOPPED_AUTOLOGIN`] / [`STEAMOS_TOOK_OVER`]) are process memory, so a host **crash** mid-stream +/// would strand the box out of gaming mode with no restore. Mirroring the statics to a file lets +/// [`restore_takeover_on_startup`] put the TV back after a restart. +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct TakeoverState { + /// Autologin `gamescope-session-plus@*.service` units we stopped (to restart on restore). + stopped_autologin: Vec, + /// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart). + steamos: bool, +} + +/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared +/// on reboot, which is correct: a reboot restarts the autologin itself). +fn takeover_state_path() -> std::path::PathBuf { + let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + std::path::Path::new(&base).join("punktfunk-session-takeover.json") +} + +/// Persist the current takeover mechanics so a host crash doesn't strand the box out of gaming mode. +/// Best-effort (a write failure just loses crash-restore, not correctness). +fn persist_takeover() { + let state = TakeoverState { + stopped_autologin: STOPPED_AUTOLOGIN + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), + steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()), + }; + if state.stopped_autologin.is_empty() && !state.steamos { + clear_takeover(); + return; + } + if let Ok(bytes) = serde_json::to_vec(&state) { + let _ = std::fs::write(takeover_state_path(), bytes); + } +} + +/// Remove the persisted takeover file (after a completed restore, or when there's nothing to restore). +fn clear_takeover() { + let _ = std::fs::remove_file(takeover_state_path()); +} + +/// On host startup, restore the TV's gaming session if a previous host instance took it over and +/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted +/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a +/// client reconnecting right after the restart keeps the streamed session instead of bouncing the +/// box back to gaming mode). No-op when no takeover file exists (a clean start). Call once from +/// `serve` alongside [`start_restore_worker`]. +pub fn restore_takeover_on_startup() { + let Ok(bytes) = std::fs::read(takeover_state_path()) else { + return; // no takeover file — clean start + }; + let Ok(state) = serde_json::from_slice::(&bytes) else { + clear_takeover(); + return; + }; + if state.stopped_autologin.is_empty() && !state.steamos { + clear_takeover(); + return; + } + tracing::warn!( + units = ?state.stopped_autologin, + steamos = state.steamos, + "gamescope: found a stranded takeover from a previous host instance — scheduling TV restore" + ); + *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin; + *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos; + // A generous grace so a client reconnecting right after the restart cancels it (create_managed_session + // clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode. + *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = + Some(Instant::now() + Duration::from_secs(15)); +} + impl GamescopeDisplay { pub fn new() -> Result { Ok(GamescopeDisplay::default()) @@ -97,6 +183,32 @@ impl VirtualDisplay for GamescopeDisplay { self.cmd = cmd; } + fn poolable_now(&self) -> bool { + // Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); managed + // (`PUNKTFUNK_GAMESCOPE_SESSION`) and attach (`PUNKTFUNK_GAMESCOPE_NODE`) report + // `SessionManaged`/`External`, so the registry must not reuse a kept spawn for them (same + // backend name). Mirrors [`crate::vdisplay::launch_is_nested`]; read under the env lock the + // sub-mode ladder writes these keys under. + crate::vdisplay::with_env_lock(|| { + std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() + && std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() + }) + } + + fn launch_command(&self) -> Option { + // The registry keys keep-alive reuse on (backend, mode, launch): a kept bare-spawn running + // game A must never be reused for a session launching game B (A2). + self.cmd.clone() + } + + fn kept_display_alive(&mut self, node_id: u32) -> bool { + // The nested gamescope dies when its game exits (independently of any compositor), leaving a + // dead pooled node. Before the registry reuses that node on a reconnect, confirm it still + // exists on the daemon; a `false` makes the registry recreate instead of handing back a corpse + // (which would then burn a ~10 s first-frame retry before `mark_failed` recovered it). + gamescope_node_present(node_id) + } + fn create(&mut self, mode: Mode) -> Result { // Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the // full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE @@ -121,26 +233,51 @@ impl VirtualDisplay for GamescopeDisplay { }; point_injector_at_eis(); tracing::info!(node_id, "gamescope: attaching to existing PipeWire node"); + // ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse). return Ok(VirtualOutput { node_id, remote_fd: None, preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), keepalive: Box::new(()), + ownership: DisplayOwnership::External, + reused_gen: None, }); } check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions - let proc = GamescopeProc(spawn( + // B1: a dedicated STEAM launch needs Steam's single instance free. If the box autologged into + // game mode (Bazzite) its Steam holds the instance, and a nested second Steam would see the + // first and exit (crashing the spawn) — so free the autologin session first. Its restore is the + // A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via + // schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this. + if self.cmd.as_deref().is_some_and(is_steam_launch) { + stop_autologin_sessions(); + } + // A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting + // bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a + // shared log. The nested-command's LIBEI relay stays on the global path (per-instance input + // isolation is `design/gamescope-multiuser.md` scope, not addressed here). + let inst = SPAWN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let log = spawn_log_path(inst); + let child = spawn( mode.width, mode.height, mode.refresh_hz.max(1), self.cmd.as_deref(), - )?); + &log, + )?; + let child_pid = child.id(); + let proc = GamescopeProc { + child, + log: log.clone(), + }; // gamescope creates its PipeWire node a moment after start; poll for it (the proc is held - // alive meanwhile, and killed if we give up). - let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| { + // alive meanwhile, and killed if we give up). Discovery reads THIS spawn's log, and the + // fallback is scoped to this spawn's process tree. + let node_id = wait_for_node(Duration::from_secs(15), &log, child_pid).ok_or_else(|| { anyhow!( "gamescope PipeWire node did not appear within 15s — gamescope may have failed to \ - start or headless capture is unsupported on this GPU/driver (see /tmp/punktfunk-gamescope.log)" + start or headless capture is unsupported on this GPU/driver (see {})", + log.display() ) })?; tracing::info!( @@ -150,12 +287,12 @@ impl VirtualDisplay for GamescopeDisplay { hz = mode.refresh_hz, "gamescope virtual output ready" ); - Ok(VirtualOutput { + // Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able). + Ok(VirtualOutput::owned( node_id, - remote_fd: None, - preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), - keepalive: Box::new(proc), - }) + Some((mode.width, mode.height, mode.refresh_hz)), + Box::new(proc), + )) } } @@ -192,12 +329,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result { hz = mode.refresh_hz, "gamescope session: reusing the running session (same mode — no Steam restart)" ); - return Ok(VirtualOutput { - node_id, - remote_fd: None, - preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), - keepalive: Box::new(()), - }); + return Ok(managed_output(node_id, mode)); } tracing::warn!("gamescope session: tracked session has no live node — relaunching"); *guard = None; @@ -218,12 +350,23 @@ fn create_managed_session(client: &str, mode: Mode) -> Result { hz = mode.refresh_hz, "gamescope session: launched gamescope-session-plus at the client's mode" ); - Ok(VirtualOutput { + Ok(managed_output(node_id, mode)) +} + +/// The [`VirtualOutput`] for a managed / SteamOS-takeover session: a box-level session whose restore +/// lifecycle is (at Part A1) the gamescope module's own machinery (`schedule_restore_tv_session`), so +/// it is [`DisplayOwnership::SessionManaged`] — the registry passes it through (no pooling), and the +/// capturer's unit keepalive tears nothing down on drop. (Part A3 replaces the unit keepalive with a +/// real `ManagedSessionHandle` and flips this to `Owned`.) +fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput { + VirtualOutput { node_id, remote_fd: None, preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), keepalive: Box::new(()), - }) + ownership: DisplayOwnership::SessionManaged, + reused_gen: None, + } } /// SteamOS detection: its session launcher is present and Bazzite's session-plus is NOT (so the @@ -483,12 +626,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result { hz = mode.refresh_hz, "gamescope (SteamOS): reusing the headless session (same mode — no Steam restart)" ); - return Ok(VirtualOutput { - node_id, - remote_fd: None, - preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), - keepalive: Box::new(()), - }); + return Ok(managed_output(node_id, mode)); } *guard = None; // tracked session lost its node — fall through to a clean restart } @@ -497,9 +635,11 @@ fn create_managed_session_steamos(mode: Mode) -> Result { systemctl_user(&["daemon-reload"]); systemctl_user(&["restart", STEAMOS_SESSION_TARGET]); *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = true; - // gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower - // (Big Picture cold start) and is awaited by the caller's first-frame retry loop. - let node_id = wait_for_node(Duration::from_secs(30)).ok_or_else(|| { + persist_takeover(); // A3: survive a host crash mid-stream + // gamescope's node appears within a few seconds of the restart; Steam's first FRAME is slower + // (Big Picture cold start) and is awaited by the caller's first-frame retry loop. The managed + // session logs to journald (not a per-spawn file), so poll `find_gamescope_node` directly. + let node_id = poll_managed_node(Duration::from_secs(30)).ok_or_else(|| { anyhow!( "SteamOS headless gamescope node did not appear within 30s after restarting \ {STEAMOS_SESSION_TARGET} — check `journalctl --user -u gamescope-session.service`" @@ -518,12 +658,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result { hz = mode.refresh_hz, "gamescope (SteamOS): took over gamescope-session.target headless at the client's mode" ); - Ok(VirtualOutput { - node_id, - remote_fd: None, - preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), - keepalive: Box::new(()), - }) + Ok(managed_output(node_id, mode)) } /// ATTACH at the CLIENT's resolution: ensure the box's own game-mode session is running at `mode`'s @@ -670,11 +805,31 @@ fn running_autologin_gamescope_unit() -> Option { .map(|u| u.to_string()) } +/// Tear a gamescope `systemd --user` unit down with **SIGKILL** rather than the default SIGTERM stop +/// (`design/gamemode-and-dedicated-sessions.md` A3 / `session-aware-host-followups.md` #1): the +/// hypothesis — validated as the fix on the F44 repro box `.181` — is that gamescope's SIGTERM +/// teardown handler (the one that SIGSEGVs, exit 139) LEAKS the NVIDIA GPU context, after which every +/// subsequent gamescope fails `vkCreateDevice` with `VK_ERROR_INITIALIZATION_FAILED` (-3) until a +/// reboot. SIGKILL skips that handler so the driver reclaims the context cleanly via normal process +/// exit. Follow with `stop` + `reset-failed` to clear the unit's state so a relaunch is clean. +fn kill_unit(unit: &str) { + let _ = Command::new("systemctl") + .args(["--user", "kill", "--signal=SIGKILL", unit]) + .status(); + let _ = Command::new("systemctl") + .args(["--user", "stop", unit]) + .status(); + let _ = Command::new("systemctl") + .args(["--user", "reset-failed", unit]) + .status(); +} + /// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its /// single-instance Steam is free for our own host-managed session. Records the units so /// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient /// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing -/// is autologged in (e.g. a box that boots headless). +/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`]) +/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers. fn stop_autologin_sessions() { let Ok(out) = Command::new("systemctl") .args([ @@ -694,12 +849,10 @@ fn stop_autologin_sessions() { for line in String::from_utf8_lossy(&out.stdout).lines() { if let Some(unit) = line.split_whitespace().next() { if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") { - let _ = Command::new("systemctl") - .args(["--user", "stop", unit]) - .status(); + kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak tracing::info!( unit, - "freed Steam: stopped the autologin gaming session for this stream" + "freed Steam: SIGKILL-stopped the autologin gaming session for this stream" ); stopped.push(unit.to_string()); } @@ -707,15 +860,57 @@ fn stop_autologin_sessions() { } if !stopped.is_empty() { *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped; + persist_takeover(); // A3: survive a host crash mid-stream } } -/// Client disconnected: **schedule** a debounced restore of the TV's autologin gaming session(s) we -/// stopped on connect — the actual restore fires [`RESTORE_DEBOUNCE`] later (via [`start_restore_worker`]) -/// unless a client reconnects first, which cancels it and reuses the warm managed session. Debouncing -/// means at most one gamescope stop/relaunch per quiet period instead of one per disconnect — the -/// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite / -/// headless box). Idempotent / safe to call on every session end. +/// Cancel any pending TV-session restore — a client has (re)connected, so the box must stay in the +/// streamed session, not bounce back to gaming mode. This covers the **keep-alive reuse** reconnect +/// path (a kept dedicated / managed gamescope), which never calls `create_managed_session` (where the +/// managed path already clears `PENDING_RESTORE`) — so without this, a dedicated Steam reconnect within +/// the linger window would restart the autologin *underneath* the live session (review finding #3). +/// Called from the connect path (native `resolve_compositor`, GameStream `open_gs_virtual_source`). +/// No-op when nothing is pending; the stopped-unit list stays armed for a later real disconnect. +pub fn cancel_pending_restore() { + let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()); + if g.is_some() { + *g = None; + tracing::info!( + "gamescope: client (re)connected — cancelled the pending TV-session restore" + ); + } +} + +/// The delay before restoring the TV's autologin session after the last client disconnects — the +/// display-management **keep-alive policy**, replacing the hardcoded [`RESTORE_DEBOUNCE`] +/// (`design/gamemode-and-dedicated-sessions.md` A3). The managed gamescope session is a single +/// box-level singleton (not a registry pool entry — A1), so its keep-alive lives here rather than in +/// the registry, but reads the same policy the pooled backends do: +/// * `off` → restore immediately (0 s); +/// * `duration(s)` → restore after `s`; +/// * `forever` → **`None`**: never auto-restore — the managed session is HELD until host stop or a +/// manual return to gaming mode (the `gaming-rig` "the TV model" story, now truthful on gamescope); +/// * unconfigured → the historical 5 s [`RESTORE_DEBOUNCE`] (bit-for-bit today's behavior). +fn restore_delay() -> Option { + use crate::vdisplay::policy::{self, Linger}; + match policy::prefs() + .configured_effective() + .map(|e| e.keep_alive.linger()) + { + Some(Linger::Immediate) => Some(Duration::from_secs(0)), + Some(Linger::For(d)) => Some(d), + Some(Linger::Forever) => None, + None => Some(RESTORE_DEBOUNCE), + } +} + +/// Client disconnected: **schedule** a policy-timed restore of the TV's autologin gaming session(s) we +/// stopped on connect ([`restore_delay`], via [`start_restore_worker`]) — unless a client reconnects +/// first, which cancels it and reuses the warm managed session. Debouncing means at most one gamescope +/// stop/relaunch per quiet period instead of one per disconnect — the per-connect churn is what leaked +/// GPU context on F44. Under `keep_alive=forever` ([`restore_delay`] `None`) NO restore is scheduled: +/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless +/// box). Idempotent / safe to call on every session end. pub fn schedule_restore_tv_session() { let nothing_to_restore = STOPPED_AUTOLOGIN .lock() @@ -725,12 +920,24 @@ pub fn schedule_restore_tv_session() { if nothing_to_restore { return; // nothing was taken over → nothing to restore (also the non-managed path) } - *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = - Some(Instant::now() + RESTORE_DEBOUNCE); - tracing::info!( - secs = RESTORE_DEBOUNCE.as_secs(), - "gamescope: scheduled debounced TV-session restore (cancelled if a client reconnects)" - ); + match restore_delay() { + None => { + // keep_alive=forever → pin the managed session; leave PENDING_RESTORE unset. + *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; + tracing::info!( + "gamescope: keep-alive=forever — managed session held (no TV-restore scheduled; \ + return to gaming mode or restart the host to free it)" + ); + } + Some(delay) => { + *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = + Some(Instant::now() + delay); + tracing::info!( + secs = delay.as_secs(), + "gamescope: scheduled TV-session restore (keep-alive policy; cancelled on reconnect)" + ); + } + } } /// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s) @@ -745,6 +952,7 @@ fn do_restore_tv_session() { let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()); if *took { *took = false; + clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; remove_steamos_dropin(); systemctl_user(&["daemon-reload"]); @@ -770,6 +978,7 @@ fn do_restore_tv_session() { if units.is_empty() { return; // nothing was stolen → nothing to restore (also the non-Bazzite path) } + clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; // Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the @@ -923,12 +1132,10 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result { } } -/// Stop the host-managed session's transient unit (best-effort) and clear the EIS relay so a dead -/// session's socket name can't be reconnected. +/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44 +/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected. fn stop_session(unit_name: &str) { - let _ = Command::new("systemctl") - .args(["--user", "stop", unit_name]) - .status(); + kill_unit(unit_name); let _ = std::fs::remove_file(ei_socket_file()); } @@ -949,13 +1156,36 @@ pub fn ei_socket_file() -> std::path::PathBuf { } } +/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch +/// (`steam steam://rungameid/`, produced by `library::command_for`) gets `-silent` inserted so +/// the game is the gamescope focus with no Steam client window to navigate +/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam +/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested. +/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's +/// single instance free before a dedicated spawn (B1). Pure + unit-tested. +fn is_steam_launch(cmd: &str) -> bool { + let mut it = cmd.split_whitespace(); + it.next() == Some("steam") && cmd.contains("steam://") +} + +fn shape_dedicated_command(app: &str) -> String { + let mut it = app.split_whitespace(); + if it.next() == Some("steam") { + let rest: Vec<&str> = it.collect(); + if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) { + return format!("steam -silent {}", rest.join(" ")); + } + } + app.to_string() +} + /// Spawn `gamescope --backend headless -W w -H h -r hz -- `. The app comes from /// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real /// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session). -/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell -/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`] +/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny +/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`] /// so the input injector can connect to gamescope's EIS server from outside. -fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result { +fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result { // A non-empty per-session command (set via `set_launch_command`) wins; else the // `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps // gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently @@ -970,6 +1200,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result { }) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "sleep infinity".to_string()); + // Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the + // gamescope focus with no Steam client window to navigate. + let app = shape_dedicated_command(&app); let relay = ei_socket_file(); let _ = std::fs::remove_file(&relay); // stale socket path from a previous session let mut cmd = Command::new("gamescope"); @@ -990,14 +1223,14 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result { .args(app.split_whitespace()) // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia"); - if let Ok(log) = std::fs::File::create("/tmp/punktfunk-gamescope.log") { - if let Ok(log2) = log.try_clone() { - cmd.stdout(Stdio::from(log)).stderr(Stdio::from(log2)); + if let Ok(logf) = std::fs::File::create(log) { + if let Ok(log2) = logf.try_clone() { + cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2)); } } else { cmd.stdout(Stdio::null()).stderr(Stdio::null()); } - tracing::info!(w, h, hz, %app, "spawning gamescope (headless)"); + tracing::info!(w, h, hz, %app, log = %log.display(), "spawning gamescope (headless)"); cmd.spawn() .context("spawn gamescope (is it installed? `apt install gamescope`)") } @@ -1006,22 +1239,59 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result { /// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects /// — the adapter and the inner stream — and only the advertised id is the correct capture /// target). Falls back to `pw-dump` discovery if the log line doesn't show. -fn wait_for_node(timeout: Duration) -> Option { +/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is +/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS +/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a +/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]), +/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a +/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the +/// node stays absent across the window. +pub fn game_session_exited(node_id: u32) -> bool { + let deadline = Instant::now() + Duration::from_millis(1500); + loop { + if gamescope_node_present(node_id) { + return false; // OUR node is (still) present → not an exit (transient loss) + } + if Instant::now() >= deadline { + return true; // our node stayed gone across the window → the game exited + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which +/// logs to journald (no per-spawn file) and is single-session (no scoping needed). +fn poll_managed_node(timeout: Duration) -> Option { let deadline = Instant::now() + timeout; loop { - if let Some(id) = node_from_log() { + if let Some(id) = find_gamescope_node() { return Some(id); } if Instant::now() >= deadline { - return find_gamescope_node(); // last-resort fallback + return None; } std::thread::sleep(Duration::from_millis(300)); } } -/// Parse `stream available on node ID: N` from the spawned gamescope's log (ANSI-colored). -fn node_from_log() -> Option { - let log = std::fs::read_to_string("/tmp/punktfunk-gamescope.log").ok()?; +fn wait_for_node(timeout: Duration, log: &std::path::Path, child_pid: u32) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(id) = node_from_log(log) { + return Some(id); + } + if Instant::now() >= deadline { + // Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's + // node isn't picked by mistake. + return find_gamescope_node_scoped(Some(child_pid)); + } + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored). +fn node_from_log(log: &std::path::Path) -> Option { + let log = std::fs::read_to_string(log).ok()?; for line in log.lines().rev() { if let Some(pos) = line.find("stream available on node ID:") { let tail = &line[pos + "stream available on node ID:".len()..]; @@ -1034,6 +1304,27 @@ fn node_from_log() -> Option { None } +/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the +/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node +/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse". +fn gamescope_node_present(node_id: u32) -> bool { + let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else { + // pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node). + return true; + }; + let Ok(dump) = serde_json::from_slice::(&out.stdout) else { + return true; + }; + dump.as_array() + .map(|objs| { + objs.iter().any(|o| { + o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64) + && o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node") + }) + }) + .unwrap_or(true) +} + /// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. /// /// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only @@ -1041,10 +1332,18 @@ fn node_from_log() -> Option { /// other wedges the link. So we require `Video/Source` first and fall back to a bare name match /// only if no class-tagged node is present (older gamescope that doesn't set media.class). fn find_gamescope_node() -> Option { + find_gamescope_node_scoped(None) +} + +/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process +/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must +/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is +/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session). +fn find_gamescope_node_scoped(scope: Option) -> Option { let out = Command::new("pw-dump").output().ok()?; let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; let nodes = dump.as_array()?; - let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String)> { + let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option)> { if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") { return None; } @@ -1060,20 +1359,40 @@ fn find_gamescope_node() -> Option { .and_then(|n| n.as_str()) .unwrap_or("") .to_string(); - Some((id, name, class)) + // PipeWire records the owning process id as a string or an int depending on version. + let pid = props + .and_then(|p| p.get("application.process.id")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + .map(|n| n as u32) + }); + Some((id, name, class, pid)) }; - // Preferred: a Video/Source node named (or containing) "gamescope". + // A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When + // the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the + // per-instance log is the primary addressing (design §7 risk note). + let in_scope = |pid: Option| -> bool { + match scope { + None => true, + Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true), + } + }; + // Preferred: a Video/Source node named (or containing) "gamescope", in scope. for obj in nodes { - if let Some((id, name, class)) = node_props(obj) { - if class == "Video/Source" && (name == "gamescope" || name.contains("gamescope")) { + if let Some((id, name, class, pid)) = node_props(obj) { + if class == "Video/Source" + && (name == "gamescope" || name.contains("gamescope")) + && in_scope(pid) + { return Some(id); } } } - // Fallback: a node literally named "gamescope" with no usable class tag. + // Fallback: a node literally named "gamescope" with no usable class tag, in scope. for obj in nodes { - if let Some((id, name, _)) = node_props(obj) { - if name == "gamescope" { + if let Some((id, name, _, pid)) = node_props(obj) { + if name == "gamescope" && in_scope(pid) { tracing::warn!( node_id = id, "gamescope node has no media.class=Video/Source tag — capturing it anyway" @@ -1168,22 +1487,62 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> { None } -/// Owns the spawned gamescope process; killing it tears the virtual output down. -struct GamescopeProc(Child); +/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual +/// output down. +struct GamescopeProc { + child: Child, + log: std::path::PathBuf, +} impl Drop for GamescopeProc { fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); + let _ = self.child.kill(); + let _ = self.child.wait(); // Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this // now-dead session's socket between sessions (the stale path is the "Connection refused"). let _ = std::fs::remove_file(ei_socket_file()); + // Drop this spawn's per-instance log (A5) so `$XDG_RUNTIME_DIR` doesn't accumulate them. + let _ = std::fs::remove_file(&self.log); } } #[cfg(test)] mod tests { - use super::{parse_version, MIN_GAMESCOPE}; + use super::{is_steam_launch, parse_version, shape_dedicated_command, MIN_GAMESCOPE}; + + #[test] + fn steam_launch_detection() { + assert!(is_steam_launch("steam steam://rungameid/570")); + assert!(is_steam_launch("steam -silent steam://rungameid/570")); + assert!(!is_steam_launch("vkcube")); + assert!(!is_steam_launch("lutris lutris:rungameid/42")); + assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch + } + + #[test] + fn dedicated_command_shaping() { + // Steam URI → -silent inserted so the game is the gamescope focus. + assert_eq!( + shape_dedicated_command("steam steam://rungameid/570"), + "steam -silent steam://rungameid/570" + ); + // Idempotent: an already-silent command is left alone. + assert_eq!( + shape_dedicated_command("steam -silent steam://rungameid/570"), + "steam -silent steam://rungameid/570" + ); + // Non-Steam launches and operator custom commands are untouched. + assert_eq!(shape_dedicated_command("vkcube"), "vkcube"); + assert_eq!( + shape_dedicated_command("lutris lutris:rungameid/42"), + "lutris lutris:rungameid/42" + ); + // A bare `steam` with no URI is left alone (not a game launch). + assert_eq!( + shape_dedicated_command("steam -bigpicture"), + "steam -bigpicture" + ); + } #[test] fn parses_version_banner() { diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index a8d1349..6922d9b 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -212,12 +212,11 @@ impl VirtualDisplay for KwinDisplay { }); // Layout position (§6.2) is applied by the registry via `apply_position` right after create // (it owns the display group, so it computes auto-row / manual placement over the whole group). - Ok(VirtualOutput { + Ok(VirtualOutput::owned( node_id, - remote_fd: None, - preferred_mode: Some((mode.width, mode.height, achieved_hz)), - keepalive: Box::new(StopGuard { stop }), - }) + Some((mode.width, mode.height, achieved_hz)), + Box::new(StopGuard { stop }), + )) } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs index cfafdc1..fccaa83 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs @@ -97,12 +97,11 @@ impl VirtualDisplay for MutterDisplay { h = mode.height, "Mutter virtual monitor ready" ); - Ok(VirtualOutput { + Ok(VirtualOutput::owned( node_id, - remote_fd: None, - preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), - keepalive: Box::new(StopGuard(stop)), - }) + Some((mode.width, mode.height, mode.refresh_hz)), + Box::new(StopGuard(stop)), + )) } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs b/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs index 464cf34..096cff3 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs @@ -19,7 +19,7 @@ //! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast //! interface routed to xdpw (`scripts/headless/portals.conf`). -use super::{Mode, VirtualDisplay, VirtualOutput}; +use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput}; use anyhow::{anyhow, bail, Context, Result}; use std::os::fd::OwnedFd; use std::process::Command; @@ -130,6 +130,11 @@ impl VirtualDisplay for WlrootsDisplay { _stop: StopGuard(stop), _output: output, }), + // Owned (the compositor output is ours to tear down), but not registry-poolable: the + // portal fd can't be re-opened per attach, so the registry passes it through on + // `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach). + ownership: DisplayOwnership::Owned, + reused_gen: None, }) } } diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs index 18b9df5..f120871 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -158,6 +158,22 @@ pub struct Layout { pub positions: BTreeMap, } +/// How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is +/// served (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes +/// — a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers +/// it. Linux-only in effect (a launching Windows session opens into the one desktop). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GameSession { + /// 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). + #[default] + Auto, + /// A launching session always gets its OWN headless gamescope at the client's mode, nesting just + /// the game — no Steam Big Picture, no game mode. Degrades to `auto` when gamescope is unavailable. + Dedicated, +} + /// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any /// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] @@ -202,6 +218,11 @@ pub struct DisplayPolicy { /// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write). #[serde(default = "default_max_displays")] pub max_displays: u32, + /// How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2). + /// Orthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto` + /// so existing `display-settings.json` files are untouched. + #[serde(default)] + pub game_session: GameSession, } fn one() -> u32 { @@ -224,6 +245,7 @@ impl Default for DisplayPolicy { identity: Identity::default(), layout: Layout::default(), max_displays: 4, + game_session: GameSession::default(), } } } @@ -279,7 +301,11 @@ impl EffectivePolicy { /// transform, factored out pure so arranging displays stays orthogonal to the other axes and is /// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new /// layout — rule; a named preset would ignore them.) - pub fn with_manual_layout(&self, positions: BTreeMap) -> DisplayPolicy { + pub fn with_manual_layout( + &self, + positions: BTreeMap, + game_session: GameSession, + ) -> DisplayPolicy { DisplayPolicy { version: 1, preset: Preset::Custom, @@ -292,6 +318,8 @@ impl EffectivePolicy { positions, }, max_displays: self.max_displays, + // Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it). + game_session, } } } @@ -398,6 +426,13 @@ impl DisplayPolicyStore { self.configured().map(|p| p.effective()) } + /// The game-session routing axis (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to + /// the preset — read directly off the stored policy (or the default `Auto` when unconfigured), so a + /// preset selection never resets it. + pub fn game_session(&self) -> GameSession { + self.get().game_session + } + /// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk /// write succeeds, so a full disk can't leave memory and file disagreeing. pub fn set(&self, policy: DisplayPolicy) -> Result<()> { @@ -560,7 +595,9 @@ mod tests { let mut positions = BTreeMap::new(); positions.insert("1".to_string(), Position { x: 0, y: 0 }); positions.insert("7".to_string(), Position { x: 2560, y: 0 }); - let p = eff.with_manual_layout(positions); + let p = eff.with_manual_layout(positions, GameSession::Dedicated); + // The orthogonal game-session axis is preserved through the layout transform. + assert_eq!(p.game_session, GameSession::Dedicated); // Preset drops to Custom so the explicit fields (incl. the layout) rule… assert_eq!(p.preset, Preset::Custom); // …every other behavior axis is preserved verbatim… diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index ed602be..f3e04c6 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -164,6 +164,28 @@ pub fn release(slot: Option) -> usize { } } +/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline +/// builder when the first frame fails on a display [`acquire`] handed back as REUSED — so the retry +/// loop's next `acquire` creates fresh instead of re-wedging on the same corpse. No-op off Linux / if +/// the entry is already gone (idempotent — the subsequent stale-gen lease drop no-ops too). +pub fn mark_failed(gen: u64) { + #[cfg(target_os = "linux")] + linux::mark_failed(gen); + #[cfg(not(target_os = "linux"))] + let _ = gen; +} + +/// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch +/// tore it down), so `/display/state` must stop listing it and its keepalive must be reaped +/// (`design/gamemode-and-dedicated-sessions.md` A4). Called from the session-switch watcher / a +/// per-connect re-detect that finds the previous backend's compositor gone. No-op off Linux. +pub fn invalidate_backend(backend: &str) { + #[cfg(target_os = "linux")] + linux::invalidate_backend(backend); + #[cfg(not(target_os = "linux"))] + let _ = backend; +} + // --------------------------------------------------------------------------------------------- // Linux keep-alive pool // --------------------------------------------------------------------------------------------- @@ -202,6 +224,13 @@ mod linux { /// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the /// group's last member drops. `None` for extend/primary and non-first / non-exclusive members. topology_restore: Option, + /// The launch command this display was created with (`design/gamemode-and-dedicated-sessions.md` + /// A2): keep-alive reuse requires an exact match, so a kept spawn running game A never serves a + /// session launching game B. `None` = a plain desktop / no nested command. + launch: Option, + /// The session epoch at creation (A4). Reuse requires an epoch match; the linger timer reaps + /// entries whose epoch is stale (their compositor instance was replaced under them). + epoch: u64, /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// — its entry was reused + re-stamped — is a no-op). gen: u64, @@ -210,6 +239,18 @@ mod linux { /// A per-group topology-restore action (see [`Entry::topology_restore`]). type Restore = Box; + /// The result of the keep-alive reuse lookup (A2 validated reuse): a live kept display was reused, + /// a dead one was pulled out (recreate), or nothing matched. + enum ReuseOutcome { + /// A live kept display — the session-facing output to return. + Reused(VirtualOutput), + /// A dead kept display, removed from the pool, plus its group restore (run before the corpse's + /// keepalive drops); the caller falls through to a fresh create. + Dead(Entry, Option), + /// No matching kept display. + Miss, + } + /// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a /// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown /// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the @@ -245,6 +286,19 @@ mod linux { }) } + /// Does a pooled entry's session `epoch` still match the current one for reuse / expiry purposes? + /// The session epoch tracks the box's **active-session (desktop) compositor** instance (KWin / + /// Mutter / wlroots) — whose PipeWire node dies with the compositor, so a stale-epoch kept output + /// is a corpse. A **gamescope** spawn is the exact opposite: an independent nested session (its own + /// group), whose node lives with its own child process, wholly unrelated to whatever desktop / + /// game-mode compositor the epoch tracks. So gamescope entries are EXEMPT from the epoch — a desktop + /// switch, or a game-mode gamescope restart, must never invalidate a kept dedicated game session + /// (review findings #2/#5/#6/#7/#10). Their liveness is the `kept_display_alive` node probe + the B2 + /// game-exit path + `mark_failed`, not the epoch. + fn epoch_matches(backend: &str, entry_epoch: u64, cur_epoch: u64) -> bool { + backend == "gamescope" || entry_epoch == cur_epoch + } + /// The linger resolution for Linux: the console policy's `keep_alive` when configured, else /// **Immediate** (today's behavior — a Linux disconnect tears the output down at once). fn linger() -> Linger { @@ -262,9 +316,17 @@ mod linux { fn take_expired(entries: &mut Vec, now: Instant) -> (Vec, Vec) { let mut expired = Vec::new(); let mut restores = Vec::new(); + // A4 backstop: also reap a KEPT (non-Active) DESKTOP display whose session epoch is stale — its + // compositor instance was replaced (a Game↔Desktop switch / same-kind restart), so its node id + // now means nothing. gamescope spawns are exempt (`epoch_matches` — independent nested sessions). + // An Active entry is left to its own session's capture-loss rebuild (which, under the bumped + // epoch, won't reuse it); `invalidate_backend` clears a whole desktop backend on a known switch. + let cur_epoch = crate::vdisplay::session_epoch(); let mut i = 0; while i < entries.len() { - if entries[i].life.poll_expiry(now) { + let dead_epoch = !epoch_matches(entries[i].backend, entries[i].epoch, cur_epoch) + && !matches!(entries[i].life, lifecycle::State::Active { .. }); + if entries[i].life.poll_expiry(now) || dead_epoch { let mut e = entries.remove(i); let backend = e.backend; if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) { @@ -312,13 +374,18 @@ mod linux { preferred_mode: Option<(u32, u32, u32)>, gen: u64, quit: Arc, + reused: bool, ) -> VirtualOutput { - VirtualOutput { + // The pooled display is registry-owned; the session holds a gen-stamped lease as its keepalive. + let mut out = VirtualOutput::owned( node_id, - remote_fd: None, preferred_mode, - keepalive: Box::new(DisplayLease { gen, quit }), - } + Box::new(DisplayLease { gen, quit }), + ); + // A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can + // `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node. + out.reused_gen = reused.then_some(gen); + out } pub(super) fn acquire( @@ -328,6 +395,10 @@ mod linux { ) -> Result { ensure_timer(); let backend = vd.name(); + // A2 reuse key: the launch command this acquire carries (a kept spawn running game A must never + // be reused for a session launching game B). A4 reuse key: the current session epoch. + let launch = vd.launch_command(); + let cur_epoch = crate::vdisplay::session_epoch(); let r = reg(); // Reap expired first (run any group restores + drop outside the lock). @@ -340,28 +411,94 @@ mod linux { } drop(expired); - // Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session - // re-attaches a fresh PipeWire consumer to the still-live `node_id`. - { - let mut es = r.entries.lock().unwrap(); - if let Some(e) = es.iter_mut().find(|e| { - matches!( - e.life, - lifecycle::State::Lingering { .. } | lifecycle::State::Pinned - ) && e.backend == backend - && e.mode == mode - }) { - // Lingering/Pinned → Active (Acquire::Reuse); side effect matters, value is known. - e.life.acquire(); - let gen = r.gen.fetch_add(1, Ordering::Relaxed); - e.gen = gen; - let out = output_for(e.node_id, e.preferred_mode, gen, quit); - tracing::info!( - backend, - node_id = e.node_id, - "virtual display reused (keep-alive reconnect)" - ); - return Ok(out); + // Reuse: a kept (lingering/pinned) display of the same backend + mode + launch + epoch. A + // reconnecting session re-attaches a fresh PipeWire consumer to the still-live `node_id`. Gated + // on `vd.poolable_now()` (A1): a gamescope managed/attach acquire must NOT reuse a kept bare-spawn + // (they share the backend name `"gamescope"`); its `create` builds a `SessionManaged`/`External` + // output that passes through below. + if vd.poolable_now() { + // Reuse a kept display, matching backend + mode + launch (+ epoch for the desktop backends; + // gamescope spawns are independent nested sessions, exempt from the active-session epoch — + // see `epoch_matches`). The liveness probe (`kept_display_alive`, which may shell `pw-dump` + // for gamescope) must NOT run under the pool lock (it can block / hang the daemon), so: + // 1. find the candidate + snapshot (gen, node_id) UNDER the lock, then release it; + // 2. probe liveness OUTSIDE the lock; + // 3. re-lock and re-find the SAME entry by its gen (another thread may have reused/removed + // it meanwhile — then we just miss and create fresh). + let candidate = { + let es = r.entries.lock().unwrap(); + es.iter() + .find(|e| { + matches!( + e.life, + lifecycle::State::Lingering { .. } | lifecycle::State::Pinned + ) && e.backend == backend + && e.mode == mode + && e.launch == launch + && epoch_matches(e.backend, e.epoch, cur_epoch) + }) + .map(|e| (e.gen, e.node_id)) + }; + if let Some((cand_gen, node_id)) = candidate { + let alive = vd.kept_display_alive(node_id); // OUTSIDE the lock (may block) + let reuse = { + let mut es = r.entries.lock().unwrap(); + // Re-find the SAME entry by its snapshot gen; skip if it's gone or no longer kept + // (a concurrent reconnect adopted it) — we then miss and create fresh. + match es.iter().position(|e| { + e.gen == cand_gen + && matches!( + e.life, + lifecycle::State::Lingering { .. } | lifecycle::State::Pinned + ) + }) { + Some(idx) if alive => { + es[idx].life.acquire(); + let gen = r.gen.fetch_add(1, Ordering::Relaxed); + es[idx].gen = gen; + let preferred_mode = es[idx].preferred_mode; + tracing::info!( + backend, + node_id, + "virtual display reused (keep-alive reconnect)" + ); + ReuseOutcome::Reused(output_for( + node_id, + preferred_mode, + gen, + quit.clone(), + true, + )) + } + Some(idx) => { + // Dead kept display: remove it, hand off its group restore, create fresh. + let mut dead = es.remove(idx); + let restore = hand_off_restore( + &mut es, + dead.backend, + dead.topology_restore.take(), + ); + ReuseOutcome::Dead(dead, restore) + } + None => ReuseOutcome::Miss, // adopted/removed by another thread + } + }; + match reuse { + ReuseOutcome::Reused(out) => return Ok(out), + ReuseOutcome::Dead(dead, restore) => { + // Outside the lock: re-enable physicals (if the group emptied) then drop the + // corpse's keepalive (may block) — then fall through to a fresh create below. + if let Some(rst) = restore { + rst(); + } + tracing::info!( + backend, + "virtual display: kept display was dead — recreating (validated reuse)" + ); + drop(dead); + } + ReuseOutcome::Miss => {} + } } } @@ -381,13 +518,18 @@ mod linux { // the group arrangement (manual per-slot positions) + the state slot. let identity_slot = vd.last_identity_slot(); - // wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the - // portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The - // poolable backends put their node on the default daemon (remote_fd = None). - if real.remote_fd.is_some() { + // Pool ONLY a registry-owned display on the default PipeWire daemon + // (design/gamemode-and-dedicated-sessions.md A1). Pass through, unchanged (capturer owns the + // keepalive, teardown on drop), everything else: + // * `External`/`SessionManaged` — gamescope attach / managed session: the gamescope module + // owns their lifecycle (its own restore machinery), so the registry must not keep them + // (the stale-node reuse wedge). Their unit keepalive tears nothing down on drop. + // * `remote_fd = Some` — wlroots' sandboxed xdpw portal fd can't be re-opened per attach. + if real.ownership != crate::vdisplay::DisplayOwnership::Owned || real.remote_fd.is_some() { tracing::debug!( backend, - "virtual display not poolable (portal fd) — keep-alive off for this backend" + ownership = ?real.ownership, + "virtual display not registry-poolable — keep-alive off (owner keeps it / portal fd)" ); return Ok(real); } @@ -410,6 +552,8 @@ mod linux { backend, identity_slot, topology_restore, + launch: launch.clone(), + epoch: cur_epoch, gen, }; @@ -455,7 +599,7 @@ mod linux { if (position.x, position.y) != (0, 0) { vd.apply_position(position.x, position.y); } - Ok(output_for(node_id, preferred_mode, gen, quit)) + Ok(output_for(node_id, preferred_mode, gen, quit, false)) } /// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The @@ -704,6 +848,71 @@ mod linux { n } + /// A2 — tear down a reused-but-dead pool entry by its generation stamp. Removes it (hand off / + /// run its group restore), drops the keepalive outside the lock. Idempotent (already gone → no-op). + pub(super) fn mark_failed(gen: u64) { + let Some(r) = REG.get() else { return }; + let (torn, restore) = { + let mut es = r.entries.lock().unwrap(); + let Some(idx) = es.iter().position(|e| e.gen == gen) else { + return; // already gone — the subsequent stale-gen lease drop no-ops too + }; + let mut e = es.remove(idx); + let backend = e.backend; + let restore = hand_off_restore(&mut es, backend, e.topology_restore.take()); + (e, restore) + }; + if let Some(rst) = restore { + rst(); // outside the lock, before the keepalive drops + } + tracing::warn!( + backend = torn.backend, + "virtual display: reused kept display was dead on first frame — torn down (A2 mark_failed)" + ); + drop(torn); // keepalive Drop outside the lock (may block) + } + + /// A4 — invalidate every kept display of `backend` (its compositor instance is gone). Removes them + /// all (any lifecycle state — a dead compositor's Active entries are doomed too; their sessions + /// rebuild), runs/hands off group restores, drops keepalives outside the lock (they hit dead + /// sockets and fail fast). Mirrors `force_release`'s shape but selects by backend, not slot/state. + pub(super) fn invalidate_backend(backend: &str) { + let Some(r) = REG.get() else { return }; + let (removed, restores) = { + let mut es = r.entries.lock().unwrap(); + let mut out = Vec::new(); + let mut restores = Vec::new(); + let mut i = 0; + while i < es.len() { + if es[i].backend == backend { + let mut e = es.remove(i); + let b = e.backend; + if let Some(rst) = hand_off_restore(&mut es, b, e.topology_restore.take()) { + restores.push(rst); + } + out.push(e); + } else { + i += 1; + } + } + (out, restores) + }; + if removed.is_empty() { + return; + } + for restore in restores { + restore(); + } + tracing::info!( + backend, + count = removed.len(), + "virtual displays invalidated — compositor instance gone (A4 session switch)" + ); + for e in removed { + drop(e); // outside the lock + } + } + /// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the /// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op. struct DisplayLease { @@ -744,6 +953,8 @@ mod linux { backend, identity_slot: None, topology_restore: restore, + launch: None, + epoch: 0, gen, } } diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 021a11e..e5fd9c5 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -31,7 +31,7 @@ use windows::Win32::System::Threading::{ CreateMutexW, OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, }; -use super::{Mode, VirtualOutput}; +use super::{DisplayOwnership, Mode, VirtualOutput}; use crate::win_display::{ force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig, @@ -531,6 +531,9 @@ impl VirtualDisplayManager { mgr: self, gen: mon.gen, }), + // The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry + // (which delegates to it via `vd.create`) treats it as Owned. + ownership: DisplayOwnership::Owned, } } diff --git a/design/display-management.md b/design/display-management.md index 6e2a5e0..5c64cfa 100644 --- a/design/display-management.md +++ b/design/display-management.md @@ -33,7 +33,11 @@ Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md` (pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent, -not required). +not required), **`design/gamemode-and-dedicated-sessions.md`** (PLANNED — reconciles this layer +with session-mobile Bazzite/SteamOS hosts: display **ownership classes** so the registry stops +pooling gamescope managed/attach outputs it doesn't own, validated reuse + invalidation, the +§5.1 "policy replaces the managed 5 s debounce" promise actually implemented, and the dedicated +per-launch gamescope game sessions built on it). ## 1. Goal @@ -590,7 +594,12 @@ out per-host instead of lying: The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity, -conflict = join-only. That's just codifying reality. +conflict = join-only. That's just codifying reality. **Gap (2026-07-05):** the shipped registry +does NOT implement this row — it pools every `remote_fd == None` output, including the +managed/attach/SteamOS paths' unit-keepalive outputs, which double-owns the managed session +against the gamescope module's own restore worker (stale-node reuse wedge on game-mode +reconnect). The fix — explicit display **ownership classes** + registry-owned managed-session +restore — is designed in `design/gamemode-and-dedicated-sessions.md` Part A. ## 8. Management API, web console, tray diff --git a/design/gamemode-and-dedicated-sessions.md b/design/gamemode-and-dedicated-sessions.md new file mode 100644 index 0000000..72cffd7 --- /dev/null +++ b/design/gamemode-and-dedicated-sessions.md @@ -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-.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` (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 ` (+ `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-.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-`, 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": {"": …}` 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/`): Steam is single-instance per uid, so: + 1. Command shape becomes `steam -silent steam://rungameid/` 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. diff --git a/design/session-aware-host-followups.md b/design/session-aware-host-followups.md index 4b1e066..220c66e 100644 --- a/design/session-aware-host-followups.md +++ b/design/session-aware-host-followups.md @@ -20,6 +20,14 @@ virtual output primary** — `apply_session_env` defaults `PUNKTFUNK_KWIN_VIRTUA `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` on for the auto desktop path. Both shipped in `3363576`; details in git history.) +> **Update 2026-07-05:** items **#1** (SIGKILL teardown / keep-warm) and **#7** (restore-guard / +> keep-warm interaction) are picked up by `design/gamemode-and-dedicated-sessions.md` **Part A3** +> — the managed session's restore becomes registry-owned and policy-driven (`keep_alive` replaces +> the hardcoded 5 s debounce; "keep warm" = `keep_alive: forever`), with the SIGKILL-teardown +> experiment folded into that stage's validation. That doc also covers the display-management +> registry's game-mode conflicts (ownership classes, stale-node reuse) and dedicated per-launch +> gamescope game sessions. + ## Still parked ### 1. F44 gamescope teardown corrupts the GPU context diff --git a/docs-site/content/docs/virtual-displays.md b/docs-site/content/docs/virtual-displays.md index a56e277..c33d56c 100644 --- a/docs-site/content/docs/virtual-displays.md +++ b/docs-site/content/docs/virtual-displays.md @@ -106,6 +106,26 @@ Per-backend support: each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more are streaming. +### Dedicated game sessions + +**Dedicated game sessions** control how a session that *launches a game from your library* is served +(Linux hosts): + +- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session on a + Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or spawned into your live KDE / + GNOME / Sway desktop. +- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution and + refresh**, with just the game inside. The game boots straight in — no Steam Big Picture to navigate, + no game-mode desktop. Steam titles launch with the client hidden (`steam -silent`); non-Steam titles + start almost instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**, + the game keeps running when you disconnect and you re-attach straight back into it; when you quit the + game, the session ends cleanly and your client returns to its library. + +Dedicated needs `gamescope` installed on the host; if it isn't, a launch falls back to **Auto** +routing. This axis is independent of the preset — pick it under Host → *Virtual displays*. On a box +that's already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores +it when the session ends. (GameStream / Moonlight launches follow the same routing.) + ## Persistent scaling Set your display **scaling** once and have it stick across reconnects. This works by giving each @@ -149,3 +169,13 @@ an empty extension. Use **Primary** or **Exclusive** so your desktop actually la **KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all — see [requirements](/docs/requirements). + +**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box, disconnecting +and reconnecting within game mode reuses the still-warm session (or cleanly recreates it) instead of +landing on a dead stream — and switching between game mode and the KDE / GNOME desktop mid-stream +follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your +library; a game mode / desktop session keeps streaming. + +**My couch box's TV stayed on the streamed session after I disconnected.** With the **gaming-rig** +preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes +instantly — return to game mode on the box (or restart the host) to hand the TV back. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 8773bb6..75376a5 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -283,6 +283,16 @@ #define QUIT_CLOSE_CODE 81 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// QUIC application error code the **host** closes the control connection with when a **dedicated game +// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher +// client can distinguish "the game ended" from an error and return to its library cleanly rather than +// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of +// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client +// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. +#define APP_EXITED_CLOSE_CODE 82 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST diff --git a/web/messages/de.json b/web/messages/de.json index b86843d..52dbc80 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -97,6 +97,10 @@ "display_conflict_reject": "Besetzt — ablehnen", "display_identity": "Client-Identität", "display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.", + "display_game_session": "Dedizierte Spiel-Sitzungen", + "display_game_session_help": "Wie eine Sitzung bedient wird, die ein Spiel aus der Bibliothek startet. „Dediziert“ gibt dem Start immer ein eigenes headless-gamescope in genau deiner Auflösung — das Spiel startet direkt, ohne Steam Big Picture, ohne Game-Mode. „Auto“ nutzt die aktuelle Sitzung der Box. gamescope muss installiert sein; sonst fällt Dediziert auf Auto zurück.", + "display_game_session_auto": "Auto", + "display_game_session_dedicated": "Dediziert", "display_identity_shared": "Geteilt", "display_identity_per_client": "Pro Client", "display_identity_per_client_mode": "Pro Client + Auflösung", diff --git a/web/messages/en.json b/web/messages/en.json index 6d222fd..1dd88ea 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -97,6 +97,10 @@ "display_conflict_reject": "Busy — reject", "display_identity": "Per-client identity", "display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.", + "display_game_session": "Dedicated game sessions", + "display_game_session_help": "How a session that launches a game from the library is served. “Dedicated” always gives the launch its own headless gamescope at your exact resolution — the game boots straight in, no Steam Big Picture, no game mode. “Auto” uses whatever session the box is in. gamescope must be installed; otherwise Dedicated falls back to Auto.", + "display_game_session_auto": "Auto", + "display_game_session_dedicated": "Dedicated", "display_identity_shared": "Shared", "display_identity_per_client": "Per client", "display_identity_per_client_mode": "Per client + resolution", diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index f5d7e4c..2c7a413 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -14,6 +14,7 @@ import type { ApiDisplayInfo, DisplayPolicy, EffectivePolicy, + GameSession, Identity, KeepAlive, LayoutMode, @@ -141,6 +142,8 @@ const DisplayForm: FC<{ identity: effective.identity, layout: effective.layout, max_displays: effective.max_displays, + // Game-session is orthogonal to the preset — carry it through the Custom switch. + game_session: draft.game_session ?? "auto", }); } else { apply({ ...draft, preset: id as Preset }); @@ -330,6 +333,24 @@ const DisplayForm: FC<{ )} + {/* Game-session routing — orthogonal to the preset/lifecycle axes, so it lives outside the + Custom block and applies immediately on change (like a preset click). */} +
+ { + const next = { ...draft, game_session: v as GameSession }; + setDraft(next); + apply(next); + }} + /> +
+ {/* What's in force right now */}
{m.display_effective()}: @@ -339,6 +360,9 @@ const DisplayForm: FC<{ {tr(IDENTITY_LABEL, effective.identity)} {tr(LAYOUT_LABEL, effective.layout.mode)} {`${effective.max_displays}×`} + {(draft.game_session ?? "auto") === "dedicated" && ( + {m.display_game_session_dedicated()} + )}

{m.display_pending_note()}

@@ -611,6 +635,11 @@ const LAYOUT_LABEL: Record string> = { manual: m.display_layout_manual, }; +const GAME_SESSION_LABEL: Record string> = { + auto: m.display_game_session_auto, + dedicated: m.display_game_session_dedicated, +}; + /** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */ const tr = (map: Record string>, key: string | null | undefined): string => { const fn = key == null ? undefined : map[key];