diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 8498c776..603cfbb4 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -77,7 +77,7 @@ pub use session::{session_epoch, try_recover_session}; #[path = "vdisplay/routing.rs"] pub(crate) mod routing; pub use routing::{ - apply_input_env, managed_session_available, restore_managed_session, + apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now, restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session, }; #[cfg(target_os = "linux")] diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 0e6c6703..353f60a0 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -1686,21 +1686,7 @@ fn restore_delay() -> Option { /// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless /// box). Idempotent / safe to call on every session end. pub fn schedule_restore_tv_session() { - let nothing_to_restore = STOPPED_AUTOLOGIN - .lock() - .unwrap_or_else(|e| e.into_inner()) - .is_empty() - && !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) - && STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none() - // A managed session that took nothing over (started beside a live desktop — e.g. a client - // gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it - // was ORPHANED forever after disconnect ("closing the app does not end the session", - // field report 2026-07-24) — the restore stops it even with no autologin to bring back. - && MANAGED_SESSION - .lock() - .unwrap_or_else(|e| e.into_inner()) - .is_none(); - if nothing_to_restore { + if !takeover_live() { return; // nothing was taken over → nothing to restore (also the non-managed path) } match restore_delay() { @@ -1723,6 +1709,48 @@ pub fn schedule_restore_tv_session() { } } +/// Is anything of the box's own session ours right now — an autologin unit we stopped, a stopped +/// display manager, a SteamOS target we re-pointed, or a managed session we launched beside a live +/// desktop? The precondition for every restore path. +fn takeover_live() -> bool { + !STOPPED_AUTOLOGIN + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty() + || *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) + || STOPPED_DM + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + // A managed session that took nothing over (started beside a live desktop — e.g. a client + // gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it + // was ORPHANED forever after disconnect ("closing the app does not end the session", + // field report 2026-07-24) — the restore stops it even with no autologin to bring back. + || MANAGED_SESSION + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() +} + +/// Give the box its own session back **now**, synchronously — the host is going away (SIGTERM from +/// `systemctl --user stop`/`restart`, a package update, Ctrl-C) and a live takeover must not +/// outlive it. On a DM-flavor takeover the display manager is STOPPED: nothing else on the box +/// will ever restart it, and the persisted crash-restore state lives in `$XDG_RUNTIME_DIR`, which +/// logind removes along with the user manager — so not even the next host start can heal it. The +/// box would stay dark until someone reached a VT. +/// +/// Deliberately ignores the keep-alive policy that [`schedule_restore_tv_session`] honors: +/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host +/// that would serve them is exiting. No-op when nothing was taken over. +pub fn restore_takeover_now() { + if !takeover_live() { + return; + } + *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here + tracing::info!("gamescope: host is shutting down — restoring the box's own session first"); + do_restore_tv_session(); +} + /// Does any DRM connector report a physically `connected` display? Scans /// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a /// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index 62f0b6e5..cbfb0981 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -268,6 +268,18 @@ pub fn restore_takeover_on_startup() { #[cfg(not(target_os = "linux"))] pub fn restore_takeover_on_startup() {} +/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks +/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown +/// path — a takeover that outlives the host leaves the box with no display manager and nobody left +/// to restart it. No-op when nothing was taken over. +#[cfg(target_os = "linux")] +pub fn restore_takeover_now() { + gamescope::restore_takeover_now(); +} + +#[cfg(not(target_os = "linux"))] +pub fn restore_takeover_now() {} + #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 98599f51..39afdd41 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -350,6 +350,8 @@ pub(crate) async fn serve( // A3: recover a TV takeover stranded by a crashed previous host instance (persisted to // $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start. crate::vdisplay::restore_takeover_on_startup(); + // …and the other end of that: give the box its session back when WE are the ones going away. + install_shutdown_restore(); // Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com / // displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never // blocks on the network. A no-op on a host whose stores all carry their own art. @@ -486,6 +488,58 @@ pub(crate) async fn serve( Ok(()) } +/// How long a shutdown waits for the box's session to be handed back before exiting anyway. The +/// restore is a couple of `systemctl` calls (or one `pkexec` helper run); this only bounds a +/// genuine wedge, well inside systemd's 90 s `TimeoutStopSec`. +const SHUTDOWN_RESTORE_GRACE: std::time::Duration = std::time::Duration::from_secs(20); + +/// Hand the box's own session back on the way out. Until this existed the host had NO signal +/// handling at all: `SIGTERM` killed it outright, which is fine for a host that owns nothing — but +/// a managed gamescope takeover owns the box's session, and on a mask-fragile display manager +/// (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed +/// there, the host leaves a box with no graphical session and nothing left to restart it: the +/// crash-restore state lives in `$XDG_RUNTIME_DIR`, which logind removes along with the user +/// manager, so even the next host start can't heal it. `systemctl --user restart punktfunk-host` +/// mid-stream — or a package update doing it for you — was enough. +/// +/// So: catch `SIGTERM`/`SIGINT`, restore, then exit. Restoring runs on a blocking thread (it shells +/// out) under [`SHUTDOWN_RESTORE_GRACE`], and a host that took nothing over exits immediately. +fn install_shutdown_restore() { + #[cfg(unix)] + tokio::spawn(async { + use tokio::signal::unix::{signal, SignalKind}; + let (Ok(mut term), Ok(mut int)) = ( + signal(SignalKind::terminate()), + signal(SignalKind::interrupt()), + ) else { + tracing::warn!( + "could not install shutdown signal handlers — a host stopped mid-takeover will \ + leave the box's own session down until it is restarted" + ); + return; + }; + let sig = tokio::select! { + _ = term.recv() => "SIGTERM", + _ = int.recv() => "SIGINT", + }; + tracing::info!( + signal = sig, + "host stopping — handing the box's session back" + ); + let restore = tokio::task::spawn_blocking(crate::vdisplay::restore_takeover_now); + if tokio::time::timeout(SHUTDOWN_RESTORE_GRACE, restore) + .await + .is_err() + { + tracing::warn!( + secs = SHUTDOWN_RESTORE_GRACE.as_secs(), + "the session restore did not finish in time — exiting anyway" + ); + } + std::process::exit(0); + }); +} + /// The accept loop is sequential, so the control phase must be bounded — a client that /// connects and never finishes the handshake would otherwise wedge the host for everyone. const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);