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
+35 -15
View File
@@ -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")]