diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 25742f4d..428cd854 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -18,6 +18,11 @@ parse_deps = false # undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs # `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module). exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] +# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the +# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately, +# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave +# the header documenting names it never defines. +include = ["PunktfunkEndReason"] [export.rename] "InputEvent" = "PunktfunkInputEvent" diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index f894e340..686b6c38 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,24 +2273,27 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } -/// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 -/// when it did and 0 otherwise; the return status reports only whether the handle was usable. +/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. /// -/// A refinement of "the session ended", never a substitute — read it only once a plane has -/// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and -/// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). -/// It latches, so it is still readable while the connection is being torn down, and a client that -/// never calls it behaves exactly as it did before this existed. +/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +/// while the connection is torn down, and a client that never calls it behaves exactly as it did +/// before this existed. /// -/// The point is that a game ending is a normal finish, not a failure: a launcher client can send -/// the player back to the host's library — one tap from the next title — rather than reporting an -/// error and dropping to host selection for something the player just did on purpose. +/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +/// their game from a host falling off the network, so every client wrote one message for all of +/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +/// for `HOST_ERROR` and `LOST`. +/// +/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. /// /// # Safety /// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. #[cfg(feature = "quic")] #[no_mangle] -pub unsafe extern "C" fn punktfunk_connection_game_exited( +pub unsafe extern "C" fn punktfunk_connection_end_reason( c: *mut PunktfunkConnection, out: *mut u8, ) -> PunktfunkStatus { @@ -2303,7 +2306,7 @@ pub unsafe extern "C" fn punktfunk_connection_game_exited( }; if !out.is_null() { // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`. - unsafe { *out = u8::from(c.inner.ended_because_game_exited()) }; + unsafe { *out = c.inner.end_reason() as u8 }; } PunktfunkStatus::Ok }) diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 1db75644..865f6b3f 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -110,6 +110,91 @@ pub struct MicUplinkStats { /// the control task is wedged, which callers treat as a closed session. const CTRL_QUEUE: usize = 32; +/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +/// C surface. +/// +/// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +/// player quitting their game and a host falling off the network both arrive as "the session +/// ended", and a client with no way to separate them has to word all of them the same. Every client +/// worded them as failures. +/// +/// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +/// of the C ABI: append only, never renumber. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PunktfunkEndReason { + /// Not ended (or ended before a reason could be observed). Also what an unknown future value + /// decodes to, so an older client reading a newer core degrades to "no opinion". + None = 0, + /// **This client** closed the session — the user pressed stop, or the handle was dropped. + /// Nothing to report: the UI already knows, it initiated it. + Local = 1, + /// The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + /// and the one reason a launcher client can act on: go back to the library the title was + /// launched from rather than all the way out to host selection. + GameExited = 2, + /// The host ended the session cleanly and deliberately — an operator "End" in the console, or + /// the session simply finishing. Normal; say so plainly or say nothing. + HostEnded = 3, + /// The host closed reporting a failure of its own. Worth showing, and the host's log has the + /// detail. + HostError = 4, + /// The connection died rather than being closed: idle timeout, reset, the network going away. + /// This — and only this — is the "the host may be asleep, wake it" case. + Lost = 5, +} + +impl PunktfunkEndReason { + /// Decode the wire/ABI byte. Unknown values become [`Self::None`] rather than panicking: this + /// crosses an ABI where the writer may be newer than the reader. + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Local, + 2 => Self::GameExited, + 3 => Self::HostEnded, + 4 => Self::HostError, + 5 => Self::Lost, + _ => Self::None, + } + } + + /// Whether this ending is an ordinary outcome rather than something to alarm the user about. + /// + /// The single question nearly every client actually asks. `Local`, `GameExited` and `HostEnded` + /// are all things that were *meant* to happen; only a host-side failure or a dead connection + /// are not. [`Self::None`] counts as normal — no evidence of trouble is not evidence of it. + pub fn is_normal(self) -> bool { + !matches!(self, Self::HostError | Self::Lost) + } +} + +#[cfg(feature = "quic")] +impl From<&quinn::ConnectionError> for PunktfunkEndReason { + /// Classify the QUIC close. + /// + /// Only two application codes ever arrive from a host at session end: `APP_EXITED` when the + /// game it launched quit, and the teardown's own `0` (clean) / `1` (the session returned an + /// error) from `native.rs`. Anything else with an application code is a deliberate host-side + /// close we do not have a name for, which is still closer to "the host ended it" than to a + /// dead link — but a code we have never issued is more likely a fault than a courtesy, so it + /// lands in `HostError` where it will at least be visible. + fn from(e: &quinn::ConnectionError) -> Self { + match e { + quinn::ConnectionError::LocallyClosed => Self::Local, + quinn::ConnectionError::ApplicationClosed(ac) => { + match u32::try_from(u64::from(ac.error_code)) { + Ok(crate::quic::APP_EXITED_CLOSE_CODE) => Self::GameExited, + Ok(0) => Self::HostEnded, + _ => Self::HostError, + } + } + // TimedOut, Reset, VersionMismatch, TransportError, CidsExhausted, and the peer's + // transport-level close: the link failed, nobody said goodbye. + _ => Self::Lost, + } + } +} + pub struct NativeClient { // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust // embedders can share one `Arc` across their plane threads (the same @@ -180,9 +265,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, - /// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] — - /// see [`NativeClient::ended_because_game_exited`]. - game_exited: Arc, + /// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see + /// [`NativeClient::end_reason`]. + end_reason: Arc, /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. @@ -451,7 +536,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); - let game_exited = Arc::new(AtomicBool::new(false)); + let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8)); let quit = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let probe = Arc::new(Mutex::new(ProbeState::default())); @@ -467,7 +552,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.clone(); - let game_exited_w = game_exited.clone(); + let end_reason_w = end_reason.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -543,7 +628,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, - game_exited: game_exited_w, + end_reason: end_reason_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -597,7 +682,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, - game_exited, + end_reason, quit, worker: Some(worker), frames_dropped, @@ -816,22 +901,27 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } - /// Whether the session ended because **the game the host launched for it exited** — the host - /// closed with [`crate::quic::APP_EXITED_CLOSE_CODE`] rather than dropping out. + /// WHY the session ended — see [`PunktfunkEndReason`]. /// - /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it is only - /// ever true once that is, and false covers every other ending (user stop, host gone, network - /// loss, idle timeout) — so a client that ignores it behaves exactly as before. + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it stays + /// [`PunktfunkEndReason::None`] until that is true, and every client that ignores it behaves + /// exactly as it did before this existed. /// - /// What it is FOR: a game ending is a normal, expected finish, not a failure. A launcher client - /// can read this and go back to the host's library — where the player is one tap from the next - /// title — instead of showing "session ended by " and dropping to host selection, which - /// reads as an error for something the player just did on purpose. + /// What it is FOR: **most endings are not failures.** A client that cannot tell them apart has + /// to pick one wording for all of them, and every such client picked an error — "Session ended + /// by ", "Connection lost — the host may be asleep" — including when the player quit the + /// game themselves. This is the discriminator that lets each client stay quiet for a normal + /// finish, return to its library when a launched game exits, and reserve the alarming copy for + /// an ending that actually deserves it. /// - /// Poll it after the session ends (a `Closed` on any plane, or `is_session_ended`); it latches, - /// so it is still readable while the connection is being torn down. + /// Latches, so it is still readable while the connection is being torn down. + pub fn end_reason(&self) -> PunktfunkEndReason { + PunktfunkEndReason::from_u8(self.end_reason.load(Ordering::SeqCst)) + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. pub fn ended_because_game_exited(&self) -> bool { - self.game_exited.load(Ordering::SeqCst) + self.end_reason() == PunktfunkEndReason::GameExited } /// Register the calling thread as latency-critical so a later diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index 08694306..8308f2e2 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,7 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, - game_exited, + end_reason, quit, mode_slot, probe, @@ -195,22 +195,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, )); - // Watch for connection close → stop the pump, and record WHY if the host said so. + // Watch for connection close → stop the pump, and classify WHY. { let shutdown = shutdown.clone(); - let game_exited = game_exited.clone(); + let end_reason = end_reason.clone(); let conn = conn.clone(); tokio::spawn(async move { let why = conn.closed().await; - // The host closes with APP_EXITED when the game it launched for this session exited. - // Latch that before `shutdown`, so any client that reacts to the shutdown flag can - // already read the reason — the two are observed by different threads. - if let quinn::ConnectionError::ApplicationClosed(ac) = &why { - if u32::try_from(u64::from(ac.error_code)) == Ok(crate::quic::APP_EXITED_CLOSE_CODE) - { - game_exited.store(true, Ordering::SeqCst); - } - } + // Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a + // client that reacts to the shutdown flag must never find the reason still unset. + let reason = crate::client::PunktfunkEndReason::from(&why); + end_reason.store(reason as u8, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index d8029e75..0b8e0fa5 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,10 +68,9 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, - /// Set alongside `shutdown` when the HOST's close carried - /// [`crate::quic::APP_EXITED_CLOSE_CODE`] — the launched game exited (see - /// [`NativeClient::ended_because_game_exited`]). - pub(crate) game_exited: Arc, + /// A [`crate::client::PunktfunkEndReason`] as `u8`, classified from the connection's close and + /// latched alongside `shutdown` (see [`NativeClient::end_reason`]). + pub(crate) end_reason: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 1c8530b9..60b559dd 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,12 +138,13 @@ pub use stats::Stats; /// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never /// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and /// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -/// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it -/// ended because the game the host launched for it EXITED (the host's close carried -/// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing -/// consumed it). Purely a read of state the core already had: no new call is required of an -/// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes -/// either way, so [`WIRE_VERSION`] is unchanged. +/// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +/// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +/// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +/// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +/// connection was simply lost. Purely a read of state the core already had: no new call is required +/// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +/// bytes either way, so [`WIRE_VERSION`] is unchanged. pub const ABI_VERSION: u32 = 17; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index ce197656..9bf9d924 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,12 +76,13 @@ // capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never // receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and // arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it -// ended because the game the host launched for it EXITED (the host's close carried -// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing -// consumed it). Purely a read of state the core already had: no new call is required of an -// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes -// either way, so [`WIRE_VERSION`] is unchanged. +// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +// connection was simply lost. Purely a read of state the core already had: no new call is required +// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +// bytes either way, so [`WIRE_VERSION`] is unchanged. #define PUNKTFUNK_ABI_VERSION 17 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. @@ -1622,6 +1623,63 @@ typedef uint8_t PunktfunkInputKind; #endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +// C surface. +// +// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +// player quitting their game and a host falling off the network both arrive as "the session +// ended", and a client with no way to separate them has to word all of them the same. Every client +// worded them as failures. +// +// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +// of the C ABI: append only, never renumber. +enum PunktfunkEndReason +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L + : uint8_t +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L + { +#if defined(PUNKTFUNK_FEATURE_QUIC) + // Not ended (or ended before a reason could be observed). Also what an unknown future value + // decodes to, so an older client reading a newer core degrades to "no opinion". + PUNKTFUNK_END_REASON_NONE = 0, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // **This client** closed the session — the user pressed stop, or the handle was dropped. + // Nothing to report: the UI already knows, it initiated it. + PUNKTFUNK_END_REASON_LOCAL = 1, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + // and the one reason a launcher client can act on: go back to the library the title was + // launched from rather than all the way out to host selection. + PUNKTFUNK_END_REASON_GAME_EXITED = 2, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host ended the session cleanly and deliberately — an operator "End" in the console, or + // the session simply finishing. Normal; say so plainly or say nothing. + PUNKTFUNK_END_REASON_HOST_ENDED = 3, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host closed reporting a failure of its own. Worth showing, and the host's log has the + // detail. + PUNKTFUNK_END_REASON_HOST_ERROR = 4, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The connection died rather than being closed: idle timeout, reset, the network going away. + // This — and only this — is the "the host may be asleep, wake it" case. + PUNKTFUNK_END_REASON_LOST = 5, +#endif +}; +#ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum PunktfunkEndReason PunktfunkEndReason; +#else +typedef uint8_t PunktfunkEndReason; +#endif // __STDC_VERSION__ >= 202311L +#endif // __cplusplus +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the // encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these @@ -2505,23 +2563,25 @@ PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint #endif #if defined(PUNKTFUNK_FEATURE_QUIC) -// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 -// when it did and 0 otherwise; the return status reports only whether the handle was usable. +// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. // -// A refinement of "the session ended", never a substitute — read it only once a plane has -// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and -// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). -// It latches, so it is still readable while the connection is being torn down, and a client that -// never calls it behaves exactly as it did before this existed. +// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +// while the connection is torn down, and a client that never calls it behaves exactly as it did +// before this existed. // -// The point is that a game ending is a normal finish, not a failure: a launcher client can send -// the player back to the host's library — one tap from the next title — rather than reporting an -// error and dropping to host selection for something the player just did on purpose. +// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +// their game from a host falling off the network, so every client wrote one message for all of +// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +// for `HOST_ERROR` and `LOST`. +// +// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. // // # Safety // `c` is a valid connection handle; `out` is NULL or writable for one `u8`. -PunktfunkStatus punktfunk_connection_game_exited(PunktfunkConnection *c, - uint8_t *out); +PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t *out); #endif #if defined(PUNKTFUNK_FEATURE_QUIC)