forked from unom/punktfunk
fix(vdisplay): the gamescope sub-mode is a per-session value, not a process-global env write
Completes what the env-lock fix started. `apply_input_env` published its decision into
PUNKTFUNK_GAMESCOPE_NODE/_SESSION and `GamescopeDisplay::create` read it back out —
with the lock released in between, and with the GameStream plane, the mid-stream
switch watcher and the capture-loss rebuild all re-running the writer. So session B's
routing decision could retarget session A's `create`: A asks for a bare spawn, B
connects, A attaches to B's session instead.
The decision now travels as a `GamescopeRoute` return value and is carried on the
backend INSTANCE via `set_gamescope_route` — the discipline `set_launch_command`
already documents two lines above it ("Carried on the backend instance — NOT a
process-global env var — so concurrent sessions can't stomp each other's launch
target"). Nothing writes the two keys any more; they are read once, as operator
overrides, and `create`/`poolable_now`/`launch_is_nested` all answer from the
session's own route.
The route carries its payload rather than just the verdict, so the managed session
flavour and the attach node id stop being separate env reads too.
Threaded through both planes: handshake -> SessionContext -> `vd`, and the GameStream
`open_source` -> `run`. Three re-resolution points needed the same treatment and each
would have silently downgraded a session to a bare spawn without it — the switch
watcher's rebuilt backend, the capture-loss rebuild (which can swap the whole
instance), and the operator-pinned path, which deliberately skips the input-backend
write but still needs a route. That last one is why the ladder is split out as
`resolve_gamescope_route`: a `PUNKTFUNK_COMPOSITOR` pin must not silently mean "bare
spawn" on a box pinned to the managed session.
`#[must_use]` on both entry points found the capture-loss site, which I had missed.
Verified on 192.168.1.21: whole workspace `cargo check -p punktfunk-host`, clippy
--all-targets clean over the host and pf-vdisplay, 100 pf-vdisplay tests, and the live
GNOME create/drop still green. Windows + Linux xcheck clean on the Mac.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -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<String>) {}
|
||||
/// 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<crate::GamescopeRoute>) {}
|
||||
/// 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
|
||||
|
||||
@@ -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<crate::GamescopeRoute>,
|
||||
}
|
||||
|
||||
/// 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<crate::GamescopeRoute>) {
|
||||
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<String> {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<OperatorGamescope> = 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<String>,
|
||||
/// Likewise `PUNKTFUNK_GAMESCOPE_SESSION` — the managed session flavour.
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
#[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<GamescopeRoute> {
|
||||
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<GamescopeRoute> {
|
||||
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<GamescopeRoute> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn resolve_gamescope_route(
|
||||
_chosen: Compositor,
|
||||
_dedicated_launch: bool,
|
||||
) -> Option<GamescopeRoute> {
|
||||
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
|
||||
|
||||
@@ -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<AtomicBool>,
|
||||
) -> Result<(Box<dyn Capturer>, crate::vdisplay::Compositor)> {
|
||||
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
|
||||
c
|
||||
) -> Result<(
|
||||
Box<dyn Capturer>,
|
||||
crate::vdisplay::Compositor,
|
||||
Option<crate::vdisplay::GamescopeRoute>,
|
||||
)> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,14 +40,17 @@ fn pick_compositor(
|
||||
pub(super) fn resolve_compositor(
|
||||
pref: CompositorPref,
|
||||
dedicated_launch: bool,
|
||||
) -> Result<crate::vdisplay::Compositor> {
|
||||
) -> Result<(
|
||||
crate::vdisplay::Compositor,
|
||||
Option<crate::vdisplay::GamescopeRoute>,
|
||||
)> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ pub(super) async fn negotiate(
|
||||
bool,
|
||||
Start,
|
||||
Option<crate::vdisplay::Compositor>,
|
||||
// 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<crate::vdisplay::GamescopeRoute>,
|
||||
Option<super::stream::PrepHandle>,
|
||||
)> {
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -919,6 +919,10 @@ pub(super) struct SessionContext {
|
||||
pub(super) bitrate_rx: std::sync::mpsc::Receiver<u32>,
|
||||
/// 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<crate::vdisplay::GamescopeRoute>,
|
||||
/// 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<PreparedDispl
|
||||
rfi,
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
gamescope_route,
|
||||
mut bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
@@ -1195,6 +1200,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// session after capture is up (below).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
vd.set_launch_command(launch.clone());
|
||||
// Same per-instance discipline for the gamescope sub-mode this session resolved at
|
||||
// handshake time: it used to arrive through PUNKTFUNK_GAMESCOPE_NODE/_SESSION, which a
|
||||
// second connect (or the switch watcher below) could overwrite before this `create`.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
vd.set_gamescope_route(gamescope_route.clone());
|
||||
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5):
|
||||
// serialize setup so a reconnect FLOOD can't run concurrent monitor create/teardown,
|
||||
// STOP the prior session + WAIT for it to release its monitor (instead of tearing a
|
||||
@@ -1248,7 +1258,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let spawned_launch = match launch.as_deref() {
|
||||
Some(cmd) if crate::vdisplay::launch_is_nested(compositor) => {
|
||||
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<PreparedDispl
|
||||
// a mere disconnect only after a reconnect window (`_game_life`'s drop, below).
|
||||
let game_lease = launch_target.as_ref().map(|target| {
|
||||
#[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")]
|
||||
@@ -1583,7 +1593,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
});
|
||||
// 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);
|
||||
let switched_route = 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.
|
||||
@@ -1598,6 +1608,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
let rebuilt =
|
||||
(|| -> Result<(Box<dyn crate::vdisplay::VirtualDisplay>, 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<PreparedDispl
|
||||
#[cfg(target_os = "linux")]
|
||||
if launch.is_some()
|
||||
&& crate::session_settings::get().session_on_game_exit
|
||||
&& crate::vdisplay::launch_is_nested(compositor)
|
||||
&& crate::vdisplay::launch_is_nested(compositor, gamescope_route.as_ref())
|
||||
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
|
||||
{
|
||||
tracing::info!(
|
||||
@@ -2218,7 +2232,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
// Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
|
||||
crate::vdisplay::apply_input_env(c, false);
|
||||
let rebuilt_route = crate::vdisplay::apply_input_env(c, false);
|
||||
if c != compositor {
|
||||
if matches!(
|
||||
c,
|
||||
@@ -2257,6 +2271,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
"capture loss: opening the newly-detected compositor failed — retrying"),
|
||||
}
|
||||
}
|
||||
// The rebuild re-resolved the sub-mode; hand it to whichever backend
|
||||
// instance the rebuild will use — the freshly opened one on a
|
||||
// compositor switch, or the existing one when the backend is unchanged.
|
||||
// Skipping this would leave the rebuilt display on a bare spawn.
|
||||
vd.set_gamescope_route(rebuilt_route.clone());
|
||||
}
|
||||
}
|
||||
let _probe = (loss_at.elapsed() < PROBE_HOLDOFF)
|
||||
|
||||
Reference in New Issue
Block a user