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
|
// 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).
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user