diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 74f48ea..74c6510 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -203,6 +203,10 @@ pub(crate) async fn serve(opts: M3Options, np: Arc) -> Result<()> // 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. 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 // 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). diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index af2bf54..d512c48 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -366,10 +366,13 @@ 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 -/// `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). +/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope, the **default is a managed +/// session at the client's mode** (tears the TV's autologin down on connect; restored on a debounced +/// idle) — so the client gets ITS resolution (capture == encode == client mode), not the TV's, and a +/// 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")] pub fn apply_input_env(chosen: Compositor) { let backend = match chosen { @@ -379,18 +382,20 @@ pub fn apply_input_env(chosen: Compositor) { }; 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 { + let force_managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(); + let attach = !force_managed + && (std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some() + || std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some()); + 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() { 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"); } } } @@ -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 -/// gaming session (stopped its single-instance Steam to stream at the client's mode), restart that -/// session so the TV returns to gaming mode. No-op on other compositors / when nothing was taken. +/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a +/// 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")] pub fn restore_managed_session() { - gamescope::restore_tv_session(); + gamescope::schedule_restore_tv_session(); } #[cfg(not(target_os = "linux"))] 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")] mod gamescope; #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/vdisplay/gamescope.rs b/crates/punktfunk-host/src/vdisplay/gamescope.rs index 660ccd1..c82819d 100644 --- a/crates/punktfunk-host/src/vdisplay/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/gamescope.rs @@ -42,9 +42,21 @@ struct SessionState { static MANAGED_SESSION: std::sync::Mutex> = std::sync::Mutex::new(None); /// 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> = 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> = 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. const SESSION_UNIT: &str = "punktfunk-gamescope"; /// 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 /// mode live). Then discover the node + point the injector, exactly as the attach path does. fn create_managed_session(client: &str, mode: Mode) -> Result { + // 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 // 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 - // 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(); let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()); let same_mode = guard.as_ref().is_some_and(|s| { @@ -180,7 +195,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result { /// 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 -/// [`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 /// is autologged in (e.g. a box that boots headless). 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 -/// autologin gaming session(s) we stopped on connect — so the TV returns to gaming mode when no one -/// is streaming. Idempotent / safe to call on every session end (no-op when we stopped nothing, -/// e.g. a non-gamescope or headless box). The brief Steam restart is the cost of "TV by default, -/// client mode while streaming" given Steam's single-instance limit. -pub fn restore_tv_session() { +/// Client disconnected: **schedule** a debounced restore of the TV's autologin gaming session(s) we +/// stopped on connect — the actual restore fires [`RESTORE_DEBOUNCE`] later (via [`start_restore_worker`]) +/// unless a client reconnects first, which cancels it and reuses the warm managed session. Debouncing +/// means at most one gamescope stop/relaunch per quiet period instead of one per disconnect — the +/// per-connect churn is what leaked GPU context on F44. No-op when nothing was stolen (non-Bazzite / +/// headless box). Idempotent / safe to call on every session end. +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())); if units.is_empty() { return; // nothing was stolen → nothing to restore (also the non-Bazzite path) @@ -236,11 +271,43 @@ pub fn restore_tv_session() { .status(); tracing::info!( 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 /// [`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. diff --git a/packaging/bazzite/host.env b/packaging/bazzite/host.env index 133efa2..9f6b70c 100644 --- a/packaging/bazzite/host.env +++ b/packaging/bazzite/host.env @@ -19,7 +19,10 @@ PUNKTFUNK_ZEROCOPY=1 # 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 +# +# In Gaming Mode the host MANAGES a gamescope-session-plus at the CLIENT's resolution by default +# (tears the TV's autologin down on connect; restores it on a debounced idle, reused on a quick +# 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