feat(host/gamescope): managed-default Gaming with debounced TV-restore

Feature A: in Gaming Mode, default to a host-managed gamescope at the CLIENT's
mode (tear the TV's autologin down on connect) instead of attaching to the
running TV session — so the client receives ITS resolution (capture == encode ==
client mode, fixing the InitializeEncoder size mismatch the attach path hit),
not the TV's 4K.

Reliability is the debounce: restore_managed_session() now SCHEDULES the TV
restore RESTORE_DEBOUNCE (5s) after the last disconnect via a host-lifetime
worker, instead of restoring immediately per-disconnect. A reconnect inside the
window cancels the pending restore and reuses the still-warm managed session
(create_managed_session clears PENDING_RESTORE at the top) — so a quick reconnect
(e.g. a controller hiccup) never triggers a gamescope stop/relaunch, which is the
per-connect churn that leaked NVIDIA GPU context on F44 (the black-screen
reconnect).

- vdisplay/gamescope.rs: PENDING_RESTORE + RESTORE_DEBOUNCE; schedule_restore_tv_session
  (debounced), do_restore_tv_session (the actual restore, worker-driven),
  start_restore_worker (100ms tick, RAII keepalive handle). create_managed_session
  cancels the pending restore + reuse path unchanged.
- vdisplay.rs: apply_input_env flips gamescope to managed-DEFAULT; PUNKTFUNK_GAMESCOPE_ATTACH
  (or an explicit _NODE) opts back to attach for couch-on-TV; _MANAGED forces managed.
  restore_managed_session schedules; new start_restore_worker wrapper.
- m3.rs serve(): hold the restore worker for the host lifetime.
- bazzite host.env: document managed-default + the ATTACH opt-out.

Compiles, clippy-clean, 78 host tests pass. F44 single stop/start leak to be
verified live on the box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 22:34:33 +00:00
parent 66c2bee183
commit c25706b355
4 changed files with 123 additions and 29 deletions
+4
View File
@@ -203,6 +203,10 @@ pub(crate) async fn serve(opts: M3Options, np: Arc<NativePairing>) -> Result<()>
// One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink // One virtual microphone for the whole host lifetime (see MicService): the client's mic uplink
// (0xCB) is Opus-decoded and fed into a persistent PipeWire Audio/Source host apps record from. // (0xCB) is Opus-decoded and fed into a persistent PipeWire Audio/Source host apps record from.
let mic_service = MicService::start(); let mic_service = MicService::start();
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
// 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();
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed // Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the // at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
// web console arms it on demand instead (a fresh, time-limited PIN). // web console arms it on demand instead (a fresh, time-limited PIN).
+35 -15
View File
@@ -366,10 +366,13 @@ pub fn apply_session_env(active: &ActiveSession) {
pub fn apply_session_env(_active: &ActiveSession) {} pub fn apply_session_env(_active: &ActiveSession) {}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority /// 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 /// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope, the **default is a managed
/// churny host-managed restart) unless the operator explicitly opted into the managed session with /// session at the client's mode** (tears the TV's autologin down on connect; restored on a debounced
/// `PUNKTFUNK_GAMESCOPE_MANAGED` — attaching to the running session avoids the per-connect /// idle) — so the client gets ITS resolution (capture == encode == client mode), not the TV's, and a
/// stop/relaunch that leaked GPU context (the reconnect-black-screen on Bazzite F44). /// quick reconnect reuses the warm session (no churn). Opt out to **attach** (mirror the running TV
/// session at its own mode, gaming stays live on the panel, no Steam restart) with
/// `PUNKTFUNK_GAMESCOPE_ATTACH`; an explicit `PUNKTFUNK_GAMESCOPE_NODE` also implies attach, and
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) { pub fn apply_input_env(chosen: Compositor) {
let backend = match chosen { let backend = match chosen {
@@ -379,18 +382,20 @@ pub fn apply_input_env(chosen: Compositor) {
}; };
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend); std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope { if chosen == Compositor::Gamescope {
// Managed = the operator opted in (new `PUNKTFUNK_GAMESCOPE_MANAGED`, or legacy let force_managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some();
// `PUNKTFUNK_GAMESCOPE_SESSION` set explicitly). Otherwise ATTACH to the running session. let attach = !force_managed
let managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some() && (std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some()
|| std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(); || std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some());
if managed { if 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");
}
} else {
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() { if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam"); std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
} }
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"); std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
} else {
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
} }
} }
} }
@@ -488,15 +493,30 @@ pub fn gamescope_ei_socket_file() -> &'static str {
} }
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin /// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
/// gaming session (stopped its single-instance Steam to stream at the client's mode), restart that /// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
/// session so the TV returns to gaming mode. No-op on other compositors / when nothing was taken. /// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
/// (which reuses the warm session, avoiding the per-connect gamescope stop/relaunch that leaked GPU
/// context on F44). No-op on other compositors / when nothing was taken. Needs [`start_restore_worker`]
/// running to actually fire.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn restore_managed_session() { pub fn restore_managed_session() {
gamescope::restore_tv_session(); gamescope::schedule_restore_tv_session();
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn restore_managed_session() {} pub fn restore_managed_session() {}
/// Start the host-lifetime worker that fires debounced [`restore_managed_session`] restores once a
/// client has been gone long enough. Hold the returned handle for the host's lifetime; dropping it
/// stops the worker. Call once from `serve()`.
#[cfg(target_os = "linux")]
pub fn start_restore_worker() -> std::sync::Arc<()> {
gamescope::start_restore_worker()
}
#[cfg(not(target_os = "linux"))]
pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(())
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod gamescope; mod gamescope;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+77 -10
View File
@@ -42,9 +42,21 @@ struct SessionState {
static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mutex::new(None); static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mutex::new(None);
/// Autologin gaming-mode `gamescope-session-plus@*` units we stopped on connect to free Steam /// Autologin gaming-mode `gamescope-session-plus@*` units we stopped on connect to free Steam
/// (single-instance), so [`restore_tv_session`] can restart them when the client disconnects. /// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new()); static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
/// what leaked NVIDIA GPU context on F44 (the black-screen reconnect). Driven by the host-lifetime
/// [`start_restore_worker`] thread.
static PENDING_RESTORE: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
/// How long to wait after the last disconnect before restoring the TV's autologin gaming session —
/// long enough that a quick reconnect (e.g. a controller hiccup) reuses the warm managed session
/// instead of triggering a stop/relaunch.
const RESTORE_DEBOUNCE: Duration = Duration::from_secs(5);
/// systemd --user transient unit name for the host-managed gamescope-session-plus session. /// systemd --user transient unit name for the host-managed gamescope-session-plus session.
const SESSION_UNIT: &str = "punktfunk-gamescope"; const SESSION_UNIT: &str = "punktfunk-gamescope";
/// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts). /// The gamescope-session-plus launcher script (Bazzite / SteamOS-like hosts).
@@ -125,10 +137,13 @@ impl VirtualDisplay for GamescopeDisplay {
/// otherwise stop the old transient unit and RELAUNCH at the new mode (gamescope can't change output /// otherwise stop the old transient unit and RELAUNCH at the new mode (gamescope can't change output
/// mode live). Then discover the node + point the injector, exactly as the attach path does. /// mode live). Then discover the node + point the injector, exactly as the attach path does.
fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> { fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
// A (re)connect cancels any pending debounced TV-restore: we're about to (re)use the managed
// session, so the autologin must stay stopped and the warm session stays up (no stop/relaunch).
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the // Steam is single-instance: if the box autologged into gaming mode on a physical display (the
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and // Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
// stopping it; [`restore_tv_session`] (called when the client disconnects) brings it back. // stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
stop_autologin_sessions(); stop_autologin_sessions();
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()); let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| { let same_mode = guard.as_ref().is_some_and(|s| {
@@ -180,7 +195,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its /// 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 /// single-instance Steam is free for our own host-managed session. Records the units so
/// [`restore_tv_session`] can restart them on disconnect. Our own session is the transient /// [`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 /// `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).
fn stop_autologin_sessions() { fn stop_autologin_sessions() {
@@ -218,12 +233,32 @@ fn stop_autologin_sessions() {
} }
} }
/// Client disconnected: tear down our host-managed session (freeing Steam) and restart the /// Client disconnected: **schedule** a debounced restore of the TV's autologin gaming session(s) we
/// autologin gaming session(s) we stopped on connect — so the TV returns to gaming mode when no one /// stopped on connect — the actual restore fires [`RESTORE_DEBOUNCE`] later (via [`start_restore_worker`])
/// is streaming. Idempotent / safe to call on every session end (no-op when we stopped nothing, /// unless a client reconnects first, which cancels it and reuses the warm managed session. Debouncing
/// e.g. a non-gamescope or headless box). The brief Steam restart is the cost of "TV by default, /// means at most one gamescope stop/relaunch per quiet period instead of one per disconnect — the
/// client mode while streaming" given Steam's single-instance limit. /// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite /
pub fn restore_tv_session() { /// headless box). Idempotent / safe to call on every session end.
pub fn schedule_restore_tv_session() {
if STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
{
return; // nothing was stolen → nothing to restore (also the non-Bazzite 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)"
);
}
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
/// cancelled+reconnected window keeps the list for a later real restore.
fn do_restore_tv_session() {
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner())); let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
if units.is_empty() { if units.is_empty() {
return; // nothing was stolen → nothing to restore (also the non-Bazzite path) return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
@@ -236,11 +271,43 @@ pub fn restore_tv_session() {
.status(); .status();
tracing::info!( tracing::info!(
unit, unit,
"restored the TV's autologin gaming session (client gone)" "restored the TV's autologin gaming session (debounce elapsed, no client)"
); );
} }
} }
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
/// deadline passes. Returns a keepalive handle — drop it (host shutdown) to stop the worker. Cheap:
/// a 100 ms tick that does nothing until a restore is actually pending.
pub fn start_restore_worker() -> std::sync::Arc<()> {
let handle = std::sync::Arc::new(());
let weak = std::sync::Arc::downgrade(&handle);
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-restore-worker".into())
.spawn(move || {
while weak.upgrade().is_some() {
std::thread::sleep(Duration::from_millis(100));
let due = {
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
match *g {
Some(deadline) if Instant::now() >= deadline => {
*g = None;
true
}
_ => false,
}
};
if due {
do_restore_tv_session();
}
}
})
{
tracing::error!(error = %e, "restore-worker spawn failed — TV session won't auto-restore on idle");
}
handle
}
/// Point the libei injector at the running gamescope's EIS socket (it reads the relay file /// Point the libei injector at the running gamescope's EIS socket (it reads the relay file
/// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the /// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the
/// session). Shared by the attach and host-managed-session paths. /// session). Shared by the attach and host-managed-session paths.
+7 -4
View File
@@ -19,7 +19,10 @@ PUNKTFUNK_ZEROCOPY=1
# Force a specific backend for testing (skips auto-detect + env retargeting): # Force a specific backend for testing (skips auto-detect + env retargeting):
# PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope # PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope
# PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput # 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: # In Gaming Mode the host MANAGES a gamescope-session-plus at the CLIENT's resolution by default
# PUNKTFUNK_GAMESCOPE_MANAGED=1 # (tears the TV's autologin down on connect; restores it on a debounced idle, reused on a quick
# PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui # reconnect). To instead ATTACH to the running TV session at its own mode (couch-on-TV — gaming
# stays live on the panel, no Steam restart), set:
# PUNKTFUNK_GAMESCOPE_ATTACH=1
# PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui # only for an ad-hoc bare-spawn fallback