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:
@@ -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()
|
"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::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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ pub use session::{session_epoch, try_recover_session};
|
|||||||
#[path = "vdisplay/routing.rs"]
|
#[path = "vdisplay/routing.rs"]
|
||||||
pub(crate) mod routing;
|
pub(crate) mod routing;
|
||||||
pub use routing::{
|
pub use routing::{
|
||||||
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
|
apply_input_env, managed_session_available, restore_managed_session,
|
||||||
wants_dedicated_game_session,
|
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use routing::{
|
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)
|
/// 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
|
/// 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
|
/// [`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());
|
let mut took = STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if *took {
|
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;
|
*took = false;
|
||||||
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
clear_takeover(); // A3: takeover undone — drop the persisted crash-restore marker
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
@@ -1524,7 +1557,35 @@ impl Drop for GamescopeProc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn steam_launch_detection() {
|
fn steam_launch_detection() {
|
||||||
|
|||||||
@@ -207,6 +207,20 @@ pub fn cancel_pending_tv_restore() {
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn cancel_pending_tv_restore() {}
|
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
|
/// 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
|
/// 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
|
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
||||||
|
|||||||
@@ -86,10 +86,22 @@ impl NativeClient {
|
|||||||
// A typed application close from the host (pairing not armed / armed for a
|
// A typed application close from the host (pairing not armed / armed for a
|
||||||
// different device / rate-limited / version mismatch) beats the generic
|
// different device / rate-limited / version mismatch) beats the generic
|
||||||
// transport error the aborted exchange produced — it is the actual answer.
|
// 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
|
||||||
Some(r) => PunktfunkError::Rejected(r),
|
// host's CONNECTION_CLOSE a short grace to be processed before deciding
|
||||||
None => e,
|
// 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,
|
ok => ok,
|
||||||
};
|
};
|
||||||
// Always tell the host we're done so it never blocks at its read — code 0 on
|
// 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,
|
negotiated,
|
||||||
host_caps,
|
host_caps,
|
||||||
}),
|
}),
|
||||||
Err(e) => Err(match reject_from_close(&conn) {
|
Err(e) => {
|
||||||
Some(r) => PunktfunkError::Rejected(r),
|
// The host's typed close can land a beat AFTER the stream error it caused: the
|
||||||
None => e,
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub enum PunktfunkStatus {
|
|||||||
RejectedSuperseded = -26,
|
RejectedSuperseded = -26,
|
||||||
RejectedWireVersion = -27,
|
RejectedWireVersion = -27,
|
||||||
RejectedBusy = -28,
|
RejectedBusy = -28,
|
||||||
|
RejectedSetupFailed = -29,
|
||||||
Panic = -99,
|
Panic = -99,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ impl PunktfunkError {
|
|||||||
R::Superseded => PunktfunkStatus::RejectedSuperseded,
|
R::Superseded => PunktfunkStatus::RejectedSuperseded,
|
||||||
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
|
||||||
R::Busy => PunktfunkStatus::RejectedBusy,
|
R::Busy => PunktfunkStatus::RejectedBusy,
|
||||||
|
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ pub const PAIR_APPROVAL_TIMEOUT_CLOSE_CODE: u32 = 0x65;
|
|||||||
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
|
pub const PAIR_SUPERSEDED_CLOSE_CODE: u32 = 0x66;
|
||||||
/// The client's wire (protocol) version does not match the host's — one side needs updating.
|
/// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||||
pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
|
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
|
/// 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
|
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
|
||||||
@@ -59,6 +65,9 @@ pub enum RejectReason {
|
|||||||
WireVersionMismatch,
|
WireVersionMismatch,
|
||||||
/// The host refused admission because a conflicting session is live.
|
/// The host refused admission because a conflicting session is live.
|
||||||
Busy,
|
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 {
|
impl RejectReason {
|
||||||
@@ -75,6 +84,7 @@ impl RejectReason {
|
|||||||
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
|
PAIR_SUPERSEDED_CLOSE_CODE => Self::Superseded,
|
||||||
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
|
||||||
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
REJECT_BUSY_CLOSE_CODE => Self::Busy,
|
||||||
|
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,6 +101,7 @@ impl RejectReason {
|
|||||||
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
|
Self::Superseded => PAIR_SUPERSEDED_CLOSE_CODE,
|
||||||
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
|
||||||
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
Self::Busy => REJECT_BUSY_CLOSE_CODE,
|
||||||
|
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +118,7 @@ impl RejectReason {
|
|||||||
Self::Superseded => "superseded",
|
Self::Superseded => "superseded",
|
||||||
Self::WireVersionMismatch => "wire-version",
|
Self::WireVersionMismatch => "wire-version",
|
||||||
Self::Busy => "busy",
|
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::Superseded => "a newer request from this device replaced this one",
|
||||||
Self::WireVersionMismatch => "client and host versions do not match",
|
Self::WireVersionMismatch => "client and host versions do not match",
|
||||||
Self::Busy => "the host is busy with another session",
|
Self::Busy => "the host is busy with another session",
|
||||||
|
Self::SetupFailed => "the host could not start the stream session",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,10 @@ pub(crate) async fn serve(
|
|||||||
// permit's lifetime: it's released while a knock is parked for delegated approval and
|
// 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.
|
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
|
||||||
let sem_session = sem.clone();
|
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 {
|
sessions.spawn(async move {
|
||||||
match serve_session(
|
match serve_session(
|
||||||
conn,
|
conn,
|
||||||
@@ -445,7 +449,23 @@ pub(crate) async fn serve(
|
|||||||
"closed before the control handshake (reachability probe)"
|
"closed before the control handshake (reachability probe)"
|
||||||
),
|
),
|
||||||
Err(e) => {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,8 +92,24 @@ pub(super) fn resolve_compositor(
|
|||||||
let available = crate::vdisplay::available();
|
let available = crate::vdisplay::available();
|
||||||
let chosen = match pick_compositor(pref, &available, detected) {
|
let chosen = match pick_compositor(pref, &available, detected) {
|
||||||
Some(c) => c,
|
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 => {
|
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
|
// 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:
|
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||||
// its next knock lands in the recovered desktop.
|
// its next knock lands in the recovered desktop.
|
||||||
|
|||||||
@@ -977,6 +977,13 @@
|
|||||||
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||||
#define WIRE_VERSION_CLOSE_CODE 103
|
#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).
|
// Minimum supported multiplier (renders under native, upscaled on present).
|
||||||
#define MIN_SCALE 0.5
|
#define MIN_SCALE 0.5
|
||||||
|
|
||||||
@@ -1010,6 +1017,7 @@ enum PunktfunkStatus
|
|||||||
PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26,
|
PUNKTFUNK_STATUS_REJECTED_SUPERSEDED = -26,
|
||||||
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
|
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
|
||||||
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
|
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
|
||||||
|
PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29,
|
||||||
PUNKTFUNK_STATUS_PANIC = -99,
|
PUNKTFUNK_STATUS_PANIC = -99,
|
||||||
};
|
};
|
||||||
#ifndef __cplusplus
|
#ifndef __cplusplus
|
||||||
|
|||||||
Reference in New Issue
Block a user