diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 6e94f66a..f21a6994 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -87,8 +87,9 @@ pub use session::{session_epoch, try_recover_session}; #[path = "vdisplay/routing.rs"] pub(crate) mod routing; pub use routing::{ - apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now, - restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session, + apply_input_env, managed_session_available, resolve_gamescope_route, restore_managed_session, + restore_takeover_now, restore_takeover_on_startup, start_restore_worker, + wants_dedicated_game_session, GamescopeRoute, }; #[cfg(target_os = "linux")] pub use routing::{ diff --git a/crates/pf-vdisplay/src/vdisplay/backend.rs b/crates/pf-vdisplay/src/vdisplay/backend.rs index 36a2498a..6b2e663d 100644 --- a/crates/pf-vdisplay/src/vdisplay/backend.rs +++ b/crates/pf-vdisplay/src/vdisplay/backend.rs @@ -117,6 +117,13 @@ pub trait VirtualDisplay: Send { /// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an /// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it). fn set_launch_command(&mut self, _cmd: Option) {} + /// Set the RESOLVED gamescope sub-mode for this session (from + /// [`apply_input_env`](crate::apply_input_env)). Carried on the backend instance for the same + /// reason as [`set_launch_command`](Self::set_launch_command) — it used to travel through + /// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`, where the GameStream plane and the mid-session switch + /// watcher could overwrite one session's decision before another session's `create` read it. + /// Default: no-op (only the gamescope backend has sub-modes). + fn set_gamescope_route(&mut self, _route: Option) {} /// Set the connecting client's cert fingerprint so the backend can give that client a STABLE virtual /// monitor identity across reconnects and its saved per-monitor config (notably DPI scaling) is /// reapplied — via the OS (Windows EDID serial), the compositor (KWin per-slot output name), or diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index b81c3285..f4ca8d17 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -49,6 +49,12 @@ pub struct GamescopeDisplay { /// WSI layer advertises HDR10/scRGB surfaces to nested games, and the composite gamescope /// hands us can be negotiated as a 10-bit PQ stream (`packaging/gamescope`). hdr: bool, + /// This session's resolved sub-mode (set via [`VirtualDisplay::set_gamescope_route`]). Same + /// per-instance discipline as `cmd`, and for the same reason: it used to arrive through + /// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`, which a concurrent connect could overwrite between + /// the decision and this session's `create`. `None` = nothing resolved it (a caller that never + /// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default. + route: Option, } /// A running host-managed session (its transient systemd --user unit) + the mode it was launched at. @@ -263,16 +269,17 @@ impl VirtualDisplay for GamescopeDisplay { self.hdr } + fn set_gamescope_route(&mut self, route: Option) { + self.route = route; + } + 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::launch_is_nested`]; read under the env lock the // sub-mode ladder writes these keys under. - crate::with_env_lock(|| { - std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() - && std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() - }) + matches!(self.route, None | Some(crate::GamescopeRoute::Spawn)) } fn launch_command(&self) -> Option { @@ -294,16 +301,17 @@ impl VirtualDisplay for GamescopeDisplay { // full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE // them (via the injected --nested-refresh + generated CVT modes, not the box's TV EDID) — // and relaunch it when the client's mode changes. Reuses the node + EIS discovery below. - // Both keys read in ONE guarded snapshot: `apply_input_env` writes them under the env - // lock but releases it before they are consumed, so reading them live here raced its - // writes — and read them WITHOUT the lock that `poolable_now` and `launch_is_nested` take - // for the same two keys. - let (session_env, node_env) = crate::with_env_lock(|| { - ( - std::env::var("PUNKTFUNK_GAMESCOPE_SESSION").ok(), - std::env::var("PUNKTFUNK_GAMESCOPE_NODE").ok(), - ) - }); + // THIS session's resolved sub-mode, handed over by the host from `apply_input_env`. It + // used to be read out of the process env here, which meant a second connect could retarget + // this one between the decision and this line. + let (session_env, node_env) = match self.route.clone() { + Some(crate::GamescopeRoute::Managed { client }) => (Some(client), None), + Some(crate::GamescopeRoute::Attach { node }) => (None, Some(node)), + Some(crate::GamescopeRoute::Spawn) => (None, None), + // Nobody resolved a route (no `apply_input_env` on this path): bare spawn, which is + // also what the ladder's own default arm picks. + None => (None, None), + }; if let Some(client) = session_env { return create_managed_session(&client, mode, self.hdr); } diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index c658708a..abd14860 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -5,8 +5,32 @@ use super::*; -/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`], -/// written into the env knobs `GamescopeDisplay::create` dispatches on. +/// The RESOLVED gamescope sub-mode for one session, with the payload `GamescopeDisplay::create` +/// needs. +/// +/// This is what [`apply_input_env`] hands back, and it is carried on the backend INSTANCE +/// (`VirtualDisplay::set_gamescope_route`) exactly as `set_launch_command` carries the launch — not +/// through process env. The env knobs used to BE the channel: `apply_input_env` wrote +/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` and `create` read them back, but the lock was released in +/// between, and the whole GameStream plane plus the mid-session switch watcher re-run the writer — +/// so session B's decision could overwrite session A's before A's `create` ever read it. They +/// remain *operator overrides*, sampled once (see `operator_gamescope`); nothing writes them now. +/// +/// Defined on every platform because the host's `SessionContext` carries it beside `compositor`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GamescopeRoute { + /// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode. `client` is + /// the session flavour (`steam`), from `PUNKTFUNK_GAMESCOPE_SESSION` if the operator set it. + Managed { client: String }, + /// Attach to an already-running gamescope. `node` is a PipeWire node id, or `auto` to discover + /// the box's own game-mode session (from `PUNKTFUNK_GAMESCOPE_NODE` if the operator set it). + Attach { node: String }, + /// Bare-spawn a headless gamescope for this session, nesting its launch command. + Spawn, +} + +/// How a gamescope-backed session is realized — the pure ladder's verdict, before the payload is +/// attached (see [`GamescopeRoute`]). #[cfg(target_os = "linux")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GamescopeMode { @@ -80,24 +104,31 @@ fn pick_gamescope_mode( static OPERATOR_GAMESCOPE: std::sync::OnceLock = std::sync::OnceLock::new(); #[cfg(target_os = "linux")] -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct OperatorGamescope { managed: bool, attach: bool, - node: bool, - session: bool, + /// The operator's `PUNKTFUNK_GAMESCOPE_NODE` VALUE, if set — the ladder needs its presence and + /// `apply_input_env` needs its content to build the route. + node: Option, + /// Likewise `PUNKTFUNK_GAMESCOPE_SESSION` — the managed session flavour. + session: Option, } #[cfg(target_os = "linux")] -fn operator_gamescope() -> OperatorGamescope { - *OPERATOR_GAMESCOPE.get_or_init(|| { +fn operator_gamescope() -> &'static OperatorGamescope { + OPERATOR_GAMESCOPE.get_or_init(|| { let ov = with_env_lock(|| OperatorGamescope { managed: std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(), attach: std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(), - node: std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(), - session: std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(), + node: std::env::var("PUNKTFUNK_GAMESCOPE_NODE") + .ok() + .filter(|v| !v.is_empty()), + session: std::env::var("PUNKTFUNK_GAMESCOPE_SESSION") + .ok() + .filter(|v| !v.is_empty()), }); - if ov.managed || ov.attach || ov.node || ov.session { + if ov.managed || ov.attach || ov.node.is_some() || ov.session.is_some() { tracing::info!( ?ov, "gamescope: operator sub-mode overrides sampled from the environment" @@ -107,11 +138,14 @@ fn operator_gamescope() -> OperatorGamescope { }) } +/// +/// Returns the resolved [`GamescopeRoute`] when `chosen` is gamescope — the caller must carry it to +/// the backend instance via `VirtualDisplay::set_gamescope_route`. It is a RETURN VALUE and no +/// longer an env write precisely because two sessions connecting at once would otherwise clobber +/// each other's decision through the process env. #[cfg(target_os = "linux")] -pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) { - // Sampled BEFORE the lock — `operator_gamescope` takes it itself, and this mutex is not - // reentrant. - let ov = operator_gamescope(); +#[must_use = "the resolved gamescope route must reach the backend instance (set_gamescope_route)"] +pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option { let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let backend = match chosen { Compositor::Gamescope => "gamescope", @@ -125,42 +159,66 @@ pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) { Compositor::Wlroots | Compositor::Hyprland => "wlr", }; std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend); - if chosen == Compositor::Gamescope { + drop(_env_guard); + resolve_gamescope_route(chosen, dedicated_launch) +} + +/// The gamescope sub-mode ladder ALONE — no input-backend env write. +/// +/// Split out for the operator-pinned path (`PUNKTFUNK_COMPOSITOR` set), which deliberately leaves +/// `PUNKTFUNK_INPUT_BACKEND` alone but still needs a route: without one, `create` would fall +/// through to a bare spawn on a box the operator pinned to the managed session. +#[cfg(target_os = "linux")] +#[must_use = "the resolved gamescope route must reach the backend instance (set_gamescope_route)"] +pub fn resolve_gamescope_route( + chosen: Compositor, + dedicated_launch: bool, +) -> Option { + if chosen != Compositor::Gamescope { + return None; + } + { + // Sampled inside — `operator_gamescope` takes ENV_LOCK itself, and `apply_input_env` has + // already dropped its guard before calling us (the mutex is not reentrant). + let ov = operator_gamescope(); let mode = pick_gamescope_mode( dedicated_launch, ov.managed, ov.attach, - ov.node, - ov.session, + ov.node.is_some(), + ov.session.is_some(), gamescope::managed_session_available(), gamescope::foreign_gamescope_running(), ); tracing::info!(?mode, "gamescope sub-mode"); - match mode { - GamescopeMode::Attach => { - std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION"); - if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() { - std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"); - } - } - GamescopeMode::Managed => { - if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() { - std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam"); - } - std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); - } - GamescopeMode::Spawn => { - // Bare spawn: `create` must fall through to the spawn path, so neither knob may - // linger from an earlier connect's managed/attach selection. - std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION"); - std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); - } - } + // Nothing is written back to the two knobs: they are the OPERATOR's inputs, sampled once + // above, and the decision travels out as a value. An earlier revision published it here, + // which is how one Attach latched Attach for the host's lifetime (the ladder re-read its + // own output) and how a second session could retarget a first session's `create`. + Some(match mode { + GamescopeMode::Attach => GamescopeRoute::Attach { + node: ov.node.clone().unwrap_or_else(|| "auto".to_string()), + }, + GamescopeMode::Managed => GamescopeRoute::Managed { + client: ov.session.clone().unwrap_or_else(|| "steam".to_string()), + }, + GamescopeMode::Spawn => GamescopeRoute::Spawn, + }) } } #[cfg(not(target_os = "linux"))] -pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {} +pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) -> Option { + None +} + +#[cfg(not(target_os = "linux"))] +pub fn resolve_gamescope_route( + _chosen: Compositor, + _dedicated_launch: bool, +) -> Option { + None +} /// 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 @@ -191,15 +249,12 @@ pub fn wants_dedicated_game_session(has_launch: bool) -> bool { /// 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 -/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope -/// sub-mode (the env knobs are that resolution's output). +/// into the session — it would start twice. Takes the session's own resolved +/// [`GamescopeRoute`] (from [`apply_input_env`]) rather than re-reading process env, so a +/// concurrent session's routing decision cannot change this session's answer. #[cfg(target_os = "linux")] -pub fn launch_is_nested(compositor: Compositor) -> bool { - compositor == Compositor::Gamescope - && with_env_lock(|| { - std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() - && std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() - }) +pub fn launch_is_nested(compositor: Compositor, route: Option<&GamescopeRoute>) -> bool { + compositor == Compositor::Gamescope && matches!(route, Some(GamescopeRoute::Spawn)) } /// Launch `cmd` into the live gamescope session (managed/attach — see diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index b1eb29ae..36ea6ead 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -252,7 +252,7 @@ fn run( // exactly like the native plane), create a virtual output at the client mode, and capture it. // Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a // Desktop<->Game switch. - let (mut capturer, compositor) = + let (mut capturer, compositor, gamescope_route) = open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit)?; tracing::info!( ?compositor, @@ -288,7 +288,9 @@ fn run( // source open), so launching again would start it twice. #[cfg(target_os = "linux")] let spawned_launch = match target.as_ref().and_then(|t| t.command.as_deref()) { - Some(_) if crate::vdisplay::launch_is_nested(compositor) => None, + Some(_) if crate::vdisplay::launch_is_nested(compositor, gamescope_route.as_ref()) => { + None + } Some(cmd) => match crate::library::launch_session_command(compositor, cmd) { Ok(spawned) => Some(spawned), Err(e) => { @@ -310,7 +312,7 @@ fn run( // relaunch of the same title reclaims the game (above). let _game_life = target.as_ref().map(|t| { #[cfg(target_os = "linux")] - let nested = crate::vdisplay::launch_is_nested(compositor); + let nested = crate::vdisplay::launch_is_nested(compositor, gamescope_route.as_ref()); #[cfg(not(target_os = "linux"))] let nested = false; #[cfg(target_os = "linux")] @@ -368,7 +370,7 @@ fn run( // without a Moonlight reconnect. (A resolution change can't be followed mid-stream on // GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.) let rebuild = - || open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit).map(|(c, _)| c); + || open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit).map(|(c, _, _)| c); return stream_body( &mut capturer, Some(&rebuild), @@ -535,7 +537,9 @@ fn open_gs_mirror_source( .map(Ok) .unwrap_or_else(crate::vdisplay::detect) .context("detect compositor")?; - crate::vdisplay::apply_input_env(compositor, false); + // A mirror streams an existing head — no gamescope sub-mode applies, so the resolved route is + // deliberately dropped here rather than carried. + let _ = crate::vdisplay::apply_input_env(compositor, false); let mut vd = crate::vdisplay::open_mirror(compositor, connector)?; // Cursor mode is the session's negotiated one: metadata where this encode path composites // `frame.cursor`, otherwise let the compositor embed it (§7.5 — one resolver, per-backend @@ -633,9 +637,16 @@ fn open_gs_virtual_source( launch: Option<&GsApp>, // The session's deliberate-quit flag, handed to the display's keep-alive lease. quit: &Arc, -) -> Result<(Box, crate::vdisplay::Compositor)> { - let compositor = if let Some(c) = app.and_then(|a| a.compositor) { - c +) -> Result<( + Box, + crate::vdisplay::Compositor, + Option, +)> { + let (compositor, gamescope_route) = if let Some(c) = app.and_then(|a| a.compositor) { + // An app-pinned compositor still needs a route resolved, or `create` falls through to a + // bare spawn on a box pinned to the managed session (mirrors `native::resolve_compositor`). + let r = crate::vdisplay::resolve_gamescope_route(c, false); + (c, r) } else { // Windows has a single virtual-display backend (pf-vdisplay); `vdisplay::open` ignores the // compositor arg there, so short-circuit the Linux session-detection state machine with a @@ -644,7 +655,7 @@ fn open_gs_virtual_source( // killed the GameStream video thread → black screen (the native plane was already guarded). #[cfg(target_os = "windows")] { - crate::vdisplay::Compositor::Kwin + (crate::vdisplay::Compositor::Kwin, None) } #[cfg(not(target_os = "windows"))] { @@ -661,15 +672,16 @@ fn open_gs_virtual_source( // the resolved command so an unresolvable entry falls back to auto routing (review #9). let has_launch = launch.and_then(|t| t.command.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 + let r = + crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true); + (crate::vdisplay::Compositor::Gamescope, r) } 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 r = crate::vdisplay::apply_input_env(c, false); + (c, r) } } }; @@ -681,6 +693,10 @@ fn open_gs_virtual_source( // Linux this is a no-op backend-side, and a library title resolves to no command at all — the // interactive-session spawner launches it by id instead. vd.set_launch_command(launch.and_then(|t| t.command.clone())); + // This plane's resolved gamescope sub-mode, on the instance for the same reason as the launch + // command above — the GameStream and native planes both call `apply_input_env`, so publishing + // through the process env let either retarget the other's `create`. + vd.set_gamescope_route(gamescope_route.clone()); // Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream // connect used to skip it entirely, so it could ADD/reconfigure the shared monitor while a // native session was mid-build (and vice versa), and its sealed-channel delivery would replace @@ -729,7 +745,7 @@ fn open_gs_virtual_source( ) .context("capture virtual output")?; capturer.set_active(true); - Ok((capturer, compositor)) + Ok((capturer, compositor, gamescope_route)) } /// The encoder bit depth implied by the captured frame's pixel format: a 10-bit (HDR) source — the diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 71392ef3..7918e34b 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -976,7 +976,7 @@ async fn serve_session( }); } - let (hello, welcome, udp_port, data_sock, direct, start, compositor, prep) = + let (hello, welcome, udp_port, data_sock, direct, start, compositor, gamescope_route, prep) = tokio::time::timeout( HANDSHAKE_TIMEOUT, handshake::negotiate( @@ -1529,6 +1529,7 @@ async fn serve_session( rfi: rfi_rx, bitrate_rx, compositor, + gamescope_route, bitrate_kbps, live_bitrate, encoder_ceiling_kbps, diff --git a/crates/punktfunk-host/src/native/compositor.rs b/crates/punktfunk-host/src/native/compositor.rs index 55bc9350..7b4d9a3f 100644 --- a/crates/punktfunk-host/src/native/compositor.rs +++ b/crates/punktfunk-host/src/native/compositor.rs @@ -40,14 +40,17 @@ fn pick_compositor( pub(super) fn resolve_compositor( pref: CompositorPref, dedicated_launch: bool, -) -> Result { +) -> Result<( + crate::vdisplay::Compositor, + Option, +)> { use crate::vdisplay::Compositor; // Windows has a single virtual-display backend (pf-vdisplay); 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, dedicated_launch); - Ok(Compositor::Kwin) + Ok((Compositor::Kwin, None)) } #[cfg(not(target_os = "windows"))] { @@ -83,11 +86,12 @@ pub(super) fn resolve_compositor( // env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the // backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin. if dedicated_launch && !overridden { - crate::vdisplay::apply_input_env(Compositor::Gamescope, true); + let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true); tracing::info!( + ?route, "dedicated game session — routing to a headless gamescope spawn at the client mode" ); - return Ok(Compositor::Gamescope); + return Ok((Compositor::Gamescope, route)); } let available = crate::vdisplay::available(); let chosen = match pick_compositor(pref, &available, detected) { @@ -125,11 +129,19 @@ pub(super) fn resolve_compositor( ); } }; - if !overridden { - // Point input at the same backend and resolve the gamescope sub-mode (managed where the - // session infra exists, attach to a foreign gamescope, else per-session bare spawn). - crate::vdisplay::apply_input_env(chosen, false); - } + // Point input at the same backend and resolve the gamescope sub-mode (managed where the + // session infra exists, attach to a foreign gamescope, else per-session bare spawn). The + // route travels back to the caller as a VALUE and is carried on the backend instance — an + // operator pin skips the input retarget but still needs a route resolved, or `create` would + // fall through to a bare spawn on a box that was pinned to the managed session. + let route = if !overridden { + crate::vdisplay::apply_input_env(chosen, false) + } else { + // An operator pin deliberately leaves PUNKTFUNK_INPUT_BACKEND alone, but still needs a + // route resolved — otherwise `create` falls through to a bare spawn on a box pinned to + // the managed session. + crate::vdisplay::resolve_gamescope_route(chosen, false) + }; let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect(); match Compositor::from_pref(pref) { Some(want) if want == chosen => { @@ -149,7 +161,7 @@ pub(super) fn resolve_compositor( "auto-detected compositor (client: auto)" ), } - Ok(chosen) + Ok((chosen, route)) } } diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 884606e5..967d4b1d 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -83,6 +83,9 @@ pub(super) async fn negotiate( bool, Start, Option, + // This session's resolved gamescope sub-mode — carried to the data plane as a value rather + // than published to process env, where a concurrent connect could overwrite it. + Option, Option, )> { let peer = conn.remote_address(); @@ -225,6 +228,11 @@ pub(super) async fn negotiate( } Punktfunk1Source::Synthetic => None, }; + // Split the pair immediately: `compositor` keeps its old meaning for the Welcome and the + // cursor-forward decision, while the gamescope sub-mode travels separately to the data plane + // (SessionContext → `vd.set_gamescope_route`) instead of through process env. + let gamescope_route = compositor.as_ref().and_then(|(_, r)| r.clone()); + let compositor = compositor.map(|(c, _)| c); // A requested library launch (the client sends only the store-qualified id; we look it up // in OUR library so a client can't inject a command) is resolved below — after the Welcome, @@ -617,6 +625,14 @@ pub(super) async fn negotiate( Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?; bringup.mark("start"); Ok::<_, anyhow::Error>(( - hello, welcome, udp_port, data_sock, direct, start, compositor, prep, + hello, + welcome, + udp_port, + data_sock, + direct, + start, + compositor, + gamescope_route, + prep, )) } diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 47725a2a..2a5e237e 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -919,6 +919,10 @@ pub(super) struct SessionContext { pub(super) bitrate_rx: std::sync::mpsc::Receiver, /// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there). pub(super) compositor: crate::vdisplay::Compositor, + /// This session's resolved gamescope sub-mode, or `None` for every other backend. Carried here + /// (and on to the backend instance) rather than through `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`: + /// two sessions connecting at once used to overwrite each other's decision in the process env. + pub(super) gamescope_route: Option, /// Negotiated encoder bitrate (kbps). pub(super) bitrate_kbps: u32, /// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the @@ -1073,6 +1077,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option { + Some(cmd) if crate::vdisplay::launch_is_nested(compositor, gamescope_route.as_ref()) => { tracing::info!(command = %cmd, "launch nested into the per-session gamescope"); None } @@ -1275,7 +1285,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option Result<(Box, Pipeline)> { let mut new_vd = crate::vdisplay::open(sw.compositor)?; + // The switch re-resolved the sub-mode; give it to the NEW instance, the + // same way the initial build does. Without this the rebuilt backend would + // have no route and fall through to a bare spawn. + new_vd.set_gamescope_route(switched_route.clone()); let pipe = build_pipeline_with_retry( &mut new_vd, cur_mode, @@ -2143,7 +2157,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option