fix(host/linux): survive and self-heal dead gamescope sessions on headless SteamOS boxes

Three fixes for the "most connects simply fail" trap on a SteamOS host
with no physical display (VM testbox, panel-less mini PC):

- restore path: skip the restore-to-physical-panel when no DRM connector
  reports a connected display — removing the headless drop-in and
  restarting gamescope-session.target on such a box just crash-loops
  gamescope and strands every later connect on "no usable compositor".
  Keep the headless session (and the takeover state) instead.
- connect path: when no live graphical session exists but the MANAGED
  gamescope infra is present (SteamOS gamescope-session / Bazzite
  session-plus), route to the gamescope takeover — which rebuilds the
  session at the client's mode — instead of failing the connect.
- error delivery: a session-setup failure used to just drop the QUIC
  connection, reaching the client as "control stream finished mid-frame"
  (indistinguishable from transport trouble). Close with a typed
  setup-failed code (0x68, RejectedSetupFailed = -29) carrying the error
  text, and give the client handshake/pairing error paths a short grace
  for the CONNECTION_CLOSE to beat the stream error it caused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:50:30 +02:00
parent 87e7c82cbc
commit 2562663fc6
11 changed files with 177 additions and 13 deletions
+5
View File
@@ -325,6 +325,11 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
"Client and host versions don't match — update both to the same release.".into()
}
R::Busy => "The host is busy with another session.".into(),
R::SetupFailed => {
"The host accepted the connection but couldn't start the stream — the host's log \
(web console → Log) has the cause."
.into()
}
}
}
+2 -2
View File
@@ -72,8 +72,8 @@ pub use session::{session_epoch, try_recover_session};
#[path = "vdisplay/routing.rs"]
pub(crate) mod routing;
pub use routing::{
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
wants_dedicated_game_session,
apply_input_env, managed_session_available, restore_managed_session,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
};
#[cfg(target_os = "linux")]
pub use routing::{
@@ -1116,6 +1116,26 @@ pub fn schedule_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
/// headless box (VM, panel-less mini PC) has none — in which case a "restore to the physical
/// panel" can only fail, gamescope having no output to drive. Errors (no DRM at all, sysfs
/// unreadable) read as headless: the safe direction is keeping the working session.
fn physical_display_connected() -> bool {
connected_connector_under(std::path::Path::new("/sys/class/drm"))
}
/// [`physical_display_connected`] against an arbitrary sysfs root (the unit-testable core).
fn connected_connector_under(base: &std::path::Path) -> bool {
let Ok(entries) = std::fs::read_dir(base) else {
return false;
};
entries.flatten().any(|e| {
std::fs::read_to_string(e.path().join("status")).is_ok_and(|s| s.trim() == "connected")
})
}
/// 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
@@ -1127,6 +1147,19 @@ fn do_restore_tv_session() {
{
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
if *took {
// A box with no physically connected display (a VM, a panel-less mini PC) has no
// "physical gaming session" to restore TO: removing the drop-in and restarting the
// target just crash-loops gamescope (no output to drive) and strands every later
// connect on "no usable compositor". Keep the headless session — and the takeover
// state, so a same-mode reconnect reuses it warm — instead. Checked at restore time
// (not connect time) so plugging a panel in later restores normally.
if !physical_display_connected() {
tracing::info!(
"gamescope (SteamOS): no physical display connected — keeping the headless \
session (nothing to restore to)"
);
return;
}
*took = false;
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
@@ -1524,7 +1557,35 @@ impl Drop for GamescopeProc {
#[cfg(test)]
mod tests {
use super::{cgroup_is_punktfunk_owned, is_steam_launch, shape_dedicated_command};
use super::{
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch,
shape_dedicated_command,
};
#[test]
fn connector_status_scan() {
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
let mk = |name: &str, status: Option<&str>| {
let dir = base.join(name);
std::fs::create_dir_all(&dir).unwrap();
if let Some(s) = status {
std::fs::write(dir.join("status"), s).unwrap();
}
};
// Headless layout: device + render nodes only (no status files) → not connected.
mk("card0", None);
mk("renderD128", None);
assert!(!connected_connector_under(&base));
// Connectors present but nothing plugged in → still not connected.
mk("card0-HDMI-A-1", Some("disconnected\n"));
assert!(!connected_connector_under(&base));
// A live panel → connected.
mk("card0-eDP-1", Some("connected\n"));
assert!(connected_connector_under(&base));
// A missing base dir (no DRM at all) reads as headless.
assert!(!connected_connector_under(&base.join("nope")));
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn steam_launch_detection() {
@@ -207,6 +207,20 @@ pub fn cancel_pending_tv_restore() {
#[cfg(not(target_os = "linux"))]
pub fn cancel_pending_tv_restore() {}
/// Can the MANAGED gamescope path stand a session up from nothing on this box (SteamOS's
/// `gamescope-session` launcher or Bazzite's `gamescope-session-plus` present)? Lets the connect
/// path route a "no live graphical session" box to the gamescope takeover — which rebuilds the
/// session at the client's mode — instead of failing the connect. Always `false` off Linux.
#[cfg(target_os = "linux")]
pub fn managed_session_available() -> bool {
gamescope::managed_session_available()
}
#[cfg(not(target_os = "linux"))]
pub fn managed_session_available() -> bool {
false
}
/// 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), **schedule** a
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
+14 -2
View File
@@ -86,10 +86,22 @@ impl NativeClient {
// A typed application close from the host (pairing not armed / armed for a
// different device / rate-limited / version mismatch) beats the generic
// transport error the aborted exchange produced — it is the actual answer.
Err(e) => Err(match reject_from_close(&conn) {
// Same close-vs-stream-error race as the connect handshake: give the
// host's CONNECTION_CLOSE a short grace to be processed before deciding
// the error was plain transport trouble.
Err(e) => {
if conn.close_reason().is_none() {
let _ = tokio::time::timeout(
std::time::Duration::from_millis(300),
conn.closed(),
)
.await;
}
Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
})
}
ok => ok,
};
// Always tell the host we're done so it never blocks at its read — code 0 on
@@ -240,9 +240,22 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
negotiated,
host_caps,
}),
Err(e) => Err(match reject_from_close(&conn) {
Err(e) => {
// The host's typed close can land a beat AFTER the stream error it caused: the
// stream reset/FIN and the CONNECTION_CLOSE are in flight together, and quinn can
// hand the reader its mid-frame EOF before it processes the close. Give the close a
// short grace to arrive so a host-side setup failure renders as its real reason
// ("the host could not start the stream session") instead of "control stream
// finished mid-frame". No-op when the connection already closed (or never will —
// bounded by the timeout).
if conn.close_reason().is_none() {
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), conn.closed())
.await;
}
Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
})
}
}
}
+2
View File
@@ -61,6 +61,7 @@ pub enum PunktfunkStatus {
RejectedSuperseded = -26,
RejectedWireVersion = -27,
RejectedBusy = -28,
RejectedSetupFailed = -29,
Panic = -99,
}
@@ -89,6 +90,7 @@ impl PunktfunkError {
R::Superseded => PunktfunkStatus::RejectedSuperseded,
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
R::Busy => PunktfunkStatus::RejectedBusy,
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
}
}
}
+13
View File
@@ -33,6 +33,12 @@ pub const PAIR_APPROVAL_TIMEOUT_CLOSE_CODE: u32 = 0x65;
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
/// The client's wire (protocol) version does not match the host's — one side needs updating.
pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
/// The host admitted the connection but could not stand the stream session up (compositor /
/// capture / encoder setup failed host-side). The close reason bytes carry the specific error
/// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
/// code, a setup failure reached the client as a bare dropped connection ("control stream
/// finished mid-frame") — indistinguishable from transport trouble.
pub const SETUP_FAILED_CLOSE_CODE: u32 = 0x68;
/// Why a host turned a connection away, decoded from the QUIC application close code — the
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
@@ -59,6 +65,9 @@ pub enum RejectReason {
WireVersionMismatch,
/// The host refused admission because a conflicting session is live.
Busy,
/// The host admitted the connection but failed to start the stream session (host-side
/// setup error — the host log has the specific cause).
SetupFailed,
}
impl RejectReason {
@@ -75,6 +84,7 @@ impl RejectReason {
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
REJECT_BUSY_CLOSE_CODE => Self::Busy,
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
_ => return None,
})
}
@@ -91,6 +101,7 @@ impl RejectReason {
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
Self::Busy => REJECT_BUSY_CLOSE_CODE,
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
}
}
@@ -107,6 +118,7 @@ impl RejectReason {
Self::Superseded => "superseded",
Self::WireVersionMismatch => "wire-version",
Self::Busy => "busy",
Self::SetupFailed => "setup-failed",
}
}
}
@@ -125,6 +137,7 @@ impl std::fmt::Display for RejectReason {
Self::Superseded => "a newer request from this device replaced this one",
Self::WireVersionMismatch => "client and host versions do not match",
Self::Busy => "the host is busy with another session",
Self::SetupFailed => "the host could not start the stream session",
})
}
}
+21 -1
View File
@@ -423,6 +423,10 @@ pub(crate) async fn serve(
// permit's lifetime: it's released while a knock is parked for delegated approval and
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
let sem_session = sem.clone();
// Kept for the error path below: `serve_session` consumes `conn`, but a setup failure
// must still close the connection with a typed reason (quinn connections are cheap
// Arc-handle clones).
let conn_err = conn.clone();
sessions.spawn(async move {
match serve_session(
conn,
@@ -445,7 +449,23 @@ pub(crate) async fn serve(
"closed before the control handshake (reachability probe)"
),
Err(e) => {
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
// Make the failure legible to the client (the [`close_rejected`] discipline,
// extended to EVERY session error): a setup failure that just drops the
// connection reaches the client as a bare close mid-control-frame ("control
// stream finished mid-frame") — indistinguishable from transport trouble.
// Close with the typed setup-failed code, carrying the error text in the
// reason bytes for client-side logs. When a gate already closed with its own
// typed code, or the peer closed first, this close is a no-op (first wins).
let detail = format!("{e:#}");
let mut cut = detail.len().min(256);
while !detail.is_char_boundary(cut) {
cut -= 1;
}
conn_err.close(
punktfunk_core::reject::SETUP_FAILED_CLOSE_CODE.into(),
detail[..cut].as_bytes(),
);
tracing::warn!(%peer, error = %detail, "session ended with error")
}
}
});
+17 -1
View File
@@ -92,8 +92,24 @@ pub(super) fn resolve_compositor(
let available = crate::vdisplay::available();
let chosen = match pick_compositor(pref, &available, detected) {
Some(c) => c,
// No live session, but the MANAGED gamescope infra exists (SteamOS's
// `gamescope-session`, Bazzite's `gamescope-session-plus`): route to the gamescope
// backend anyway — its managed path stands the session up from nothing at the
// client's mode (drop-in takeover / session relaunch), so a dead gaming session
// self-heals on the next connect instead of bouncing every client until someone
// restarts it by hand. (The trap that motivated this: a headless SteamOS box whose
// gamescope died — every connect failed "no usable compositor" even though the
// takeover could rebuild it.) Not under an operator pin: an explicit
// `PUNKTFUNK_COMPOSITOR` keeps its exact, hand-configured meaning.
None if !overridden && crate::vdisplay::managed_session_available() => {
tracing::info!(
"no live graphical session — managed gamescope infra present; routing to \
the managed takeover to revive the session"
);
Compositor::Gamescope
}
None => {
// No live session — the state a compositor crash leaves behind (gnome-shell
// The state a compositor crash leaves behind (gnome-shell
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
// configured a recovery hook, fire it (debounced) and tell the client to retry:
// its next knock lands in the recovered desktop.
+8
View File
@@ -977,6 +977,13 @@
// The client's wire (protocol) version does not match the host's — one side needs updating.
#define WIRE_VERSION_CLOSE_CODE 103
// The host admitted the connection but could not stand the stream session up (compositor /
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
// code, a setup failure reached the client as a bare dropped connection ("control stream
// finished mid-frame") — indistinguishable from transport trouble.
#define SETUP_FAILED_CLOSE_CODE 104
// Minimum supported multiplier (renders under native, upscaled on present).
#define MIN_SCALE 0.5
@@ -1010,6 +1017,7 @@ enum PunktfunkStatus
PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26,
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29,
PUNKTFUNK_STATUS_PANIC = -99,
};
#ifndef __cplusplus