cultnet-rs, cultmesh-rs: an unsendable goodbye names the failure by a fixed code

end_unsendable_session put the io::Error's Display on the wire: the
platform's wording, which differs by OS and by which packet failed.
cultnet-rs gains send_error_code, the fixed name of a failed send's
cause (EMSGSIZE, EINVAL, EAFNOSUPPORT, or UNKNOWN), matching the codes
the TypeScript goodbye already carries; is_permanent_send_error is now
"has a known code". The goodbye reads "packet could not be sent: CODE".

ResponseSendFailed carried an ErrorKind, which lost the cause (EMSGSIZE
has no kind and read as Uncategorized). It now carries the same fixed
code, and the session ends where the send failed, over the real error,
so the goodbye and the caller's rejection name the same failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 23:01:33 +02:00
commit 5ef988b1d0
4 changed files with 114 additions and 51 deletions

View file

@ -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),
}));
}
}

View file

@ -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]

View file

@ -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

View file

@ -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");
}