cultnet-py: an unsendable goodbye names the failure by a fixed code

end_unsendable_session put the OSError's text on the wire, which is the
platform's wording and can quote an address. send_error_code names a
permanent failure EMSGSIZE, EINVAL or EAFNOSUPPORT (Winsock equivalents
included), anything else UNKNOWN, as the Rust and TypeScript goodbyes
do; is_permanent_send_error is now "has a known code".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 23:04:07 +02:00
commit c3cc2b49c4
3 changed files with 38 additions and 13 deletions

View file

@ -124,6 +124,7 @@ from .transport import (
compute_reconnect_delay_ms,
create_reconnect_policy,
is_permanent_send_error,
send_error_code,
create_rudp_transport_profile,
create_tcp_framed_transport_profile,
decode_rudp_packet,
@ -211,6 +212,7 @@ __all__ = [
"CultNetRudpSendOptions",
"CultNetRudpSession",
"is_permanent_send_error",
"send_error_code",
"CultNetRudpSessionOptions",
"CultNetRudpSocketMode",
"CultNetRudpReconnectLoop",

View file

@ -438,18 +438,28 @@ def create_rudp_transport_profile(
UNSENDABLE_PACKET_REASON = "packet could not be sent"
_PERMANENT_SEND_ERRNOS = frozenset(
code
for code in (
errno.EMSGSIZE,
errno.EINVAL,
errno.EAFNOSUPPORT,
getattr(errno, "WSAEMSGSIZE", None),
getattr(errno, "WSAEINVAL", None),
getattr(errno, "WSAEAFNOSUPPORT", None),
# The fixed name of each permanent send failure, keyed by errno, Winsock equivalents included.
_PERMANENT_SEND_ERROR_CODES = {
code: name
for code, name in (
(errno.EMSGSIZE, "EMSGSIZE"),
(errno.EINVAL, "EINVAL"),
(errno.EAFNOSUPPORT, "EAFNOSUPPORT"),
(getattr(errno, "WSAEMSGSIZE", None), "EMSGSIZE"),
(getattr(errno, "WSAEINVAL", None), "EINVAL"),
(getattr(errno, "WSAEAFNOSUPPORT", None), "EAFNOSUPPORT"),
)
if code is not None
)
}
_UNKNOWN_SEND_ERROR = "UNKNOWN"
def send_error_code(error: OSError) -> str:
"""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 quote an address."""
return _PERMANENT_SEND_ERROR_CODES.get(error.errno, _UNKNOWN_SEND_ERROR)
def is_permanent_send_error(error: OSError) -> bool:
@ -459,7 +469,7 @@ def is_permanent_send_error(error: OSError) -> bool:
Winsock equivalents. Everything else (no route, full buffers, firewall drops, a refusal
reported for an earlier datagram, an interrupted 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."""
return error.errno in _PERMANENT_SEND_ERRNOS
return send_error_code(error) != _UNKNOWN_SEND_ERROR
class CultNetRudpSession:
@ -872,9 +882,10 @@ class CultNetRudpSession:
"""Ends a session that owes its peer a packet that can never be sent as 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."""
be kept. Returns the goodbye for the peer; its reason names the failure by its fixed
name (`send_error_code`), never the error's text."""
self.reset_peer_state()
return self.create_disconnect(f"{UNSENDABLE_PACKET_REASON}: {error}".encode("utf-8"))
return self.create_disconnect(f"{UNSENDABLE_PACKET_REASON}: {send_error_code(error)}".encode("utf-8"))
def create_disconnect(self, reason: bytes = b"") -> CultNetRudpPacket:
self._end_session()

View file

@ -901,6 +901,18 @@ class CultNetTests(unittest.TestCase):
self.assertFalse(is_permanent_send_error(OSError(code, "transient")), errno.errorcode[code])
self.assertFalse(is_permanent_send_error(OSError("no errno")))
def test_cultnet_rudp_an_unsendable_goodbye_names_the_failure_by_a_fixed_code(self) -> None:
import errno
from cultnet_py import send_error_code
for code, name in ((errno.EMSGSIZE, "EMSGSIZE"), (errno.EINVAL, "EINVAL"), (errno.EAFNOSUPPORT, "EAFNOSUPPORT")):
self.assertEqual(send_error_code(OSError(code, "CANARY-7f3a")), name)
self.assertEqual(send_error_code(OSError(errno.ECONNREFUSED, "CANARY-7f3a")), "UNKNOWN")
session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=1))
goodbye = session.end_unsendable_session(OSError(errno.EMSGSIZE, "Message too long CANARY-7f3a"))
self.assertEqual(bytes(goodbye.payload), b"packet could not be sent: EMSGSIZE")
# The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023 above
# the lowest unacked one and the payload above that sequence stays within 4 MiB.
FLOW_CONNECTION_ID = 0x464C4F57