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:
@@ -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
|
||||
// (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).
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -42,9 +42,21 @@ struct SessionState {
|
||||
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
|
||||
/// (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());
|
||||
|
||||
/// 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.
|
||||
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<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
|
||||
// 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<VirtualOutput> {
|
||||
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user