feat(host/vdisplay): per-connect active-session backend selection
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m16s
ci / bench (push) Successful in 1m34s
deb / build-publish (push) Successful in 4m32s
ci / rust (push) Successful in 7m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m26s
docker / deploy-docs (push) Successful in 18s

Bazzite/SteamOS boxes flip between Steam Gaming Mode (gamescope) and a
KDE/GNOME desktop. The host statically read PUNKTFUNK_COMPOSITOR /
XDG_CURRENT_DESKTOP once, so switching to Desktop Mode failed the stream, and
the gamescope managed-session path stopped+relaunched the autologin per connect
— leaking GPU context on F44 (reconnect → black screen).

Replace the static read with a runtime probe of the live session and route each
connect to the right backend, churn-free:

- vdisplay::detect_active_session() probes /proc for the running compositor of
  our uid (gamescope|kwin_wayland|gnome-shell|sway, desktop outranks a leftover
  gamescope) + scans the runtime dir for the live wayland-* socket. Returns an
  ActiveKind + the SessionEnv (WAYLAND_DISPLAY/XDG_RUNTIME_DIR/DBUS/
  XDG_CURRENT_DESKTOP) that targets it.
- apply_session_env() writes that into the process env per connect (host serves
  one session at a time), so every backend (capture + input) opens against the
  live session; apply_input_env() points input at the matching backend and
  selects gamescope ATTACH (no managed restart) unless PUNKTFUNK_GAMESCOPE_MANAGED.
- resolve_compositor() (native path) auto-detects + applies; explicit
  PUNKTFUNK_COMPOSITOR still wins (legacy/CI/forcing). detect() is now
  active-aware for the GameStream/mgmt callers too.
- Bazzite host.env drops the static gamescope force; documents auto-detection
  + the optional overrides.

