fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it outright. That 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 not even the next host start can heal it. `systemctl --user restart punktfunk-host` mid-stream — or a package update doing it for you — was enough to strand a box until someone reached a VT. So SIGTERM and SIGINT now restore first and exit after. The restore runs on a blocking thread (it shells out) under a 20s grace, well inside systemd's TimeoutStopSec, and a host that took nothing over exits immediately. `restore_takeover_now` is the synchronous sibling of the debounced disconnect restore, and deliberately ignores the keep-alive policy the latter honors: `keep_alive=forever` pins a session for the NEXT client, which means nothing once the host that would serve them is exiting. Both paths now share one `takeover_live()` predicate instead of repeating the four-way "is anything ours" test. Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests, rustfmt.
This commit is contained in:
@@ -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")]
|
||||
|
||||
@@ -1686,21 +1686,7 @@ fn restore_delay() -> Option<Duration> {
|
||||
/// 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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user