diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 7adf116f..2c25b845 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -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() + } } } diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index fca354aa..499ba90a 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -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::{ diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index b8a3ec19..420409c4 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -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() { diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index 06968c07..65a21bc5 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -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 diff --git a/crates/punktfunk-core/src/client/pairing.rs b/crates/punktfunk-core/src/client/pairing.rs index 474be64c..18e3ee30 100644 --- a/crates/punktfunk-core/src/client/pairing.rs +++ b/crates/punktfunk-core/src/client/pairing.rs @@ -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) { - Some(r) => PunktfunkError::Rejected(r), - None => e, - }), + // 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 diff --git a/crates/punktfunk-core/src/client/pump/handshake.rs b/crates/punktfunk-core/src/client/pump/handshake.rs index b3d7a6e7..5ad8f08d 100644 --- a/crates/punktfunk-core/src/client/pump/handshake.rs +++ b/crates/punktfunk-core/src/client/pump/handshake.rs @@ -240,9 +240,22 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result Err(match reject_from_close(&conn) { - Some(r) => PunktfunkError::Rejected(r), - None => e, - }), + 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, + }) + } } } diff --git a/crates/punktfunk-core/src/error.rs b/crates/punktfunk-core/src/error.rs index 8bf97d6a..911d6d3b 100644 --- a/crates/punktfunk-core/src/error.rs +++ b/crates/punktfunk-core/src/error.rs @@ -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, } } } diff --git a/crates/punktfunk-core/src/reject.rs b/crates/punktfunk-core/src/reject.rs index 118e5816..8b62ac1a 100644 --- a/crates/punktfunk-core/src/reject.rs +++ b/crates/punktfunk-core/src/reject.rs @@ -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", }) } } diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index d5ec9c02..2ae31258 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -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") } } }); diff --git a/crates/punktfunk-host/src/native/compositor.rs b/crates/punktfunk-host/src/native/compositor.rs index 19e4bb03..55bc9350 100644 --- a/crates/punktfunk-host/src/native/compositor.rs +++ b/crates/punktfunk-host/src/native/compositor.rs @@ -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. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 1c55ceca..5d98f390 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -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