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:
2026-07-27 19:04:20 +02:00
parent f3615f83a5
commit 6be865f33f
4 changed files with 110 additions and 16 deletions
+1 -1
View File
@@ -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::*;
+54
View File
@@ -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);