From c3cc2b49c45325cad8d4e0fe6c20d3f8833f3718 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:04:07 +0200 Subject: [PATCH] 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 --- .../cultnet-py/src/cultnet_py/__init__.py | 2 + .../cultnet-py/src/cultnet_py/transport.py | 37 ++++++++++++------- packages/cultnet-py/tests/test_cultnet.py | 12 ++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/cultnet-py/src/cultnet_py/__init__.py b/packages/cultnet-py/src/cultnet_py/__init__.py index 3344f438..e8b939c6 100644 --- a/packages/cultnet-py/src/cultnet_py/__init__.py +++ b/packages/cultnet-py/src/cultnet_py/__init__.py @@ -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", diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index 78ae9542..76b38bf5 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -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() diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 765f5c37..3c5793c1 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -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