Result: Desktop Mode → KWin/Mutter virtual output at the client's mode
(churn-free, the reliable path); Gaming Mode → attach to the running gamescope
(no SIGSEGV/GPU leak on reconnect). Compiles + clippy-clean; 78 host tests pass.
Live validation on the Bazzite box pending (box offline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:41:51 +00:00
parent 0bc60ebc44
commit 6f77574876
4 changed files with 345 additions and 17 deletions
+23 -2
View File
@@ -1499,11 +1499,32 @@ fn pick_compositor(
/// async reactor (`spawn_blocking`).
fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Compositor> {
use crate::vdisplay::Compositor;
// 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 = std::env::var_os("PUNKTFUNK_COMPOSITOR").is_some();
let detected = if overridden {
crate::vdisplay::detect().ok()
} else {
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
// every backend (video capture + input) this connect opens against the active session —
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
let active = crate::vdisplay::detect_active_session();
crate::vdisplay::apply_session_env(&active);
tracing::info!(
active = ?active.kind,
wayland = active.env.wayland_display.as_deref().unwrap_or("-"),
"detected active graphical session"
);
crate::vdisplay::compositor_for_kind(active.kind)
};
let available = crate::vdisplay::available();
let detected = crate::vdisplay::detect().ok();
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
anyhow!("no usable compositor (set PUNKTFUNK_COMPOSITOR or run inside a supported desktop)")
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
})?;
if !overridden {
// Point input at the same backend and select gamescope ATTACH (no churny managed restart).
crate::vdisplay::apply_input_env(chosen);
}
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
match Compositor::from_pref(pref) {
Some(want) if want == chosen => {
+304 -2
View File
@@ -144,7 +144,262 @@ pub fn available() -> Vec<Compositor> {
}
}
/// Detect the compositor to drive: `PUNKTFUNK_COMPOSITOR` override, else `XDG_CURRENT_DESKTOP`.
/// The kind of graphical session live for our uid *right now* — the basis for per-connect backend
/// selection on a box that flips between Steam Gaming Mode and a KDE/GNOME desktop (Bazzite,
/// SteamOS). Detected by probing which compositor process is actually running, not by a static
/// env var, so the host follows the box as the user switches sessions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ActiveKind {
/// A `gamescope` session is live (Steam Gaming Mode / `gamescope-session-plus`).
Gaming,
/// A KWin / Plasma desktop is live.
DesktopKde,
/// A GNOME / Mutter desktop is live.
DesktopGnome,
/// A wlroots (Sway / Hyprland) desktop is live.
DesktopWlroots,
/// No recognized graphical session is running for our uid.
None,
}
/// The session environment that points a backend at the [detected](detect_active_session) active
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
/// per connect and every backend that reads them then opens against the live session.
#[derive(Clone, Debug, Default)]
pub struct SessionEnv {
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
/// PipeWire-node / D-Bus driven and don't talk Wayland to us).
pub wayland_display: Option<String>,
/// `/run/user/<uid>` — the trustworthy anchor (the default PipeWire daemon + bus live here).
pub xdg_runtime_dir: String,
/// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=<runtime>/bus`).
pub dbus_session_bus_address: String,
/// `XDG_CURRENT_DESKTOP` to advertise (KDE/GNOME/sway/gamescope) — drives portal/EIS routing.
pub xdg_current_desktop: Option<String>,
}
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
pub struct ActiveSession {
pub kind: ActiveKind,
pub env: SessionEnv,
}
impl ActiveSession {
/// A "nothing live" result carrying just the runtime-dir anchor.
fn none() -> ActiveSession {
ActiveSession {
kind: ActiveKind::None,
env: SessionEnv {
xdg_runtime_dir: default_runtime_dir(),
dbus_session_bus_address: default_bus(&default_runtime_dir()),
..Default::default()
},
}
}
}
/// The concrete backend that drives a given live-session kind. `None` for [`ActiveKind::None`].
pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
match kind {
ActiveKind::Gaming => Some(Compositor::Gamescope),
ActiveKind::DesktopKde => Some(Compositor::Kwin),
ActiveKind::DesktopGnome => Some(Compositor::Mutter),
ActiveKind::DesktopWlroots => Some(Compositor::Wlroots),
ActiveKind::None => None,
}
}
#[cfg(target_os = "linux")]
fn default_runtime_dir() -> String {
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
let uid = unsafe { libc::getuid() };
format!("/run/user/{uid}")
})
}
#[cfg(not(target_os = "linux"))]
fn default_runtime_dir() -> String {
std::env::var("XDG_RUNTIME_DIR").unwrap_or_default()
}
fn default_bus(runtime: &str) -> String {
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_else(|_| format!("unix:path={runtime}/bus"))
}
/// Detect the graphical session live for our uid right now (cheap, side-effect-free: a `/proc`
/// scan plus a runtime-dir socket scan — well under the handshake timeout). The authority is the
/// running compositor process; a desktop compositor outranks a lingering gamescope. Used to route
/// each connect to the correct backend, and to derive the [`SessionEnv`] that targets it.
#[cfg(target_os = "linux")]
pub fn detect_active_session() -> ActiveSession {
use std::os::unix::fs::MetadataExt;
let uid = unsafe { libc::getuid() };
let xdg_runtime_dir = default_runtime_dir();
let dbus = default_bus(&xdg_runtime_dir);
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
let mut kind = ActiveKind::None;
let mut best = 0u8;
if let Ok(entries) = std::fs::read_dir("/proc") {
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let pid_path = e.path();
let Ok(md) = std::fs::metadata(&pid_path) else {
continue;
};
if md.uid() != uid {
continue;
}
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
continue;
};
let (k, prio) = match comm.trim() {
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
"sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4),
_ => continue,
};
if prio > best {
best = prio;
kind = k;
}
}
}
// Wayland-protocol backends (KWin, wlroots) need the live socket; Gaming-attach and Mutter are
// node/D-Bus driven and don't.
let wayland_display = match kind {
ActiveKind::DesktopKde | ActiveKind::DesktopWlroots => {
find_wayland_socket(&xdg_runtime_dir, uid)
}
_ => None,
};
let xdg_current_desktop = match kind {
ActiveKind::DesktopKde => Some("KDE".to_string()),
ActiveKind::DesktopGnome => Some("GNOME".to_string()),
ActiveKind::DesktopWlroots => Some("sway".to_string()),
ActiveKind::Gaming => Some("gamescope".to_string()),
ActiveKind::None => None,
};
ActiveSession {
kind,
env: SessionEnv {
wayland_display,
xdg_runtime_dir,
dbus_session_bus_address: dbus,
xdg_current_desktop,
},
}
}
#[cfg(not(target_os = "linux"))]
pub fn detect_active_session() -> ActiveSession {
ActiveSession::none()
}
/// Find the live `wayland-*` socket in `runtime` for our uid (skipping `.lock` sidecars). Trust a
/// valid inherited `WAYLAND_DISPLAY` first; otherwise take the newest-mtime socket we own (a
/// desktop session normally exposes exactly one).
#[cfg(target_os = "linux")]
fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
use std::os::unix::fs::MetadataExt;
if let Ok(w) = std::env::var("WAYLAND_DISPLAY") {
if !w.is_empty() {
let p = if w.starts_with('/') {
std::path::PathBuf::from(&w)
} else {
std::path::Path::new(runtime).join(&w)
};
if p.exists() {
return Some(w);
}
}
}
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(runtime).ok()?.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with("wayland-") || name.ends_with(".lock") {
continue;
}
let Ok(md) = e.metadata() else { continue };
if md.uid() != uid {
continue;
}
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
cands.push((mtime, name));
}
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
cands.into_iter().next().map(|(_, n)| n)
}
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. The host serves one session at a
/// time, so a process-global write is sound; the next connect re-detects and re-applies. Same
/// `set_var` discipline already used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
#[cfg(target_os = "linux")]
pub fn apply_session_env(active: &ActiveSession) {
let e = &active.env;
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
if let Some(w) = &e.wayland_display {
std::env::set_var("WAYLAND_DISPLAY", w);
}
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Mutter on NVIDIA has no working dmabuf capture sync — force SHM there; the KWin/gamescope
// tiled/LINEAR paths keep zero-copy.
if active.kind == ActiveKind::DesktopGnome {
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
}
}
#[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope, also select **attach** (no
/// churny host-managed restart) unless the operator explicitly opted into the managed session with
/// `PUNKTFUNK_GAMESCOPE_MANAGED` — attaching to the running session avoids the per-connect
/// stop/relaunch that leaked GPU context (the reconnect-black-screen on Bazzite F44).
#[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) {
let backend = match chosen {
Compositor::Gamescope => "gamescope",
Compositor::Kwin | Compositor::Mutter => "libei",
Compositor::Wlroots => "wlr",
};
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope {
// Managed = the operator opted in (new `PUNKTFUNK_GAMESCOPE_MANAGED`, or legacy
// `PUNKTFUNK_GAMESCOPE_SESSION` set explicitly). Otherwise ATTACH to the running session.
let managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some()
|| std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some();
if 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");
} else {
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor) {}
/// 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.
pub fn detect() -> Result<Compositor> {
if let Ok(v) = std::env::var("PUNKTFUNK_COMPOSITOR") {
return match v.trim().to_ascii_lowercase().as_str() {
@@ -159,6 +414,10 @@ pub fn detect() -> Result<Compositor> {
}
};
}
#[cfg(target_os = "linux")]
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
}
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_uppercase();
@@ -173,7 +432,8 @@ pub fn detect() -> Result<Compositor> {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor from XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
}
@@ -245,3 +505,45 @@ mod kwin;
mod mutter;
#[cfg(target_os = "linux")]
mod wlroots;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn active_kind_maps_to_its_backend() {
assert_eq!(
compositor_for_kind(ActiveKind::Gaming),
Some(Compositor::Gamescope)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopKde),
Some(Compositor::Kwin)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopGnome),
Some(Compositor::Mutter)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopWlroots),
Some(Compositor::Wlroots)
);
// No live session → no backend; the caller turns this into a handshake error / fallback.
assert_eq!(compositor_for_kind(ActiveKind::None), None);
}
#[test]
fn detect_active_session_is_side_effect_free_and_terminates() {
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
// any box (CI has no graphical session → ActiveKind::None, with the runtime-dir anchor).
let a = detect_active_session();
assert!(!a.env.xdg_runtime_dir.is_empty());
// Wayland sockets are only resolved for the Wayland-protocol desktops.
if matches!(
a.kind,
ActiveKind::Gaming | ActiveKind::DesktopGnome | ActiveKind::None
) {
assert!(a.env.wayland_display.is_none());
}
}
}
+12 -13
View File
@@ -1,26 +1,25 @@
# punktfunk host config for Bazzite (~/.config/punktfunk/host.env).
#
# Bazzite ships gamescope, PipeWire and the NVIDIA driver, so the default backend here is
# gamescope: the host spawns a headless gamescope per session at the client's exact mode and
# captures its PipeWire node — no separate desktop session to bring up. Set PUNKTFUNK_GAMESCOPE_APP
# to what you want to run inside it (e.g. `steam -gamepadui` for a SteamOS-like couch session).
# The compositor + input backend are AUTO-DETECTED per connect from the ACTIVE session: the host
# follows the box as you flip between Steam Gaming Mode (gamescope — attached to the running
# session, no churn) and a KDE/GNOME Desktop (KWin/Mutter virtual output at the client's mode).
# So nothing here forces a backend — only the trustworthy anchors stay.
XDG_RUNTIME_DIR=/run/user/1000
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
# gamescope backend: spawned per session, no compositor login required.
PUNKTFUNK_COMPOSITOR=gamescope
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui
# gamescope hosts its own EIS input socket — input lands in the nested session.
PUNKTFUNK_INPUT_BACKEND=gamescope
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC). Auto-falls back to CPU if unavailable.
PUNKTFUNK_ZEROCOPY=1
#RUST_LOG=info
# To drive the full Plasma/GNOME desktop instead of a nested gamescope, switch to:
# PUNKTFUNK_COMPOSITOR=kwin (and run inside a KDE session — WAYLAND_DISPLAY/XDG_CURRENT_DESKTOP set)
# PUNKTFUNK_INPUT_BACKEND=libei
# --- Optional overrides (default is active-session auto-detection) ---
# Force a specific backend for testing (skips auto-detect + env retargeting):
# PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope
# PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput
# Opt into the host-MANAGED gamescope session (spawns gamescope-session-plus at the client mode,
# stops the autologin gaming session for the duration) instead of attaching to the running one:
# PUNKTFUNK_GAMESCOPE_MANAGED=1
# PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui
+6
View File
@@ -1,4 +1,10 @@
# punktfunk host configuration (~/.config/punktfunk/host.env) — consumed by punktfunk-host.service.
#
# The compositor + input backend are AUTO-DETECTED per connect from the live session (the host
# probes which compositor is actually running and retargets WAYLAND_DISPLAY/XDG_CURRENT_DESKTOP/
# DBUS at it), so a box that flips between Steam Gaming Mode and a KDE/GNOME desktop is followed
# automatically. The blocks below are OPTIONAL OVERRIDES — uncomment one only to force a backend
# (this also skips the per-connect env retargeting). The anchors XDG_RUNTIME_DIR + DBUS stay.
# Session / compositor environment (headless KWin example).
XDG_RUNTIME_DIR=/run/user/1000