diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index ad66148b..09c31adf 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,7 +3,7 @@ use cultnet_rs::{ CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, + encode_rudp_packet, is_permanent_send_error, send_error_code, }; use std::collections::BTreeMap; #[cfg(test)] @@ -237,9 +237,10 @@ pub enum CultMeshRudpRejectionReason { /// The session could not queue the response. Carries the session's error text. ResponseQueueFailed(String), /// A packet of the response can never be sent to the peer as built - /// (`is_permanent_send_error`). The session ends with a goodbye naming the - /// error, and no refusal: the server knows the peer cannot be reached. - ResponseSendFailed(std::io::ErrorKind), + /// (`is_permanent_send_error`). Carries the failure's fixed name + /// (`send_error_code`). The session has ended with a goodbye carrying the + /// same name, and no refusal: the server knows the peer cannot be reached. + ResponseSendFailed(&'static str), } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -276,8 +277,8 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::ResponseQueueFailed(error) => { write!(f, "snapshot response could not be queued: {error}") } - Self::ResponseSendFailed(kind) => { - write!(f, "snapshot response could not be sent: {kind}") + Self::ResponseSendFailed(code) => { + write!(f, "snapshot response could not be sent: {code}") } } } @@ -548,13 +549,14 @@ where )? { // The only reply `receive` returns is a Pong, which delivers no // frame, so no earlier send of this poll can precede a rejection. - let end = match &rejection.reason { - CultMeshRudpRejectionReason::ResponseSendFailed(kind) => { - SessionEnd::Unsendable(std::io::Error::from(*kind)) - } - reason => SessionEnd::Rejected(reason.refusal_text()), - }; - self.end_session(key, end)?; + // A response that could not be sent ended its session where the + // send failed, over the real error. + if !matches!( + rejection.reason, + CultMeshRudpRejectionReason::ResponseSendFailed(_) + ) { + self.end_session(key, SessionEnd::Rejected(rejection.reason.refusal_text()))?; + } return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); } } @@ -840,11 +842,13 @@ where entry.admitted_payload_bytes.saturating_add(payload_bytes); for packet in &packets { if let Some(error) = self.send_packet(key.remote_addr, packet)? { + let code = send_error_code(&error); + self.end_session(key, SessionEnd::Unsendable(error))?; return Ok(Some(CultMeshRudpApplicationRejection { session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - reason: CultMeshRudpRejectionReason::ResponseSendFailed(error.kind()), + reason: CultMeshRudpRejectionReason::ResponseSendFailed(code), })); } } diff --git a/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs b/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs index 9479ee36..bbf3dee1 100644 --- a/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs +++ b/packages/cultmesh-rs/src/rudp_document_server/send_failure_tests.rs @@ -282,7 +282,7 @@ fn a_permanent_failure_sending_a_snapshot_response_rejects_it_and_sends_no_refus assert_eq!(rejection.message_id, "x-snapshot"); assert_eq!( rejection.reason, - CultMeshRudpRejectionReason::ResponseSendFailed(ErrorKind::InvalidInput) + CultMeshRudpRejectionReason::ResponseSendFailed("EINVAL") ); assert_eq!(server.session_count(), 1); // The server knows X cannot be reached: X is sent no refusal, only the @@ -303,9 +303,11 @@ fn a_permanent_failure_sending_a_snapshot_response_rejects_it_and_sends_no_refus frames.is_empty(), "no refusal is sent to an unreachable peer: {frames:?}" ); - assert!(is_unsendable_reason( - &reason.expect("the client was told the session ended") - )); + // The goodbye names the failure by its fixed name, never the error's text. + assert_eq!( + reason.expect("the client was told the session ended"), + b"packet could not be sent: EINVAL" + ); } #[test] diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index f91a3f1e..37bf73d8 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -472,10 +472,16 @@ impl CultNetRudpSession { /// built (see `is_permanent_send_error`). Resending it would fail forever, /// and dropping it would leave a reliable sequence the peer waits on for /// good, so the session cannot be kept. Returns the goodbye for the peer; - /// its reason names the error. + /// its reason names the failure by its fixed name (`send_error_code`). pub fn end_unsendable_session(&mut self, error: &std::io::Error) -> CultNetRudpPacket { self.reset_peer_state(); - self.create_disconnect(format!("{RUDP_UNSENDABLE_PACKET_REASON}: {error}").into_bytes()) + self.create_disconnect( + format!( + "{RUDP_UNSENDABLE_PACKET_REASON}: {}", + send_error_code(error) + ) + .into_bytes(), + ) } /// Undoes a send that never reached the wire: every packet it created at @@ -2599,7 +2605,7 @@ where const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet"; const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out"; /// Prefix of the goodbye reason for a session that owed a packet which can -/// never be sent; the error's own description follows it. +/// never be sent; the failure's fixed name (`send_error_code`) follows it. const RUDP_UNSENDABLE_PACKET_REASON: &str = "packet could not be sent"; /// Whether a failed `send_to` can never succeed for the datagram as built, so @@ -2610,37 +2616,59 @@ const RUDP_UNSENDABLE_PACKET_REASON: &str = "packet could not be sent"; /// call) is a property of the path or the moment and may pass, so it is a lost /// datagram, not a reason to end a session. pub fn is_permanent_send_error(error: &std::io::Error) -> bool { - // `EMSGSIZE` and `EAFNOSUPPORT`, or their Winsock equivalents. - #[cfg(any(target_os = "linux", target_os = "android"))] - const PERMANENT_OS_ERRORS: [i32; 2] = [90, 97]; - #[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd", - target_os = "dragonfly" - ))] - const PERMANENT_OS_ERRORS: [i32; 2] = [40, 47]; - #[cfg(windows)] - const PERMANENT_OS_ERRORS: [i32; 2] = [10040, 10047]; - #[cfg(not(any( - target_os = "linux", - target_os = "android", - target_os = "macos", - target_os = "ios", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd", - target_os = "dragonfly", - windows - )))] - const PERMANENT_OS_ERRORS: [i32; 0] = []; + send_error_code(error) != UNKNOWN_SEND_ERROR +} + +const UNKNOWN_SEND_ERROR: &str = "UNKNOWN"; + +// `EMSGSIZE` and `EAFNOSUPPORT`, or their Winsock equivalents. +#[cfg(any(target_os = "linux", target_os = "android"))] +const PERMANENT_OS_ERRORS: Option<(i32, i32)> = Some((90, 97)); +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +const PERMANENT_OS_ERRORS: Option<(i32, i32)> = Some((40, 47)); +#[cfg(windows)] +const PERMANENT_OS_ERRORS: Option<(i32, i32)> = Some((10040, 10047)); +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly", + windows +)))] +const PERMANENT_OS_ERRORS: Option<(i32, i32)> = None; + +/// The fixed name of a failed send's cause: `EMSGSIZE`, `EINVAL` or +/// `EAFNOSUPPORT` for a failure that can never pass +/// (`is_permanent_send_error`), `UNKNOWN` for anything else. An unsendable +/// session's goodbye carries this name, never the error's own text, which is +/// the platform's wording and can differ by the packet that failed. +pub fn send_error_code(error: &std::io::Error) -> &'static str { + if let (Some(code), Some((message_size, address_family))) = + (error.raw_os_error(), PERMANENT_OS_ERRORS) + { + if code == message_size { + return "EMSGSIZE"; + } + if code == address_family { + return "EAFNOSUPPORT"; + } + } // `InvalidInput` is `EINVAL` and `WSAEINVAL`. - error.kind() == ErrorKind::InvalidInput - || error - .raw_os_error() - .is_some_and(|code| PERMANENT_OS_ERRORS.contains(&code)) + if error.kind() == ErrorKind::InvalidInput { + return "EINVAL"; + } + UNKNOWN_SEND_ERROR } /// A session cannot admit a Connect when its sequence space starts exhausted or diff --git a/packages/cultnet-rs/src/rudp/send_failure_tests.rs b/packages/cultnet-rs/src/rudp/send_failure_tests.rs index 4bff8cf9..4ea521c3 100644 --- a/packages/cultnet-rs/src/rudp/send_failure_tests.rs +++ b/packages/cultnet-rs/src/rudp/send_failure_tests.rs @@ -500,3 +500,32 @@ fn a_send_refused_at_any_fragment_delivers_nothing_of_it() { } } } + +#[test] +fn a_send_failure_is_named_by_a_fixed_code_never_its_text() { + use std::io::{Error, ErrorKind}; + let named = |error: Error| (send_error_code(&error), is_permanent_send_error(&error)); + assert_eq!( + named(Error::new(ErrorKind::InvalidInput, "CANARY-7f3a")), + ("EINVAL", true) + ); + assert_eq!( + named(Error::new(ErrorKind::ConnectionRefused, "CANARY-7f3a")), + ("UNKNOWN", false) + ); + #[cfg(any(target_os = "linux", target_os = "android"))] + { + assert_eq!(named(Error::from_raw_os_error(90)), ("EMSGSIZE", true)); + assert_eq!(named(Error::from_raw_os_error(97)), ("EAFNOSUPPORT", true)); + assert_eq!(named(Error::from_raw_os_error(111)), ("UNKNOWN", false)); + } + let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 1, + initial_sequence: None, + resend_delay_ms: 10, + max_pending_reliable_packets: None, + }); + let goodbye = + session.end_unsendable_session(&Error::new(ErrorKind::InvalidInput, "CANARY-7f3a")); + assert_eq!(goodbye.payload, b"packet could not be sent: EINVAL"); +}