Merge ack Cut D: a failed send to one peer stays that peer's loss; C# schema server survives peer input
A peer's undecodable or unhandleable message ends only that peer's session and never
escapes Poll (the C# RUDP schema server's live crash). A send failure's goodbye belongs to
the generation that sent it. TS transient send failures are counted, not reported. The
interop harness finds the Rust peer through cargo metadata.
Soul passes 1-4; batch 5 closes pass 4's findings. Merged tree a892e117 verified on
Yggdrasil: GameCult.Networking 481, Mesh 264, cultnet-ts 198, interop 27/27 with
CARGO_TARGET_DIR set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
2f06ef8400
25 changed files with 4417 additions and 232 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -20,6 +21,7 @@ from cultnet_py import (
|
|||
create_tcp_framed_transport_profile,
|
||||
decode_rudp_packet,
|
||||
encode_rudp_packet,
|
||||
is_permanent_send_error,
|
||||
wire_message_schema_descriptors,
|
||||
)
|
||||
|
||||
|
|
@ -31,6 +33,8 @@ from cultnet_py.cultmesh_contracts import (
|
|||
CultMeshVerseCatalog,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DatabaseSubscription:
|
||||
|
|
@ -73,6 +77,16 @@ class CultMeshLocalServer:
|
|||
_stop: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
|
||||
_thread: threading.Thread | None = field(default=None, init=False, repr=False)
|
||||
_rudp_thread: threading.Thread | None = field(default=None, init=False, repr=False)
|
||||
_rudp_send_failures: int = field(default=0, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def rudp_send_failures(self) -> int:
|
||||
"""Datagrams that could not be sent to a peer. Each is that peer's lost
|
||||
datagram: a reliable packet stays pending and is resent, and no other
|
||||
peer is affected. A datagram that can never be sent as built
|
||||
(`is_permanent_send_error`) is not counted here: it ends that peer's
|
||||
session, and the peer's goodbye names the error."""
|
||||
return self._rudp_send_failures
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_snapshot_documents is not None and self.max_snapshot_documents < 0:
|
||||
|
|
@ -208,7 +222,10 @@ class CultMeshLocalServer:
|
|||
while not self._stop.is_set():
|
||||
try:
|
||||
wire, remote_addr = rudp_socket.recvfrom(65535)
|
||||
except TimeoutError:
|
||||
except (TimeoutError, ConnectionResetError):
|
||||
# Windows reports an earlier ICMP port-unreachable, from a peer that
|
||||
# has gone away, as an error on the next receive. It names no datagram
|
||||
# of this read, so it is idle, not the end of the thread.
|
||||
self._poll_rudp_resends(rudp_socket, peers)
|
||||
continue
|
||||
except OSError:
|
||||
|
|
@ -237,46 +254,74 @@ class CultMeshLocalServer:
|
|||
)
|
||||
)
|
||||
peers[remote_addr] = peer
|
||||
self._send_rudp_packet(
|
||||
unsendable = self._send_rudp_packet(
|
||||
rudp_socket,
|
||||
remote_addr,
|
||||
peer.session.accept_connect(packet, now_ms, b"cultmesh-local-rudp"),
|
||||
)
|
||||
if unsendable is not None:
|
||||
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
|
||||
continue
|
||||
if peer is None:
|
||||
continue
|
||||
|
||||
result = peer.session.receive(packet, now_ms)
|
||||
if result.reply is not None:
|
||||
self._send_rudp_packet(rudp_socket, remote_addr, result.reply)
|
||||
for ready in result.ready_to_send:
|
||||
self._send_rudp_packet(rudp_socket, remote_addr, ready)
|
||||
try:
|
||||
result = peer.session.receive(packet, now_ms)
|
||||
except ValueError:
|
||||
# receive() has already recorded the packet's reliable sequence, so the
|
||||
# session cannot be kept: a retransmit would be acknowledged and the
|
||||
# frame silently lost. It ends, never the thread, and the peer is told.
|
||||
self._end_refused_peer(rudp_socket, peers, remote_addr)
|
||||
continue
|
||||
unsendable: OSError | None = None
|
||||
for outgoing in (*([result.reply] if result.reply is not None else []), *result.ready_to_send):
|
||||
unsendable = self._send_rudp_packet(rudp_socket, remote_addr, outgoing)
|
||||
if unsendable is not None:
|
||||
break
|
||||
if result.disconnected:
|
||||
peers.pop(remote_addr, None)
|
||||
continue
|
||||
for delivered in result.delivered:
|
||||
if delivered.channel_id != "schema":
|
||||
continue
|
||||
try:
|
||||
message = msgpack.unpackb(delivered.payload, raw=False)
|
||||
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
|
||||
continue
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
responses = self._handle_connection_message(message, peer.subscriptions)
|
||||
for response in responses:
|
||||
self._send_rudp_schema_frame(
|
||||
rudp_socket,
|
||||
remote_addr,
|
||||
peer.session,
|
||||
msgpack.packb(response, use_bin_type=True),
|
||||
)
|
||||
if packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered:
|
||||
self._send_rudp_packet(
|
||||
try:
|
||||
for delivered in result.delivered:
|
||||
if delivered.channel_id != "schema":
|
||||
continue
|
||||
try:
|
||||
message = msgpack.unpackb(delivered.payload, raw=False)
|
||||
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
|
||||
continue
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
responses = self._handle_connection_message(message, peer.subscriptions)
|
||||
for response in responses:
|
||||
if unsendable is None:
|
||||
unsendable = self._send_rudp_schema_frame(
|
||||
rudp_socket,
|
||||
remote_addr,
|
||||
peer.session,
|
||||
msgpack.packb(response, use_bin_type=True),
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
|
||||
# Whatever a peer's message makes the handling raise is that peer's failure.
|
||||
# The session recorded the frame's sequence, so it cannot be kept: a retransmit
|
||||
# would be acknowledged and never handled. The session ends, never the thread,
|
||||
# and the peer is told. The log names the error type only, because the error's
|
||||
# own message can quote what the peer sent.
|
||||
_LOGGER.warning(
|
||||
"CultMesh RUDP server could not handle a peer's message (%s); that peer's session ended.",
|
||||
type(error).__name__,
|
||||
)
|
||||
self._end_refused_peer(rudp_socket, peers, remote_addr)
|
||||
continue
|
||||
if unsendable is None and (
|
||||
packet.reliable or packet.packet_type == CultNetRudpPacketType.DATA or result.delivered
|
||||
):
|
||||
unsendable = self._send_rudp_packet(
|
||||
rudp_socket,
|
||||
remote_addr,
|
||||
peer.session.create_ack_for_received(packet.sequence),
|
||||
)
|
||||
if unsendable is not None:
|
||||
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
|
||||
|
||||
def _poll_rudp_resends(
|
||||
self,
|
||||
|
|
@ -286,7 +331,10 @@ class CultMeshLocalServer:
|
|||
now_ms = _now_ms()
|
||||
for remote_addr, peer in list(peers.items()):
|
||||
for packet in peer.session.due_resends(now_ms):
|
||||
self._send_rudp_packet(rudp_socket, remote_addr, packet)
|
||||
unsendable = self._send_rudp_packet(rudp_socket, remote_addr, packet)
|
||||
if unsendable is not None:
|
||||
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
|
||||
break
|
||||
|
||||
def _send_rudp_schema_frame(
|
||||
self,
|
||||
|
|
@ -294,21 +342,62 @@ class CultMeshLocalServer:
|
|||
remote_addr: tuple[str, int],
|
||||
session: CultNetRudpSession,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
) -> OSError | None:
|
||||
for packet in session.send_many(
|
||||
"schema",
|
||||
payload,
|
||||
CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=_now_ms()),
|
||||
):
|
||||
self._send_rudp_packet(rudp_socket, remote_addr, packet)
|
||||
unsendable = self._send_rudp_packet(rudp_socket, remote_addr, packet)
|
||||
if unsendable is not None:
|
||||
return unsendable
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _send_rudp_packet(
|
||||
self,
|
||||
rudp_socket: socket.socket,
|
||||
remote_addr: tuple[str, int],
|
||||
packet: CultNetRudpPacket,
|
||||
) -> OSError | None:
|
||||
# A transient send failure is that peer's lost datagram: counted, never
|
||||
# raised (raising here ended the RUDP thread for every peer). A permanent
|
||||
# one (`is_permanent_send_error`) is returned: the datagram can never be
|
||||
# sent as built, and the caller ends that peer's session.
|
||||
wire = encode_rudp_packet(packet)
|
||||
try:
|
||||
rudp_socket.sendto(wire, remote_addr)
|
||||
except OSError as error:
|
||||
if is_permanent_send_error(error):
|
||||
return error
|
||||
self._rudp_send_failures += 1
|
||||
return None
|
||||
|
||||
def _end_refused_peer(
|
||||
self,
|
||||
rudp_socket: socket.socket,
|
||||
peers: dict[tuple[str, int], _RudpPeerConnection],
|
||||
remote_addr: tuple[str, int],
|
||||
) -> None:
|
||||
rudp_socket.sendto(encode_rudp_packet(packet), remote_addr)
|
||||
peer = peers.pop(remote_addr)
|
||||
# The goodbye is built after the reset, or its ack field would acknowledge the
|
||||
# very frame the session refused.
|
||||
peer.session.reset_peer_state()
|
||||
goodbye = peer.session.create_disconnect(b"session refused a packet")
|
||||
self._send_rudp_packet(rudp_socket, remote_addr, goodbye)
|
||||
|
||||
def _end_unsendable_peer(
|
||||
self,
|
||||
rudp_socket: socket.socket,
|
||||
peers: dict[tuple[str, int], _RudpPeerConnection],
|
||||
remote_addr: tuple[str, int],
|
||||
error: OSError,
|
||||
) -> None:
|
||||
"""The session owes its peer a packet that can never be sent as built. It ends,
|
||||
never the thread, and the peer's goodbye names the error."""
|
||||
peer = peers.pop(remote_addr, None)
|
||||
if peer is None:
|
||||
return
|
||||
self._send_rudp_packet(rudp_socket, remote_addr, peer.session.end_unsendable_session(error))
|
||||
|
||||
def _handle_connection_message(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
|
|
@ -47,10 +48,12 @@ from cultnet_py import (
|
|||
CultNetSimulationConsensusCandidate,
|
||||
CultNetSimulationObservation,
|
||||
CultNetSimulationObservationHub,
|
||||
CultNetRudpPacketType,
|
||||
CultNetRudpSocketMode,
|
||||
CultNetRudpSocketTransportConnection,
|
||||
CultNetRudpSocketTransportOptions,
|
||||
apply_shard_log_response,
|
||||
decode_rudp_packet,
|
||||
document_delete,
|
||||
document_put_raw,
|
||||
hello,
|
||||
|
|
@ -4529,5 +4532,459 @@ class CultMeshTests(unittest.TestCase):
|
|||
self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42)
|
||||
|
||||
|
||||
class CultMeshRudpSendFailureTests(unittest.TestCase):
|
||||
"""One peer whose datagrams cannot be sent must not stop the RUDP thread
|
||||
serving the others."""
|
||||
|
||||
def _client(self, server: Any, name: str) -> tuple[CultNetRudpSocketTransportConnection, tuple[str, int]]:
|
||||
sock = bind_udp_socket()
|
||||
client = CultNetRudpSocketTransportConnection(
|
||||
CultNetRudpSocketTransportOptions(
|
||||
runtime_id=name,
|
||||
socket=sock,
|
||||
mode=CultNetRudpSocketMode.CLIENT,
|
||||
connection_id=server.rudp_connection_id,
|
||||
remote_addr=("127.0.0.1", server.port),
|
||||
resend_delay_ms=25,
|
||||
)
|
||||
)
|
||||
client.connect(b"join")
|
||||
for _ in range(200):
|
||||
client.receive_once()
|
||||
if client.connected:
|
||||
break
|
||||
self.assertTrue(client.connected)
|
||||
return client, sock.getsockname()[:2]
|
||||
|
||||
def test_a_failed_send_to_one_peer_does_not_kill_the_rudp_thread(self) -> None:
|
||||
import msgpack # type: ignore
|
||||
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
x, x_addr = self._client(server, "peer-x")
|
||||
y, _ = self._client(server, "peer-y")
|
||||
|
||||
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(x_addr):
|
||||
raise OSError("injected send failure")
|
||||
return original_sendto(sock, data, *args)
|
||||
|
||||
request = hello(runtime_id="probe").to_bytes()
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
# The server's reply and ack to X both fail to send.
|
||||
x.send("schema", request)
|
||||
self._wait_for(lambda: server.rudp_send_failures > 0)
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
|
||||
y.send("schema", request)
|
||||
response = None
|
||||
for _ in range(200):
|
||||
response = y.receive_once()
|
||||
if response is not None:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
|
||||
self.assertIsNotNone(response, "Y is still served after X's send failed")
|
||||
self.assertEqual(msgpack.unpackb(response.payload, raw=False)["runtimeId"], "mesh-server")
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
self.assertGreater(server.rudp_send_failures, 0)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def _served(self, y: CultNetRudpSocketTransportConnection) -> Any:
|
||||
import msgpack # type: ignore
|
||||
|
||||
y.send("schema", hello(runtime_id="probe").to_bytes())
|
||||
for _ in range(200):
|
||||
response = y.receive_once()
|
||||
if response is not None:
|
||||
return msgpack.unpackb(response.payload, raw=False)
|
||||
time.sleep(0.005)
|
||||
return None
|
||||
|
||||
def test_a_vanished_peer_does_not_kill_the_rudp_thread(self) -> None:
|
||||
# On Windows the closed port of a peer that went away is reported as
|
||||
# ConnectionResetError (WinError 10054) by the next receive. It can only fail there.
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
try:
|
||||
x, _ = self._client(server, "peer-x")
|
||||
y, _ = self._client(server, "peer-y")
|
||||
x.send("schema", hello(runtime_id="probe").to_bytes())
|
||||
x.socket.close()
|
||||
time.sleep(1.0) # the server's reply and resends to X draw ICMP port-unreachable
|
||||
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
served = self._served(y)
|
||||
self.assertIsNotNone(served, "Y is still served after X vanished")
|
||||
self.assertEqual(served["runtimeId"], "mesh-server")
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_reset_reported_by_a_receive_is_idle_not_the_end_of_the_thread(self) -> None:
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_recvfrom = socket.socket.recvfrom
|
||||
resets = [3]
|
||||
try:
|
||||
y, _ = self._client(server, "peer-y")
|
||||
|
||||
def recvfrom(sock: socket.socket, *args: Any) -> Any:
|
||||
if sock is server._rudp_socket and resets[0] > 0:
|
||||
resets[0] -= 1
|
||||
raise ConnectionResetError(errno.ECONNRESET, "injected reset")
|
||||
return original_recvfrom(sock, *args)
|
||||
|
||||
with patch.object(socket.socket, "recvfrom", recvfrom):
|
||||
self._wait_for(lambda: resets[0] == 0)
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
self.assertIsNotNone(self._served(y))
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_permanent_send_failure_ends_only_that_peers_session_and_the_goodbye_names_it(self) -> None:
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
x, x_addr = self._client(server, "peer-x")
|
||||
y, _ = self._client(server, "peer-y")
|
||||
failures = [1]
|
||||
|
||||
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(x_addr) and failures[0] > 0:
|
||||
failures[0] -= 1
|
||||
raise OSError(errno.EMSGSIZE, "Message too long")
|
||||
return original_sendto(sock, data, *args)
|
||||
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
# The server's response to X can never be sent as built.
|
||||
x.send("schema", hello(runtime_id="probe").to_bytes())
|
||||
for _ in range(200):
|
||||
x.receive_once()
|
||||
if x.disconnect_reason is not None:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
self.assertIsNotNone(x.disconnect_reason, "X is told its session ended")
|
||||
self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: "))
|
||||
self.assertGreater(len(x.disconnect_reason), len(b"packet could not be sent: "))
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
|
||||
# Y is unaffected.
|
||||
self.assertFalse(x.connected)
|
||||
self.assertIsNotNone(self._served(y))
|
||||
self.assertEqual(server.rudp_send_failures, 0)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_healthy_sends_are_not_failures(self) -> None:
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
try:
|
||||
x, _ = self._client(server, "peer-x")
|
||||
self.assertIsNotNone(self._served(x))
|
||||
self.assertEqual(server.rudp_send_failures, 0)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_permanent_failure_in_a_resend_ends_only_that_peers_session(self) -> None:
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
x, x_addr = self._client(server, "peer-x")
|
||||
y, _ = self._client(server, "peer-y")
|
||||
mode = ["transient"]
|
||||
|
||||
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(x_addr) and mode[0] == "transient":
|
||||
raise OSError(errno.ENOBUFS, "injected transient failure")
|
||||
if args and tuple(args[-1]) == tuple(x_addr) and mode[0] == "permanent":
|
||||
mode[0] = "healthy"
|
||||
raise OSError(errno.EMSGSIZE, "Message too long")
|
||||
return original_sendto(sock, data, *args)
|
||||
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
# X's response is lost, so a resend is pending; then it can never be sent as built.
|
||||
x.send("schema", hello(runtime_id="probe").to_bytes())
|
||||
self._wait_for(lambda: server.rudp_send_failures > 0)
|
||||
self.assertFalse(x.disconnect_reason, "a transient failure ends no session")
|
||||
mode[0] = "permanent"
|
||||
for _ in range(200):
|
||||
x.receive_once()
|
||||
if x.disconnect_reason is not None:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
self.assertIsNotNone(x.disconnect_reason)
|
||||
self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: "))
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
self.assertIsNotNone(self._served(y))
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_permanent_failure_acknowledging_a_frame_ends_the_session(self) -> None:
|
||||
import msgpack # type: ignore
|
||||
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
x, x_addr = self._client(server, "peer-x")
|
||||
failures = [1]
|
||||
|
||||
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(x_addr) and failures[0] > 0:
|
||||
failures[0] -= 1
|
||||
raise OSError(errno.EMSGSIZE, "Message too long")
|
||||
return original_sendto(sock, data, *args)
|
||||
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
# Not a message the server answers, so its ack is the only datagram owed.
|
||||
x.send("schema", msgpack.packb(1))
|
||||
for _ in range(200):
|
||||
x.receive_once()
|
||||
if x.disconnect_reason is not None:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
self.assertIsNotNone(x.disconnect_reason)
|
||||
self.assertTrue(x.disconnect_reason.startswith(b"packet could not be sent: "))
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_connect_whose_accept_can_never_be_sent_starts_no_session(self) -> None:
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
y, _ = self._client(server, "peer-y")
|
||||
z_socket = bind_udp_socket()
|
||||
z_addr = z_socket.getsockname()[:2]
|
||||
failures = [1]
|
||||
|
||||
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(z_addr) and failures[0] > 0:
|
||||
failures[0] -= 1
|
||||
raise OSError(errno.EMSGSIZE, "Message too long")
|
||||
return original_sendto(sock, data, *args)
|
||||
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
z = CultNetRudpSocketTransportConnection(
|
||||
CultNetRudpSocketTransportOptions(
|
||||
runtime_id="peer-z",
|
||||
socket=z_socket,
|
||||
mode=CultNetRudpSocketMode.CLIENT,
|
||||
connection_id=server.rudp_connection_id,
|
||||
remote_addr=("127.0.0.1", server.port),
|
||||
resend_delay_ms=25,
|
||||
)
|
||||
)
|
||||
z.connect(b"join")
|
||||
# A session that started would resend its Accept, and the resend would get through.
|
||||
time.sleep(0.5)
|
||||
z_socket.settimeout(0.05)
|
||||
received = []
|
||||
while True:
|
||||
try:
|
||||
wire, _ = z_socket.recvfrom(65535)
|
||||
except TimeoutError:
|
||||
break
|
||||
received.append(decode_rudp_packet(wire).packet_type)
|
||||
self.assertEqual(received, [CultNetRudpPacketType.DISCONNECT])
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
self.assertIsNotNone(self._served(y))
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_packet_the_session_refuses_ends_only_that_peers_session(self) -> None:
|
||||
from cultnet_py import CultNetRudpPacket, encode_rudp_packet
|
||||
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
try:
|
||||
x, _ = self._client(server, "peer-x")
|
||||
y, _ = self._client(server, "peer-y")
|
||||
# A fragment whose metadata the session refuses: fragment id 0 names no set.
|
||||
refused = CultNetRudpPacket(
|
||||
packet_type=CultNetRudpPacketType.DATA,
|
||||
connection_id=server.rudp_connection_id,
|
||||
sequence=0,
|
||||
ack=0,
|
||||
ack_mask=0,
|
||||
channel_id="schema",
|
||||
reliable=False,
|
||||
fragment_id=0,
|
||||
fragment_index=0,
|
||||
fragment_count=2,
|
||||
payload=b"x",
|
||||
)
|
||||
x.socket.sendto(encode_rudp_packet(refused), ("127.0.0.1", server.port))
|
||||
for _ in range(200):
|
||||
x.receive_once()
|
||||
if x.disconnect_reason is not None:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
self.assertEqual(x.disconnect_reason, b"session refused a packet")
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
self.assertIsNotNone(self._served(y), "Y is still served")
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_message_whose_handling_fails_ends_only_that_peers_session(self) -> None:
|
||||
import msgpack # type: ignore
|
||||
|
||||
canary = "canary-7f3e1c"
|
||||
# Well-formed frames whose fields have the wrong type: handling raises TypeError.
|
||||
frames = [
|
||||
{"schemaVersion": "cultnet.database_subscribe.v0", "subscriptionId": canary, "schemaIds": 5},
|
||||
{"schemaVersion": "cultnet.database_subscribe.v0", "subscriptionId": canary, "recordKeys": True},
|
||||
]
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
try:
|
||||
y, _ = self._client(server, "peer-y")
|
||||
for frame in frames:
|
||||
with self.subTest(frame=frame):
|
||||
x, _ = self._client(server, "peer-x")
|
||||
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
|
||||
x.send("schema", msgpack.packb(frame, use_bin_type=True))
|
||||
for _ in range(200):
|
||||
x.receive_once()
|
||||
if x.disconnect_reason is not None:
|
||||
break
|
||||
time.sleep(0.005)
|
||||
self.assertEqual(x.disconnect_reason, b"session refused a packet")
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
self.assertIsNotNone(self._served(y), "Y is still served")
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn("TypeError", logs.output[0])
|
||||
self.assertNotIn(canary, logs.output[0])
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def _raw_peer(self, server: Any) -> tuple[socket.socket, Any]:
|
||||
"""A peer driven packet by packet, to deliver frames in an order a client would not."""
|
||||
from cultnet_py import CultNetRudpSession, CultNetRudpSessionOptions, encode_rudp_packet
|
||||
|
||||
sock = bind_udp_socket()
|
||||
sock.settimeout(0.05)
|
||||
session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=server.rudp_connection_id))
|
||||
sock.sendto(encode_rudp_packet(session.create_connect(0, b"join")), ("127.0.0.1", server.port))
|
||||
wire, _ = sock.recvfrom(65535)
|
||||
accept = decode_rudp_packet(wire)
|
||||
self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT)
|
||||
session.receive(accept, 0)
|
||||
return sock, session
|
||||
|
||||
def test_the_goodbye_for_a_refused_reliable_frame_does_not_acknowledge_it(self) -> None:
|
||||
import dataclasses
|
||||
|
||||
from cultnet_py import CultNetRudpSendOptions, encode_rudp_packet
|
||||
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
try:
|
||||
sock, session = self._raw_peer(server)
|
||||
# A reliable fragment whose metadata the session refuses: fragment id 0 names no set.
|
||||
# The session records its sequence before it refuses it.
|
||||
(fragment,) = session.send_many("schema", b"x", CultNetRudpSendOptions(reliable=True, ordered=True))
|
||||
refused = dataclasses.replace(fragment, fragment_id=0, fragment_index=0, fragment_count=2)
|
||||
sock.sendto(encode_rudp_packet(refused), ("127.0.0.1", server.port))
|
||||
goodbye = None
|
||||
for _ in range(40):
|
||||
try:
|
||||
wire, _ = sock.recvfrom(65535)
|
||||
except TimeoutError:
|
||||
continue
|
||||
packet = decode_rudp_packet(wire)
|
||||
if packet.packet_type == CultNetRudpPacketType.DISCONNECT:
|
||||
goodbye = packet
|
||||
break
|
||||
self.assertIsNotNone(goodbye)
|
||||
self.assertEqual(goodbye.payload, b"session refused a packet")
|
||||
distance = goodbye.ack - refused.sequence
|
||||
acknowledged = distance == 0 or (0 < distance <= 32 and goodbye.ack_mask & (1 << (distance - 1)) != 0)
|
||||
self.assertFalse(acknowledged, "the goodbye acknowledged the refused frame")
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_responses_after_one_that_can_never_be_sent_are_not_sent(self) -> None:
|
||||
from cultnet_py import CultNetRudpSendOptions, encode_rudp_packet
|
||||
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
sock, session = self._raw_peer(server)
|
||||
addr = sock.getsockname()[:2]
|
||||
request = hello(runtime_id="probe").to_bytes()
|
||||
options = CultNetRudpSendOptions(reliable=True, ordered=True)
|
||||
first = session.send("schema", request, options)
|
||||
second = session.send("schema", request, options)
|
||||
attempts: list[CultNetRudpPacketType] = []
|
||||
failures = [1]
|
||||
|
||||
def sendto(sock_: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(addr):
|
||||
packet_type = decode_rudp_packet(data).packet_type
|
||||
attempts.append(packet_type)
|
||||
if packet_type == CultNetRudpPacketType.DATA and failures[0] > 0:
|
||||
failures[0] -= 1
|
||||
raise OSError(errno.EMSGSIZE, "Message too long")
|
||||
return original_sendto(sock_, data, *args)
|
||||
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
# The second frame waits for the first, so one receive delivers both, and
|
||||
# the first of the two responses can never be sent as built.
|
||||
sock.sendto(encode_rudp_packet(second), ("127.0.0.1", server.port))
|
||||
time.sleep(0.1)
|
||||
attempts.clear()
|
||||
sock.sendto(encode_rudp_packet(first), ("127.0.0.1", server.port))
|
||||
self._wait_for(lambda: CultNetRudpPacketType.DISCONNECT in attempts)
|
||||
time.sleep(0.1)
|
||||
|
||||
# The unsendable response, then only the goodbye: no second response, no ack.
|
||||
self.assertEqual(attempts, [CultNetRudpPacketType.DATA, CultNetRudpPacketType.DISCONNECT])
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def test_a_peer_ended_in_the_resend_loop_is_sent_nothing_more_from_it(self) -> None:
|
||||
server = CultMesh.serve_node(CultMesh.create_node(runtime_id="mesh-server"))
|
||||
original_sendto = socket.socket.sendto
|
||||
try:
|
||||
x, x_addr = self._client(server, "peer-x")
|
||||
mode = ["transient"]
|
||||
attempts: list[CultNetRudpPacketType] = []
|
||||
|
||||
def sendto(sock: socket.socket, data: bytes, *args: Any) -> int:
|
||||
if args and tuple(args[-1]) == tuple(x_addr):
|
||||
packet_type = decode_rudp_packet(data).packet_type
|
||||
if mode[0] != "transient":
|
||||
attempts.append(packet_type)
|
||||
# Only responses fail; X's requests are acknowledged, so X goes quiet.
|
||||
if packet_type == CultNetRudpPacketType.DATA and mode[0] == "transient":
|
||||
raise OSError(errno.ENOBUFS, "injected transient failure")
|
||||
if packet_type == CultNetRudpPacketType.DATA and mode[0] == "permanent":
|
||||
mode[0] = "healthy"
|
||||
raise OSError(errno.EMSGSIZE, "Message too long")
|
||||
return original_sendto(sock, data, *args)
|
||||
|
||||
with patch.object(socket.socket, "sendto", sendto):
|
||||
x.send("schema", hello(runtime_id="one").to_bytes())
|
||||
x.send("schema", hello(runtime_id="two").to_bytes())
|
||||
for _ in range(40):
|
||||
x.receive_once()
|
||||
time.sleep(0.005)
|
||||
self._wait_for(lambda: server.rudp_send_failures >= 2)
|
||||
# Both responses are owed; the next resend of the first can never be sent.
|
||||
mode[0] = "permanent"
|
||||
self._wait_for(lambda: CultNetRudpPacketType.DISCONNECT in attempts)
|
||||
time.sleep(0.2)
|
||||
|
||||
self.assertEqual(attempts, [CultNetRudpPacketType.DATA, CultNetRudpPacketType.DISCONNECT])
|
||||
self.assertTrue(server._rudp_thread.is_alive())
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
def _wait_for(self, predicate: Callable[[], bool]) -> None:
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.005)
|
||||
self.fail("condition not reached")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ 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,
|
||||
encode_rudp_packet, is_permanent_send_error,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(test)]
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -208,6 +210,14 @@ pub struct CultMeshRudpDocumentServer<S, Q, C> {
|
|||
clock: C,
|
||||
options: CultMeshRudpDocumentServerOptions,
|
||||
packets_dropped: u64,
|
||||
send_failures: u64,
|
||||
/// Peers whose every datagram fails to send, standing in for an unroutable
|
||||
/// or full path that a loopback peer cannot be made to have.
|
||||
#[cfg(test)]
|
||||
failing_peers: BTreeSet<SocketAddr>,
|
||||
/// Peers whose next datagram after this many fails permanently, once.
|
||||
#[cfg(test)]
|
||||
unsendable_after: BTreeMap<SocketAddr, usize>,
|
||||
}
|
||||
|
||||
impl<S, Q, C> CultMeshRudpDocumentServer<S, Q, C>
|
||||
|
|
@ -236,6 +246,11 @@ where
|
|||
clock,
|
||||
options,
|
||||
packets_dropped: 0,
|
||||
send_failures: 0,
|
||||
#[cfg(test)]
|
||||
failing_peers: BTreeSet::new(),
|
||||
#[cfg(test)]
|
||||
unsendable_after: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +262,18 @@ where
|
|||
self.packets_dropped
|
||||
}
|
||||
|
||||
/// Datagrams that could not be sent to a peer and may yet be: no route, full
|
||||
/// buffers, a refused path. Each is that peer's lost datagram: a reliable
|
||||
/// packet stays pending and is resent, and the peer's session ends by idle
|
||||
/// timeout, lifetime, refusal or Disconnect, never by the failure. Never an
|
||||
/// error: one unreachable peer must not stop the poll that serves the
|
||||
/// others. A datagram that can never be sent as built
|
||||
/// (`is_permanent_send_error`) is not counted here: it ends that peer's
|
||||
/// session, and the peer's goodbye names the error.
|
||||
pub fn send_failures(&self) -> u64 {
|
||||
self.send_failures
|
||||
}
|
||||
|
||||
pub fn local_addr(&self) -> Result<SocketAddr> {
|
||||
Ok(self.socket.local_addr()?)
|
||||
}
|
||||
|
|
@ -265,7 +292,14 @@ where
|
|||
let mut wire = vec![0_u8; MAX_UDP_DATAGRAM_BYTES];
|
||||
let (received, remote_addr) = match self.socket.recv_from(&mut wire) {
|
||||
Ok(value) => value,
|
||||
Err(error) if error.kind() == ErrorKind::WouldBlock => {
|
||||
// Windows reports an earlier ICMP port-unreachable on the next
|
||||
// receive; it names no datagram of this poll.
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
ErrorKind::WouldBlock | ErrorKind::ConnectionReset
|
||||
) =>
|
||||
{
|
||||
return Ok(CultMeshRudpPollOutcome::Idle);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
|
|
@ -331,9 +365,10 @@ where
|
|||
}
|
||||
};
|
||||
|
||||
if let Some(reply) = result.reply {
|
||||
self.send_packet(key.remote_addr, &reply)?;
|
||||
}
|
||||
let mut unsendable = match result.reply {
|
||||
Some(reply) => self.send_packet(key.remote_addr, &reply)?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
if result.disconnected {
|
||||
self.sessions.remove(&key);
|
||||
|
|
@ -363,14 +398,17 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
if packet.reliable {
|
||||
if packet.reliable && unsendable.is_none() {
|
||||
let ack = self
|
||||
.sessions
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| anyhow!("CultMesh RUDP session disappeared before ACK"))?
|
||||
.session
|
||||
.create_ack();
|
||||
self.send_packet(key.remote_addr, &ack)?;
|
||||
unsendable = self.send_packet(key.remote_addr, &ack)?;
|
||||
}
|
||||
if let Some(error) = unsendable {
|
||||
self.end_unsendable_session(key, &error)?;
|
||||
}
|
||||
Ok(CultMeshRudpPollOutcome::Handled)
|
||||
}
|
||||
|
|
@ -390,11 +428,17 @@ where
|
|||
let mut resends = Vec::new();
|
||||
for (key, entry) in &mut self.sessions {
|
||||
for packet in entry.session.due_resends(now) {
|
||||
resends.push((key.remote_addr, packet));
|
||||
resends.push((*key, packet));
|
||||
}
|
||||
}
|
||||
for (remote_addr, packet) in &resends {
|
||||
self.send_packet(*remote_addr, packet)?;
|
||||
for (key, packet) in &resends {
|
||||
// A session ended earlier in this loop has nothing left to resend.
|
||||
if !self.sessions.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(error) = self.send_packet(key.remote_addr, packet)? {
|
||||
self.end_unsendable_session(*key, &error)?;
|
||||
}
|
||||
}
|
||||
Ok(CultMeshRudpMaintenance {
|
||||
sessions_expired: expired,
|
||||
|
|
@ -456,7 +500,10 @@ where
|
|||
accept
|
||||
}
|
||||
};
|
||||
self.send_packet(key.remote_addr, &reply)?;
|
||||
if let Some(error) = self.send_packet(key.remote_addr, &reply)? {
|
||||
self.end_unsendable_session(key, &error)?;
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
@ -592,7 +639,14 @@ where
|
|||
entry.admitted_payload_bytes =
|
||||
entry.admitted_payload_bytes.saturating_add(payload_bytes);
|
||||
for packet in &packets {
|
||||
self.send_packet(key.remote_addr, packet)?;
|
||||
if let Some(error) = self.send_packet(key.remote_addr, packet)? {
|
||||
return Ok(Some(CultMeshRudpApplicationRejection {
|
||||
session: key,
|
||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||
message_id,
|
||||
reason: format!("snapshot response could not be sent: {error}"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -607,7 +661,23 @@ where
|
|||
return Ok(());
|
||||
};
|
||||
let goodbye = entry.session.end_refused_session();
|
||||
self.send_packet(key.remote_addr, &goodbye)
|
||||
self.send_packet(key.remote_addr, &goodbye)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session owes its peer a packet that can never be sent as built. The
|
||||
/// session ends, never the poll, and the goodbye names the error.
|
||||
fn end_unsendable_session(
|
||||
&mut self,
|
||||
key: CultMeshRudpSessionKey,
|
||||
error: &std::io::Error,
|
||||
) -> Result<()> {
|
||||
let Some(mut entry) = self.sessions.remove(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let goodbye = entry.session.end_unsendable_session(error);
|
||||
self.send_packet(key.remote_addr, &goodbye)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload_budget_allows(&self, key: CultMeshRudpSessionKey, bytes: usize) -> bool {
|
||||
|
|
@ -633,13 +703,48 @@ where
|
|||
.is_some_and(|total| total <= self.options.max_admitted_payload_bytes)
|
||||
}
|
||||
|
||||
fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> {
|
||||
/// A transient send failure is that peer's lost datagram: counted, never an
|
||||
/// error. A permanent one (`is_permanent_send_error`) is returned as `Some`
|
||||
/// for the caller to end the session over. Only an encode failure, the
|
||||
/// server's own bug, is an error.
|
||||
fn send_packet(
|
||||
&mut self,
|
||||
remote_addr: SocketAddr,
|
||||
packet: &CultNetRudpPacket,
|
||||
) -> Result<Option<std::io::Error>> {
|
||||
let wire = encode_rudp_packet(packet)?;
|
||||
self.socket.send_to(&wire, remote_addr)?;
|
||||
Ok(())
|
||||
match self.send_datagram(&wire, remote_addr) {
|
||||
Ok(_) => {}
|
||||
Err(error) if is_permanent_send_error(&error) => return Ok(Some(error)),
|
||||
Err(_) => self.send_failures += 1,
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn send_datagram(&mut self, wire: &[u8], remote_addr: SocketAddr) -> std::io::Result<usize> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
match self.unsendable_after.get(&remote_addr).copied() {
|
||||
Some(0) => {
|
||||
self.unsendable_after.remove(&remote_addr);
|
||||
return Err(std::io::Error::from(ErrorKind::InvalidInput));
|
||||
}
|
||||
Some(remaining) => {
|
||||
self.unsendable_after.insert(remote_addr, remaining - 1);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if self.failing_peers.contains(&remote_addr) {
|
||||
return Err(std::io::Error::other("injected send failure"));
|
||||
}
|
||||
}
|
||||
self.socket.send_to(wire, remote_addr)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod send_failure_tests;
|
||||
|
||||
fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> {
|
||||
if options.max_sessions == 0 {
|
||||
return Err(anyhow!("max_sessions must be greater than zero"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,446 @@
|
|||
//! One peer whose datagrams cannot be sent must not stop the server serving
|
||||
//! the others. The failure is injected in `send_packet` through
|
||||
//! `failing_peers`; a loopback peer cannot be made unroutable on demand.
|
||||
|
||||
use super::*;
|
||||
use cultnet_rs::{
|
||||
CultNetRawPayloadEncoding, CultNetRudpSocketMode, CultNetRudpSocketTransportConnection,
|
||||
CultNetRudpSocketTransportOptions,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Clock(Arc<AtomicU64>);
|
||||
|
||||
impl CultMeshRudpServerClock for Clock {
|
||||
fn now_unix_millis(&self) -> u64 {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
fn now_monotonic_millis(&self) -> u64 {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
type Received = Arc<Mutex<Vec<String>>>;
|
||||
type Server = CultMeshRudpDocumentServer<
|
||||
Box<dyn FnMut(CultMeshRudpRawDocumentReceipt) -> Result<()>>,
|
||||
Box<dyn FnMut(&CultMeshRudpSnapshotQuery) -> Result<Vec<CultNetRawDocumentRecord>>>,
|
||||
Clock,
|
||||
>;
|
||||
type Client = CultNetRudpSocketTransportConnection;
|
||||
|
||||
fn document(key: &str) -> CultNetRawDocumentRecord {
|
||||
CultNetRawDocumentRecord {
|
||||
schema_id: "test.raw.v1".into(),
|
||||
record_key: key.into(),
|
||||
stored_at: "2026-09-03T00:00:00Z".into(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: vec![0x81, 0xa1, 0x78, 0x2a],
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn client(target: SocketAddr, id: u32) -> (Client, SocketAddr) {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let client = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
||||
media_delivery: None,
|
||||
runtime_id: "test-client".into(),
|
||||
socket,
|
||||
mode: CultNetRudpSocketMode::Client,
|
||||
remote_addr: Some(target),
|
||||
connection_id: id,
|
||||
initial_sequence: Some(1),
|
||||
resend_delay_ms: 10,
|
||||
transport_id: None,
|
||||
max_payload_bytes: None,
|
||||
max_fragment_bytes: Some(1200),
|
||||
max_pending_reliable_packets: Some(64),
|
||||
media_reliable_expire_after_ms: None,
|
||||
reconnect_policy: None,
|
||||
})
|
||||
.unwrap();
|
||||
(client, addr)
|
||||
}
|
||||
|
||||
fn send(client: &mut Client, message: &CultNetMessage) {
|
||||
client
|
||||
.send(
|
||||
"schema",
|
||||
encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A server with a 1 s idle timeout and two connected peers, the first
|
||||
/// returned being the one whose address sorts first.
|
||||
fn two_peers() -> (
|
||||
Server,
|
||||
Clock,
|
||||
Received,
|
||||
(Client, SocketAddr),
|
||||
(Client, SocketAddr),
|
||||
) {
|
||||
let clock = Clock(Arc::new(AtomicU64::new(1_000)));
|
||||
let received: Received = Arc::default();
|
||||
let sink_received = received.clone();
|
||||
let sink: Box<dyn FnMut(CultMeshRudpRawDocumentReceipt) -> Result<()>> =
|
||||
Box::new(move |receipt| {
|
||||
sink_received.lock().unwrap().push(receipt.message_id);
|
||||
Ok(())
|
||||
});
|
||||
let source: Box<
|
||||
dyn FnMut(&CultMeshRudpSnapshotQuery) -> Result<Vec<CultNetRawDocumentRecord>>,
|
||||
> = Box::new(|_| Ok(vec![document("catalog")]));
|
||||
let mut server = CultMeshRudpDocumentServer::new(
|
||||
UdpSocket::bind("127.0.0.1:0").unwrap(),
|
||||
sink,
|
||||
source,
|
||||
clock.clone(),
|
||||
CultMeshRudpDocumentServerOptions {
|
||||
session_idle_timeout: Duration::from_secs(1),
|
||||
resend_delay: Duration::from_millis(10),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let target = server.local_addr().unwrap();
|
||||
let (mut a, a_addr) = client(target, 7);
|
||||
let (mut b, b_addr) = client(target, 7);
|
||||
a.connect(Vec::new()).unwrap();
|
||||
b.connect(Vec::new()).unwrap();
|
||||
for _ in 0..500 {
|
||||
server.poll_once().unwrap();
|
||||
a.receive_once().unwrap();
|
||||
b.receive_once().unwrap();
|
||||
if a.connected() && b.connected() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(a.connected() && b.connected());
|
||||
for _ in 0..4 {
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
assert_eq!(server.session_count(), 2);
|
||||
if a_addr < b_addr {
|
||||
(server, clock, received, (a, a_addr), (b, b_addr))
|
||||
} else {
|
||||
(server, clock, received, (b, b_addr), (a, a_addr))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_peer_whose_sends_fail_does_not_stop_the_server_serving_the_others() {
|
||||
let (mut server, clock, received, (mut x, x_addr), (mut y, _)) = two_peers();
|
||||
server.failing_peers.insert(x_addr);
|
||||
|
||||
// X's reply, ack and snapshot response all fail to send. X is visited first.
|
||||
send(
|
||||
&mut x,
|
||||
&CultNetMessage::SnapshotRequest {
|
||||
message_id: "x-snapshot".into(),
|
||||
schema_ids: None,
|
||||
record_keys: None,
|
||||
},
|
||||
);
|
||||
send(
|
||||
&mut y,
|
||||
&CultNetMessage::DocumentPutRaw {
|
||||
message_id: "y-put".into(),
|
||||
document: document("y"),
|
||||
},
|
||||
);
|
||||
send(
|
||||
&mut y,
|
||||
&CultNetMessage::SnapshotRequest {
|
||||
message_id: "y-snapshot".into(),
|
||||
schema_ids: None,
|
||||
record_keys: None,
|
||||
},
|
||||
);
|
||||
let mut y_response = None;
|
||||
for step in 0..200 {
|
||||
// The clock moves past X's resend delay so `maintain` has X's resend due.
|
||||
clock.0.store(1_000 + step * 15, Ordering::SeqCst);
|
||||
// Never `Err`: X's failed sends are its own lost datagrams.
|
||||
server.poll_once().unwrap();
|
||||
if let Some(frame) = y.receive_once().unwrap() {
|
||||
y_response = Some(frame);
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let frame = y_response.expect("Y receives its snapshot response");
|
||||
let response =
|
||||
decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response,
|
||||
CultNetMessage::SnapshotResponseRaw { ref message_id, .. } if message_id == "y-snapshot"
|
||||
));
|
||||
assert!(server.send_failures() > 0);
|
||||
assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]);
|
||||
assert!(server.maintain().is_ok());
|
||||
|
||||
// X's session ends by the idle rule that already ends sessions, not by the
|
||||
// failure: Y stays active, X does not.
|
||||
clock.0.store(1_900, Ordering::SeqCst);
|
||||
send(
|
||||
&mut y,
|
||||
&CultNetMessage::DocumentPutRaw {
|
||||
message_id: "y-put-2".into(),
|
||||
document: document("y2"),
|
||||
},
|
||||
);
|
||||
for _ in 0..20 {
|
||||
server.poll_once().unwrap();
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(server.session_count(), 2, "the failure ended no session");
|
||||
clock.0.store(2_500, Ordering::SeqCst);
|
||||
let maintenance = server.maintain().unwrap();
|
||||
assert_eq!(maintenance.sessions_expired, 1);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
}
|
||||
|
||||
// A permanent send failure is one that can never succeed for the datagram as
|
||||
// built (`is_permanent_send_error`). It ends that peer's session, never the
|
||||
// poll, and the peer's goodbye names the error.
|
||||
|
||||
fn put(id: &str) -> CultNetMessage {
|
||||
CultNetMessage::DocumentPutRaw {
|
||||
message_id: id.into(),
|
||||
document: document(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(id: &str) -> CultNetMessage {
|
||||
CultNetMessage::SnapshotRequest {
|
||||
message_id: id.into(),
|
||||
schema_ids: None,
|
||||
record_keys: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn goodbye_reason(client: &mut Client) -> Vec<u8> {
|
||||
for _ in 0..50 {
|
||||
client.receive_once().unwrap();
|
||||
if let Some(reason) = client.disconnect_reason() {
|
||||
return reason.to_vec();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
panic!("the client was never told the session ended");
|
||||
}
|
||||
|
||||
fn is_unsendable_reason(reason: &[u8]) -> bool {
|
||||
reason.starts_with(b"packet could not be sent: ")
|
||||
&& reason.len() > b"packet could not be sent: ".len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_acknowledging_a_document_ends_the_session_after_the_document_is_kept() {
|
||||
let (mut server, _clock, received, (mut x, x_addr), (mut y, _)) = two_peers();
|
||||
server.unsendable_after.insert(x_addr, 0);
|
||||
send(&mut x, &put("x-put"));
|
||||
send(&mut y, &put("y-put"));
|
||||
|
||||
for _ in 0..10 {
|
||||
// Never `Err`: the failure ends X's session, not the poll.
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
let mut kept = received.lock().unwrap().clone();
|
||||
kept.sort();
|
||||
assert_eq!(kept, ["x-put", "y-put"]);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
assert_eq!(server.send_failures(), 0);
|
||||
assert!(is_unsendable_reason(&goodbye_reason(&mut x)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_sending_a_snapshot_response_rejects_it_naming_the_error() {
|
||||
let (mut server, _clock, _received, (mut x, x_addr), (_y, _)) = two_peers();
|
||||
server.unsendable_after.insert(x_addr, 0);
|
||||
send(&mut x, &snapshot("x-snapshot"));
|
||||
|
||||
let mut rejection = None;
|
||||
for _ in 0..10 {
|
||||
if let CultMeshRudpPollOutcome::ApplicationRejected(found) = server.poll_once().unwrap() {
|
||||
rejection = Some(found);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rejection = rejection.expect("the response can never be sent");
|
||||
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::SnapshotRequest);
|
||||
assert_eq!(rejection.message_id, "x-snapshot");
|
||||
assert!(
|
||||
rejection
|
||||
.reason
|
||||
.starts_with("snapshot response could not be sent: ")
|
||||
&& rejection.reason.len() > "snapshot response could not be sent: ".len(),
|
||||
"{}",
|
||||
rejection.reason
|
||||
);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_in_resends_ends_only_that_peers_session() {
|
||||
let (mut server, clock, received, (mut x, x_addr), (mut y, _)) = two_peers();
|
||||
// X's snapshot response is lost, so a resend is due; then the datagram can
|
||||
// never be sent as built.
|
||||
server.failing_peers.insert(x_addr);
|
||||
send(&mut x, &snapshot("x-snapshot"));
|
||||
for _ in 0..5 {
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
server.failing_peers.clear();
|
||||
server.unsendable_after.insert(x_addr, 0);
|
||||
clock.0.store(1_200, Ordering::SeqCst);
|
||||
|
||||
let maintenance = server.maintain().unwrap();
|
||||
assert!(maintenance.packets_resent > 0);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
assert!(is_unsendable_reason(&goodbye_reason(&mut x)));
|
||||
|
||||
send(&mut y, &put("y-put"));
|
||||
for _ in 0..5 {
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connect_whose_accept_can_never_be_sent_starts_no_session() {
|
||||
let (mut server, _clock, _received, _x, _y) = two_peers();
|
||||
let (mut z, z_addr) = client(server.local_addr().unwrap(), 9);
|
||||
server.unsendable_after.insert(z_addr, 0);
|
||||
z.connect(Vec::new()).unwrap();
|
||||
|
||||
for _ in 0..10 {
|
||||
server.poll_once().unwrap();
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(server.session_count(), 2);
|
||||
assert_eq!(server.packets_dropped(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_sends_are_not_failures() {
|
||||
let (mut server, _clock, _received, (mut x, _), (_y, _)) = two_peers();
|
||||
send(&mut x, &put("x-put"));
|
||||
send(&mut x, &snapshot("x-snapshot"));
|
||||
for _ in 0..10 {
|
||||
server.poll_once().unwrap();
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(server.send_failures(), 0);
|
||||
assert_eq!(server.session_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_ended_in_the_resend_loop_is_sent_nothing_more_from_it() {
|
||||
let (mut server, clock, _received, (mut x, x_addr), (_y, _)) = two_peers();
|
||||
server.failing_peers.insert(x_addr);
|
||||
send(&mut x, &snapshot("one"));
|
||||
send(&mut x, &snapshot("two"));
|
||||
for _ in 0..10 {
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
let before = server.send_failures();
|
||||
|
||||
server.unsendable_after.insert(x_addr, 0);
|
||||
clock.0.store(1_200, Ordering::SeqCst);
|
||||
let maintenance = server.maintain().unwrap();
|
||||
|
||||
// Both responses are due. The first can never be sent and ends the session;
|
||||
// the goodbye is the only other datagram attempted at X.
|
||||
assert_eq!(maintenance.packets_resent, 2);
|
||||
assert_eq!(server.send_failures() - before, 1);
|
||||
assert_eq!(server.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ack_follows_a_reply_that_can_never_be_sent() {
|
||||
let (mut server, _clock, _received, _x, _y) = two_peers();
|
||||
let target = server.local_addr().unwrap();
|
||||
// A peer driven packet by packet: a client never sends a reliable Ping, but a
|
||||
// peer may, and the server both answers it and acknowledges it.
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(20)))
|
||||
.unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: 7,
|
||||
..Default::default()
|
||||
});
|
||||
let connect = session.create_connect(0, Vec::new()).unwrap();
|
||||
socket
|
||||
.send_to(&encode_rudp_packet(&connect).unwrap(), target)
|
||||
.unwrap();
|
||||
let mut wire = vec![0_u8; MAX_UDP_DATAGRAM_BYTES];
|
||||
for _ in 0..10 {
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
let (read, _) = socket.recv_from(&mut wire).unwrap();
|
||||
let accept = decode_rudp_packet(&wire[..read]).unwrap();
|
||||
assert_eq!(accept.packet_type, CultNetRudpPacketType::Accept);
|
||||
session.receive(&accept, 0).unwrap();
|
||||
assert_eq!(server.session_count(), 3);
|
||||
|
||||
let mut ping = session.create_ping(b"p".to_vec());
|
||||
ping.reliable = true;
|
||||
ping.sequence = connect.sequence.wrapping_add(1);
|
||||
// The Pong can never be sent; the ack after it could.
|
||||
server.unsendable_after.insert(addr, 0);
|
||||
socket
|
||||
.send_to(&encode_rudp_packet(&ping).unwrap(), target)
|
||||
.unwrap();
|
||||
for _ in 0..10 {
|
||||
server.poll_once().unwrap();
|
||||
}
|
||||
|
||||
// The session ends over the Pong: the peer gets its goodbye and nothing else.
|
||||
assert_eq!(server.session_count(), 2);
|
||||
let mut arrived = Vec::new();
|
||||
while let Ok((read, _)) = socket.recv_from(&mut wire) {
|
||||
arrived.push(decode_rudp_packet(&wire[..read]).unwrap());
|
||||
}
|
||||
assert_eq!(arrived.len(), 1, "{arrived:?}");
|
||||
assert_eq!(arrived[0].packet_type, CultNetRudpPacketType::Disconnect);
|
||||
assert!(is_unsendable_reason(&arrived[0].payload));
|
||||
}
|
||||
|
||||
/// Windows reports an ICMP port-unreachable for an earlier datagram as
|
||||
/// `ConnectionReset` on the next receive. It names no datagram of this poll, so
|
||||
/// the poll is idle and the server goes on serving. Only Windows reports it, so
|
||||
/// only a Windows run can see the rule break.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_reset_reported_by_a_receive_is_idle_not_an_error() {
|
||||
let (mut server, _clock, received, (mut x, _), (mut y, _)) = two_peers();
|
||||
// X asks for a snapshot and goes away before the answer.
|
||||
send(&mut x, &snapshot("x-snapshot"));
|
||||
drop(x);
|
||||
for _ in 0..20 {
|
||||
server
|
||||
.poll_once()
|
||||
.expect("a reset from X's closed port is not an error");
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
send(&mut y, &put("y-put"));
|
||||
for _ in 0..20 {
|
||||
server.poll_once().unwrap();
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]);
|
||||
}
|
||||
|
|
@ -22,7 +22,9 @@ import {
|
|||
defineCultNetDocumentBinding,
|
||||
encodeCultNetMessageForWire,
|
||||
encodeRudpPacket,
|
||||
isPermanentSendError,
|
||||
parseCultNetMessage,
|
||||
sendRudpDatagram,
|
||||
type CultNetErrorMessage,
|
||||
type CultNetDocumentBinding,
|
||||
type CultNetMessage,
|
||||
|
|
@ -30,6 +32,7 @@ import {
|
|||
type CultNetOperationResponseMessage,
|
||||
type CultNetRawDocumentRecord,
|
||||
type CultNetReconnectPolicy,
|
||||
type CultNetRudpPacket,
|
||||
type CultNetSchemaCatalogOptions,
|
||||
type CultNetSnapshotResponseRawMessage,
|
||||
type CultNetWireContract,
|
||||
|
|
@ -4307,6 +4310,12 @@ export interface CultMeshRudpServerSession {
|
|||
|
||||
export interface CultMeshRudpDocumentServer {
|
||||
readonly bind: { host: string; port: number };
|
||||
/**
|
||||
* Datagrams that could not be sent to a peer. Each is that peer's lost datagram: a
|
||||
* reliable packet stays pending and is resent, and no other peer is affected. A datagram
|
||||
* that can never be sent as built is not counted here: it ends that peer's session.
|
||||
*/
|
||||
readonly sendFailures: number;
|
||||
start(): Promise<void>;
|
||||
close(): void;
|
||||
}
|
||||
|
|
@ -5767,15 +5776,19 @@ export class CultMesh {
|
|||
const port = options.bindPort ?? 0;
|
||||
const bind = { host, port };
|
||||
const socket = options.socket ?? createSocket(host.includes(":") ? "udp6" : "udp4");
|
||||
const sessions = new Map<string, {
|
||||
type SessionRecord = {
|
||||
sessionId: string;
|
||||
remote: { address: string; family: string; port: number };
|
||||
session: CultNetRudpSession;
|
||||
connectPayload: Uint8Array;
|
||||
closed: boolean;
|
||||
/** A packet it owed could never be sent, so the session ended. */
|
||||
unsendable: boolean;
|
||||
work: Promise<void>;
|
||||
}>();
|
||||
};
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
let sessionSequence = 0;
|
||||
let sendFailures = 0;
|
||||
const resendPollMs = Math.max(10, options.resendPollMs ?? 25);
|
||||
const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000);
|
||||
const wireContract = options.wireContract ?? "cultnet.schema.v0";
|
||||
|
|
@ -5800,19 +5813,15 @@ export class CultMesh {
|
|||
if (packet.packetType === "connect") {
|
||||
const connectPayload = Uint8Array.from(packet.payload ?? []);
|
||||
if (record?.session.connectRepeats(packet)) {
|
||||
socket.send(
|
||||
encodeRudpPacket(record.session.acceptConnect(packet, nowMs)),
|
||||
remote.port,
|
||||
remote.address,
|
||||
);
|
||||
sendPacket(record, record.session.acceptConnect(packet, nowMs));
|
||||
return;
|
||||
}
|
||||
if (record) closeSession(record);
|
||||
record = {
|
||||
const next: SessionRecord = {
|
||||
sessionId: `${runtimeId}:${++sessionSequence}`,
|
||||
remote: { address: remote.address, family: remote.family, port: remote.port },
|
||||
connectPayload,
|
||||
closed: false,
|
||||
unsendable: false,
|
||||
session: new CultNetRudpSession({
|
||||
connectionId,
|
||||
initialSequence: options.initialSequence,
|
||||
|
|
@ -5821,8 +5830,12 @@ export class CultMesh {
|
|||
}),
|
||||
work: Promise.resolve(),
|
||||
};
|
||||
sessions.set(key, record);
|
||||
socket.send(encodeRudpPacket(record.session.acceptConnect(packet, nowMs)), remote.port, remote.address);
|
||||
sendPacket(next, next.session.acceptConnect(packet, nowMs));
|
||||
// A peer whose Accept can never be sent cannot be answered: no session
|
||||
// starts, and one already there is left as it was.
|
||||
if (next.unsendable) return;
|
||||
if (record) closeSession(record);
|
||||
sessions.set(key, next);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -5830,12 +5843,22 @@ export class CultMesh {
|
|||
return;
|
||||
}
|
||||
|
||||
const result = record.session.receive(packet, nowMs);
|
||||
let result: ReturnType<CultNetRudpSession["receive"]>;
|
||||
try {
|
||||
result = record.session.receive(packet, nowMs);
|
||||
} catch {
|
||||
// receive() has already recorded the packet's reliable sequence, so the
|
||||
// session cannot be kept: a retransmit would be acknowledged and the frame
|
||||
// silently lost. It ends, never the server, and the peer is told. What a
|
||||
// datagram carries is the sender's business, so nothing is reported.
|
||||
endRefusedSession(key, record);
|
||||
return;
|
||||
}
|
||||
if (result.reply) {
|
||||
socket.send(encodeRudpPacket(result.reply), record.remote.port, record.remote.address);
|
||||
sendPacket(record, result.reply);
|
||||
}
|
||||
for (const ready of result.readyToSend ?? []) {
|
||||
socket.send(encodeRudpPacket(ready), record.remote.port, record.remote.address);
|
||||
sendPacket(record, ready);
|
||||
}
|
||||
for (const frame of result.delivered) {
|
||||
if (frame.channelId !== "schema") {
|
||||
|
|
@ -5851,11 +5874,7 @@ export class CultMesh {
|
|||
return;
|
||||
}
|
||||
if (packet.reliable) {
|
||||
socket.send(
|
||||
encodeRudpPacket(record.session.createAckForReceived(packet.sequence)),
|
||||
record.remote.port,
|
||||
record.remote.address,
|
||||
);
|
||||
sendPacket(record, record.session.createAckForReceived(packet.sequence));
|
||||
}
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
|
|
@ -5864,7 +5883,7 @@ export class CultMesh {
|
|||
socket.on("error", reportError);
|
||||
|
||||
async function handleDocumentServerFrame(
|
||||
record: { sessionId: string; remote: { address: string; family: string; port: number }; session: CultNetRudpSession; connectPayload: Uint8Array; closed: boolean },
|
||||
record: SessionRecord,
|
||||
payload: Uint8Array,
|
||||
): Promise<void> {
|
||||
if (record.closed) return;
|
||||
|
|
@ -5926,13 +5945,7 @@ export class CultMesh {
|
|||
}
|
||||
}
|
||||
|
||||
function publicSession(record: {
|
||||
sessionId: string;
|
||||
remote: { address: string; family: string; port: number };
|
||||
session: CultNetRudpSession;
|
||||
connectPayload: Uint8Array;
|
||||
closed: boolean;
|
||||
}): CultMeshRudpServerSession {
|
||||
function publicSession(record: SessionRecord): CultMeshRudpServerSession {
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
remote: { ...record.remote },
|
||||
|
|
@ -5944,31 +5957,58 @@ export class CultMesh {
|
|||
};
|
||||
}
|
||||
|
||||
function notifySessionClosed(record: {
|
||||
sessionId: string;
|
||||
remote: { address: string; family: string; port: number };
|
||||
session: CultNetRudpSession;
|
||||
connectPayload: Uint8Array;
|
||||
closed: boolean;
|
||||
}): void {
|
||||
function notifySessionClosed(record: SessionRecord): void {
|
||||
if (!options.onSessionClosed) return;
|
||||
void Promise.resolve(options.onSessionClosed(publicSession(record))).catch(reportError);
|
||||
}
|
||||
|
||||
function closeSession(record: {
|
||||
sessionId: string;
|
||||
remote: { address: string; family: string; port: number };
|
||||
session: CultNetRudpSession;
|
||||
connectPayload: Uint8Array;
|
||||
closed: boolean;
|
||||
}): void {
|
||||
function closeSession(record: SessionRecord): void {
|
||||
if (record.closed) return;
|
||||
record.closed = true;
|
||||
notifySessionClosed(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one packet to a session's peer. A failure is that peer's alone: one that may
|
||||
* pass (no route, full buffers) is a lost datagram, counted, and resent if it was
|
||||
* reliable; one that can never pass as built ends that peer's session. Neither ends
|
||||
* the server, a timer or another peer's session.
|
||||
*/
|
||||
function sendPacket(record: SessionRecord, packet: CultNetRudpPacket): void {
|
||||
sendRudpDatagram(socket, encodeRudpPacket(packet), record.remote.port, record.remote.address, (error) => {
|
||||
if (isPermanentSendError(error)) {
|
||||
endUnsendableSession(record, error);
|
||||
} else {
|
||||
sendFailures += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function endRefusedSession(key: string, record: SessionRecord): void {
|
||||
sessions.delete(key);
|
||||
closeSession(record);
|
||||
// The goodbye is built after the reset, or its ack field would acknowledge the
|
||||
// very frame the session refused.
|
||||
record.session.resetPeerState();
|
||||
const goodbye = encodeRudpPacket(record.session.createDisconnect(Buffer.from("session refused a packet", "utf8")));
|
||||
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
|
||||
}
|
||||
|
||||
/** The session owes its peer a packet that can never be sent as built. */
|
||||
function endUnsendableSession(record: SessionRecord, error: Error): void {
|
||||
if (record.unsendable) return;
|
||||
record.unsendable = true;
|
||||
const key = `${record.remote.address}:${record.remote.port}`;
|
||||
// A session that never started, or was already replaced, has nothing to end.
|
||||
if (sessions.get(key) !== record) return;
|
||||
sessions.delete(key);
|
||||
closeSession(record);
|
||||
const goodbye = encodeRudpPacket(record.session.endUnsendableSession(error));
|
||||
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
|
||||
}
|
||||
|
||||
function sendSchemaMessage(
|
||||
record: { sessionId: string; remote: { address: string; port: number }; session: CultNetRudpSession; closed: boolean },
|
||||
record: SessionRecord,
|
||||
message: CultNetMessage,
|
||||
): void {
|
||||
if (record.closed) throw new Error(`CultMesh RUDP session ${record.sessionId} is closed.`);
|
||||
|
|
@ -5978,12 +6018,15 @@ export class CultMesh {
|
|||
ordered: true,
|
||||
nowMs: Date.now(),
|
||||
})) {
|
||||
socket.send(encodeRudpPacket(packet), record.remote.port, record.remote.address);
|
||||
sendPacket(record, packet);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bind,
|
||||
get sendFailures() {
|
||||
return sendFailures;
|
||||
},
|
||||
async start(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
|
|
@ -6005,7 +6048,7 @@ export class CultMesh {
|
|||
continue;
|
||||
}
|
||||
for (const packet of record.session.dueResends(nowMs)) {
|
||||
socket.send(encodeRudpPacket(packet), record.remote.port, record.remote.address);
|
||||
sendPacket(record, packet);
|
||||
}
|
||||
}
|
||||
}, resendPollMs);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
// A datagram the document server cannot send to one peer is that peer's problem
|
||||
// alone. It never escapes a timer or the receive handler, never stops the server
|
||||
// serving the others, and only a datagram that can never be sent as built ends
|
||||
// the session that owes it.
|
||||
import assert from "node:assert/strict";
|
||||
import { createSocket, type RemoteInfo, type Socket } from "node:dgram";
|
||||
import test from "node:test";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
type CultNetPeer,
|
||||
} from "cultnet-ts";
|
||||
import { CultMesh } from "../src/index";
|
||||
|
||||
async function waitFor(predicate: () => boolean, description: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - startedAt > 3_000) {
|
||||
throw new Error(`Timed out waiting for ${description}.`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function watchUncaught(): { errors: unknown[]; stop: () => void } {
|
||||
const errors: unknown[] = [];
|
||||
const listener = (error: unknown) => errors.push(error);
|
||||
process.on("uncaughtException", listener);
|
||||
return { errors, stop: () => process.off("uncaughtException", listener) };
|
||||
}
|
||||
|
||||
function put(recordKey: string) {
|
||||
return {
|
||||
schemaVersion: "cultnet.document_put_raw.v0" as const,
|
||||
messageId: `put-${recordKey}`,
|
||||
document: {
|
||||
schemaId: "cultmesh.note.v0",
|
||||
recordKey,
|
||||
storedAt: new Date().toISOString(),
|
||||
payloadEncoding: "messagepack" as const,
|
||||
payload: encode(recordKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Server {
|
||||
socket: Socket;
|
||||
server: ReturnType<typeof CultMesh.createRudpDocumentServer>;
|
||||
/** The record key of every put received, with the port it came from. */
|
||||
puts: Map<string, number>;
|
||||
closed: number[];
|
||||
errors: Error[];
|
||||
peer(name: string): Promise<CultNetPeer>;
|
||||
}
|
||||
|
||||
async function startServer(connectionId: number, socket: Socket): Promise<Server> {
|
||||
const puts = new Map<string, number>();
|
||||
const closed: number[] = [];
|
||||
const errors: Error[] = [];
|
||||
const server = CultMesh.createRudpDocumentServer("send-failure-server", connectionId, {
|
||||
documents: new CultNetDocumentRegistry(),
|
||||
socket,
|
||||
bindHost: "127.0.0.1",
|
||||
bindPort: 0,
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 10,
|
||||
sessionTimeoutMs: 1_000,
|
||||
onDocumentPutRaw: (document) => {
|
||||
puts.set(document.recordKey, document.remote.port);
|
||||
},
|
||||
onSessionClosed: (session) => {
|
||||
closed.push(session.remote.port);
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
await server.start();
|
||||
const peer = (name: string) =>
|
||||
CultMesh.createRudpPeer(name, connectionId, `rudp://127.0.0.1:${server.bind.port}`, {
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 5,
|
||||
connectTimeoutMs: 2_000,
|
||||
});
|
||||
return { socket, server, puts, closed, errors, peer };
|
||||
}
|
||||
|
||||
test("a Connect whose Accept can never be sent starts no session, and the server keeps serving", async () => {
|
||||
const connectionId = 0x10203091;
|
||||
const uncaught = watchUncaught();
|
||||
const socket = createSocket("udp4");
|
||||
const served = await startServer(connectionId, socket);
|
||||
let peer: CultNetPeer | undefined;
|
||||
try {
|
||||
// A Connect as it arrives from UDP source port 0, which Node refuses to answer.
|
||||
const wire = Buffer.from(encodeRudpPacket(new CultNetRudpSession({ connectionId }).createConnect(Date.now())));
|
||||
const remote: RemoteInfo = { address: "127.0.0.1", family: "IPv4", port: 0, size: wire.length };
|
||||
socket.emit("message", wire, remote);
|
||||
// Past the idle timeout: a session that had started would have been closed by now.
|
||||
await sleep(1_300);
|
||||
|
||||
peer = await served.peer("after-port-zero");
|
||||
peer.send(put("after"));
|
||||
await waitFor(() => served.puts.has("after"), "the real peer's put");
|
||||
assert.deepEqual(served.closed, [], "no session started for port 0");
|
||||
assert.deepEqual(served.errors.map((error) => error.message), []);
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
peer?.close();
|
||||
served.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a datagram that may yet pass is counted; one that never can ends only that peer's session", async () => {
|
||||
const connectionId = 0x10203092;
|
||||
const socket = createSocket("udp4");
|
||||
// Sends to a failing port report the failure the way Node reports a system one:
|
||||
// later, through the send's callback.
|
||||
const failing = new Map<number, string>();
|
||||
const send = socket.send.bind(socket) as (...args: unknown[]) => void;
|
||||
Object.assign(socket, {
|
||||
send: (message: Uint8Array, port: number, address: string, callback?: (error: Error | null) => void) => {
|
||||
const code = failing.get(port);
|
||||
if (code === undefined) {
|
||||
send(message, port, address, callback);
|
||||
return;
|
||||
}
|
||||
process.nextTick(() => callback?.(Object.assign(new Error(`send failed: ${code}`), { code })));
|
||||
},
|
||||
});
|
||||
const served = await startServer(connectionId, socket);
|
||||
let x: CultNetPeer | undefined;
|
||||
let y: CultNetPeer | undefined;
|
||||
try {
|
||||
x = await served.peer("x");
|
||||
y = await served.peer("y");
|
||||
x.send(put("x-1"));
|
||||
await waitFor(() => served.puts.has("x-1"), "X's first put");
|
||||
const xPort = served.puts.get("x-1")!;
|
||||
|
||||
// No route: X's acknowledgements are lost datagrams, counted, never reported as errors,
|
||||
// and end nothing.
|
||||
assert.equal(served.server.sendFailures, 0);
|
||||
failing.set(xPort, "EHOSTUNREACH");
|
||||
x.send(put("x-2"));
|
||||
await waitFor(() => served.server.sendFailures >= 2, "the counted losses");
|
||||
y.send(put("y-1"));
|
||||
await waitFor(() => served.puts.has("y-1"), "Y's put");
|
||||
assert.deepEqual(served.closed, [], "a loss that may pass ends no session");
|
||||
assert.deepEqual(served.errors.map((error) => error.message), [], "a lost datagram is not an error");
|
||||
|
||||
// X's retransmitted put is acknowledged again, and that datagram can never be sent.
|
||||
failing.set(xPort, "EMSGSIZE");
|
||||
await waitFor(() => served.closed.length > 0, "X's session to end");
|
||||
assert.deepEqual(served.closed, [xPort]);
|
||||
assert.equal(
|
||||
served.errors.some((error) => (error as NodeJS.ErrnoException).code === "EMSGSIZE"),
|
||||
false,
|
||||
"a datagram that can never be sent ends the session instead of being reported",
|
||||
);
|
||||
|
||||
y.send(put("y-2"));
|
||||
await waitFor(() => served.puts.has("y-2"), "Y's put after X ended");
|
||||
assert.deepEqual(served.closed, [xPort]);
|
||||
} finally {
|
||||
x?.close();
|
||||
y?.close();
|
||||
served.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a packet the session refuses ends only that peer's session, and the peer is told", async () => {
|
||||
const connectionId = 0x10203093;
|
||||
const socket = createSocket("udp4");
|
||||
const served = await startServer(connectionId, socket);
|
||||
const raw = createSocket("udp4");
|
||||
await new Promise<void>((resolve) => raw.bind(0, "127.0.0.1", resolve));
|
||||
const arrived: ReturnType<typeof decodeRudpPacket>[] = [];
|
||||
raw.on("message", (wire) => arrived.push(decodeRudpPacket(wire)));
|
||||
let y: CultNetPeer | undefined;
|
||||
try {
|
||||
const session = new CultNetRudpSession({ connectionId });
|
||||
raw.send(encodeRudpPacket(session.createConnect(Date.now())), served.server.bind.port, "127.0.0.1");
|
||||
await waitFor(() => arrived.some((packet) => packet.packetType === "accept"), "the Accept");
|
||||
session.receive(arrived.find((packet) => packet.packetType === "accept")!, Date.now());
|
||||
y = await served.peer("y");
|
||||
|
||||
// A reliable fragment whose metadata the session refuses: fragment id 0 names no set.
|
||||
// The session records its sequence before it refuses it.
|
||||
const [fragment] = session.sendMany("schema", new Uint8Array([1]), { reliable: true, ordered: true });
|
||||
raw.send(
|
||||
encodeRudpPacket({ ...fragment!, fragmentId: 0, fragmentIndex: 0, fragmentCount: 2 }),
|
||||
served.server.bind.port,
|
||||
"127.0.0.1",
|
||||
);
|
||||
await waitFor(() => arrived.some((packet) => packet.packetType === "disconnect"), "the goodbye");
|
||||
const goodbye = arrived.find((packet) => packet.packetType === "disconnect")!;
|
||||
assert.equal(Buffer.from(goodbye.payload ?? []).toString("utf8"), "session refused a packet");
|
||||
// The goodbye's ack field does not acknowledge the refused frame, or the peer would
|
||||
// count as delivered a frame that was never handled.
|
||||
const acknowledged =
|
||||
goodbye.ack === fragment!.sequence ||
|
||||
(goodbye.ack > fragment!.sequence &&
|
||||
goodbye.ack - fragment!.sequence <= 32 &&
|
||||
(goodbye.ackMask & (1 << (goodbye.ack - fragment!.sequence - 1))) !== 0);
|
||||
assert.equal(acknowledged, false, "the goodbye acknowledged the refused frame");
|
||||
assert.deepEqual(served.closed, [(raw.address() as { port: number }).port]);
|
||||
|
||||
y.send(put("y-after"));
|
||||
await waitFor(() => served.puts.has("y-after"), "Y's put");
|
||||
assert.deepEqual(served.errors.map((error) => error.message), []);
|
||||
} finally {
|
||||
y?.close();
|
||||
raw.close();
|
||||
served.server.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -123,6 +123,7 @@ from .transport import (
|
|||
TcpFramedTransportConnection,
|
||||
compute_reconnect_delay_ms,
|
||||
create_reconnect_policy,
|
||||
is_permanent_send_error,
|
||||
create_rudp_transport_profile,
|
||||
create_tcp_framed_transport_profile,
|
||||
decode_rudp_packet,
|
||||
|
|
@ -209,6 +210,7 @@ __all__ = [
|
|||
"CultNetRudpReceiveResult",
|
||||
"CultNetRudpSendOptions",
|
||||
"CultNetRudpSession",
|
||||
"is_permanent_send_error",
|
||||
"CultNetRudpSessionOptions",
|
||||
"CultNetRudpSocketMode",
|
||||
"CultNetRudpReconnectLoop",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
|
|
@ -435,6 +436,32 @@ 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),
|
||||
)
|
||||
if code is not None
|
||||
)
|
||||
|
||||
|
||||
def is_permanent_send_error(error: OSError) -> bool:
|
||||
"""Whether a failed `sendto` can never succeed for the datagram as built, so resending
|
||||
it is pointless: it is too large for a datagram (EMSGSIZE), its address is malformed for
|
||||
the socket (EINVAL), or belongs to another address family (EAFNOSUPPORT), with their
|
||||
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
|
||||
|
||||
|
||||
class CultNetRudpSession:
|
||||
RELIABLE_SEND_WINDOW_PACKETS = 32
|
||||
RECEIVED_SEQUENCE_WINDOW = 4_096
|
||||
|
|
@ -841,6 +868,14 @@ class CultNetRudpSession:
|
|||
def create_ping(self, payload: bytes = b"") -> CultNetRudpPacket:
|
||||
return self._create_packet(CultNetRudpPacketType.PING, "control", payload)
|
||||
|
||||
def end_unsendable_session(self, error: OSError) -> CultNetRudpPacket:
|
||||
"""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."""
|
||||
self.reset_peer_state()
|
||||
return self.create_disconnect(f"{UNSENDABLE_PACKET_REASON}: {error}".encode("utf-8"))
|
||||
|
||||
def create_disconnect(self, reason: bytes = b"") -> CultNetRudpPacket:
|
||||
self._end_session()
|
||||
return self._create_packet(CultNetRudpPacketType.DISCONNECT, "control", reason)
|
||||
|
|
|
|||
|
|
@ -879,6 +879,28 @@ class CultNetTests(unittest.TestCase):
|
|||
self.assertEqual(old_ack.ack, oldest_sequence)
|
||||
self.assertEqual(old_ack.ack_mask, 0)
|
||||
|
||||
def test_cultnet_rudp_only_a_datagram_that_can_never_be_sent_is_a_permanent_failure(self) -> None:
|
||||
import errno
|
||||
|
||||
from cultnet_py import is_permanent_send_error
|
||||
|
||||
for code in (errno.EMSGSIZE, errno.EINVAL, errno.EAFNOSUPPORT):
|
||||
self.assertTrue(is_permanent_send_error(OSError(code, "permanent")), errno.errorcode[code])
|
||||
for code in (
|
||||
errno.EPERM,
|
||||
errno.EINTR,
|
||||
errno.EAGAIN,
|
||||
errno.EACCES,
|
||||
errno.ENETDOWN,
|
||||
errno.ENETUNREACH,
|
||||
errno.EADDRNOTAVAIL,
|
||||
errno.ENOBUFS,
|
||||
errno.ECONNREFUSED,
|
||||
errno.EHOSTUNREACH,
|
||||
):
|
||||
self.assertFalse(is_permanent_send_error(OSError(code, "transient")), errno.errorcode[code])
|
||||
self.assertFalse(is_permanent_send_error(OSError("no errno")))
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ use crate::create_reconnect_policy;
|
|||
use crate::decode_cultnet_message_from_slice;
|
||||
use crate::encode_cultnet_message_to_vec;
|
||||
|
||||
#[cfg(test)]
|
||||
mod send_failure_tests;
|
||||
|
||||
const RUDP_MAGIC: [u8; 4] = [0x43, 0x4e, 0x52, 0x30];
|
||||
const RUDP_VERSION: u8 = 0;
|
||||
pub(crate) const RUDP_FIXED_HEADER_BYTES: usize = 36;
|
||||
|
|
@ -465,6 +468,28 @@ impl CultNetRudpSession {
|
|||
self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Undoes a send that never reached the wire: every packet it created at
|
||||
/// or after `first_sequence`, pending or queued, leaves no trace and its
|
||||
/// sequences are reissued, so the peer never sees a gap. Only valid while
|
||||
/// nothing else has been sent since.
|
||||
fn withdraw_send(&mut self, first_sequence: u32) {
|
||||
self.pending_reliable
|
||||
.retain(|sequence, _| *sequence < first_sequence);
|
||||
self.queued_reliable
|
||||
.retain(|(packet, _)| packet.sequence < first_sequence);
|
||||
self.next_sequence = first_sequence;
|
||||
}
|
||||
|
||||
pub fn create_connect(&mut self, now_ms: u64, payload: Vec<u8>) -> Result<CultNetRudpPacket> {
|
||||
// A session that has had a peer starts a new generation: nothing it
|
||||
// learned from that peer describes the one this Connect reaches, and
|
||||
|
|
@ -1621,6 +1646,13 @@ pub struct CultNetRudpServerHub {
|
|||
pending_events: VecDeque<CultNetRudpServerEvent>,
|
||||
pub profile: CultNetTransportProfile,
|
||||
stats: CultNetTransportStats,
|
||||
/// Peers whose every datagram fails to send, standing in for an unroutable
|
||||
/// or full path that a loopback peer cannot be made to have.
|
||||
#[cfg(test)]
|
||||
failing_peers: BTreeSet<SocketAddr>,
|
||||
/// Peers whose next datagram after this many fails permanently, once.
|
||||
#[cfg(test)]
|
||||
unsendable_after: BTreeMap<SocketAddr, usize>,
|
||||
}
|
||||
|
||||
impl CultNetRudpServerHub {
|
||||
|
|
@ -1669,6 +1701,10 @@ impl CultNetRudpServerHub {
|
|||
pending_events: VecDeque::new(),
|
||||
profile,
|
||||
stats: CultNetTransportStats::default(),
|
||||
#[cfg(test)]
|
||||
failing_peers: BTreeSet::new(),
|
||||
#[cfg(test)]
|
||||
unsendable_after: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1709,7 +1745,7 @@ impl CultNetRudpServerHub {
|
|||
payload: Vec<u8>,
|
||||
) -> Result<()> {
|
||||
let remote_addr = session.remote_addr;
|
||||
let packets = {
|
||||
let (first_sequence, packets) = {
|
||||
let peer = self
|
||||
.peers
|
||||
.get_mut(&remote_addr)
|
||||
|
|
@ -1720,7 +1756,8 @@ impl CultNetRudpServerHub {
|
|||
session.session_generation
|
||||
));
|
||||
}
|
||||
peer.session.send_many(
|
||||
let first_sequence = peer.session.next_sequence;
|
||||
let packets = peer.session.send_many(
|
||||
channel_id,
|
||||
payload,
|
||||
channel_send_options(
|
||||
|
|
@ -1730,10 +1767,25 @@ impl CultNetRudpServerHub {
|
|||
channel_delivery(&self.profile, channel_id),
|
||||
),
|
||||
self.max_fragment_bytes,
|
||||
)?
|
||||
)?;
|
||||
(first_sequence, packets)
|
||||
};
|
||||
for packet in packets {
|
||||
self.send_packet(remote_addr, &packet)?;
|
||||
for (index, packet) in packets.iter().enumerate() {
|
||||
let Some(error) = self.send_packet(remote_addr, packet)? else {
|
||||
continue;
|
||||
};
|
||||
// A caller-directed send that can never succeed is the caller's
|
||||
// error and queues nothing. Only when earlier fragments are already
|
||||
// on the wire has the peer seen part of it, and then the session
|
||||
// cannot be kept.
|
||||
if index == 0 {
|
||||
if let Some(peer) = self.peers.get_mut(&remote_addr) {
|
||||
peer.session.withdraw_send(first_sequence);
|
||||
}
|
||||
} else {
|
||||
self.end_unsendable_peer(remote_addr, &error)?;
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
self.stats.frames_sent += 1;
|
||||
Ok(())
|
||||
|
|
@ -1764,7 +1816,9 @@ impl CultNetRudpServerHub {
|
|||
.remove(&session.remote_addr)
|
||||
.expect("validated RUDP peer exists");
|
||||
let packet = peer.session.create_disconnect(reason);
|
||||
self.send_packet(session.remote_addr, &packet)?;
|
||||
if let Some(error) = self.send_packet(session.remote_addr, &packet)? {
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
@ -1822,7 +1876,7 @@ impl CultNetRudpServerHub {
|
|||
self.end_refusing_peer(remote_addr)?;
|
||||
return Ok(true);
|
||||
};
|
||||
self.send_packet(remote_addr, &reply)?;
|
||||
self.send_in_poll(remote_addr, &reply)?;
|
||||
return Ok(true);
|
||||
}
|
||||
if !self.peers.contains_key(&remote_addr) && self.peers.len() >= self.max_peers {
|
||||
|
|
@ -1851,7 +1905,11 @@ impl CultNetRudpServerHub {
|
|||
self.stats.packets_dropped += 1;
|
||||
return Ok(true);
|
||||
};
|
||||
self.send_packet(remote_addr, &accept)?;
|
||||
if self.send_packet(remote_addr, &accept)?.is_some() {
|
||||
// The peer cannot be answered, so no session starts.
|
||||
self.stats.packets_dropped += 1;
|
||||
return Ok(true);
|
||||
}
|
||||
let replaced = self.peers.insert(
|
||||
remote_addr,
|
||||
CultNetRudpServerPeer {
|
||||
|
|
@ -1885,14 +1943,17 @@ impl CultNetRudpServerHub {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reply) = result.reply {
|
||||
self.send_packet(remote_addr, &reply)?;
|
||||
}
|
||||
for ready in result.ready_to_send {
|
||||
self.send_packet(remote_addr, &ready)?;
|
||||
}
|
||||
if let Some(ack) = ack {
|
||||
self.send_packet(remote_addr, &ack)?;
|
||||
let mut unsendable = None;
|
||||
for packet in result
|
||||
.reply
|
||||
.iter()
|
||||
.chain(&result.ready_to_send)
|
||||
.chain(ack.iter())
|
||||
{
|
||||
unsendable = self.send_packet(remote_addr, packet)?;
|
||||
if unsendable.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if result.pong {
|
||||
self.pending_events.push_back(CultNetRudpServerEvent::Pong {
|
||||
|
|
@ -1918,6 +1979,8 @@ impl CultNetRudpServerHub {
|
|||
reason: result.disconnect_reason,
|
||||
});
|
||||
self.peers.remove(&remote_addr);
|
||||
} else if let Some(error) = unsendable {
|
||||
self.end_unsendable_peer(remote_addr, &error)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
|
@ -1937,7 +2000,37 @@ impl CultNetRudpServerHub {
|
|||
session: peer.context,
|
||||
reason: goodbye.payload.clone(),
|
||||
});
|
||||
self.send_packet(remote_addr, &goodbye)
|
||||
self.send_packet(remote_addr, &goodbye)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An admitted peer owes a packet that can never be sent as built. Inside a
|
||||
/// poll that ends the peer's session, never the poll: the peer is told, and
|
||||
/// the caller sees `Disconnected` with a reason that names the error.
|
||||
fn end_unsendable_peer(
|
||||
&mut self,
|
||||
remote_addr: SocketAddr,
|
||||
error: &std::io::Error,
|
||||
) -> Result<()> {
|
||||
let Some(mut peer) = self.peers.remove(&remote_addr) else {
|
||||
return Ok(());
|
||||
};
|
||||
let goodbye = peer.session.end_unsendable_session(error);
|
||||
self.pending_events
|
||||
.push_back(CultNetRudpServerEvent::Disconnected {
|
||||
session: peer.context,
|
||||
reason: goodbye.payload.clone(),
|
||||
});
|
||||
self.send_packet(remote_addr, &goodbye)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A send inside a poll: a permanent failure ends that peer's session.
|
||||
fn send_in_poll(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> {
|
||||
if let Some(error) = self.send_packet(remote_addr, packet)? {
|
||||
self.end_unsendable_peer(remote_addr, &error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn poll_resends(&mut self) -> Result<()> {
|
||||
|
|
@ -1952,7 +2045,10 @@ impl CultNetRudpServerHub {
|
|||
);
|
||||
}
|
||||
for (remote_addr, packet) in packets {
|
||||
self.send_packet(remote_addr, &packet)?;
|
||||
// A peer ended earlier in this loop has nothing left to resend.
|
||||
if self.peers.contains_key(&remote_addr) {
|
||||
self.send_in_poll(remote_addr, &packet)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1977,11 +2073,44 @@ impl CultNetRudpServerHub {
|
|||
timed_out.into_iter().map(|(_, context)| context).collect()
|
||||
}
|
||||
|
||||
fn send_packet(&mut self, remote_addr: SocketAddr, packet: &CultNetRudpPacket) -> Result<()> {
|
||||
/// A transient send failure is that peer's lost datagram: counted, never an
|
||||
/// error. A reliable packet stays pending and is resent; an unreliable one
|
||||
/// was allowed to be lost. A permanent failure (`is_permanent_send_error`)
|
||||
/// is returned as `Some`: the datagram can never be sent as built, and the
|
||||
/// caller decides between refusing the send and ending the peer's session.
|
||||
/// Only an encode failure, the hub's own bug, is an error.
|
||||
fn send_packet(
|
||||
&mut self,
|
||||
remote_addr: SocketAddr,
|
||||
packet: &CultNetRudpPacket,
|
||||
) -> Result<Option<std::io::Error>> {
|
||||
let wire = encode_rudp_packet(packet)?;
|
||||
let sent = self.socket.send_to(&wire, remote_addr)?;
|
||||
self.stats.bytes_sent += sent as u64;
|
||||
Ok(())
|
||||
match self.send_datagram(&wire, remote_addr) {
|
||||
Ok(sent) => self.stats.bytes_sent += sent as u64,
|
||||
Err(error) if is_permanent_send_error(&error) => return Ok(Some(error)),
|
||||
Err(_) => self.stats.send_failures += 1,
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn send_datagram(&mut self, wire: &[u8], remote_addr: SocketAddr) -> std::io::Result<usize> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
match self.unsendable_after.get(&remote_addr).copied() {
|
||||
Some(0) => {
|
||||
self.unsendable_after.remove(&remote_addr);
|
||||
return Err(std::io::Error::from(ErrorKind::InvalidInput));
|
||||
}
|
||||
Some(remaining) => {
|
||||
self.unsendable_after.insert(remote_addr, remaining - 1);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if self.failing_peers.contains(&remote_addr) {
|
||||
return Err(std::io::Error::other("injected send failure"));
|
||||
}
|
||||
}
|
||||
self.socket.send_to(wire, remote_addr)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2469,6 +2598,50 @@ where
|
|||
/// the local caller as the disconnect reason.
|
||||
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.
|
||||
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
|
||||
/// resending it is pointless: it is too large for a datagram (`EMSGSIZE`), its
|
||||
/// address is malformed for the socket (`EINVAL`), or belongs to another
|
||||
/// address family (`EAFNOSUPPORT`). 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.
|
||||
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] = [];
|
||||
// `InvalidInput` is `EINVAL` and `WSAEINVAL`.
|
||||
error.kind() == ErrorKind::InvalidInput
|
||||
|| error
|
||||
.raw_os_error()
|
||||
.is_some_and(|code| PERMANENT_OS_ERRORS.contains(&code))
|
||||
}
|
||||
|
||||
/// A session cannot admit a Connect when its sequence space starts exhausted or
|
||||
/// its reliable queue holds nothing, so neither is a usable configuration.
|
||||
|
|
|
|||
502
packages/cultnet-rs/src/rudp/send_failure_tests.rs
Normal file
502
packages/cultnet-rs/src/rudp/send_failure_tests.rs
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
//! One peer whose datagrams cannot be sent must not stop the hub serving the
|
||||
//! others. The failure is injected at `send_datagram` through `failing_peers`.
|
||||
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
const CONNECTION_ID: u32 = 0x4355_4c54;
|
||||
|
||||
fn socket() -> UdpSocket {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(20)))
|
||||
.unwrap();
|
||||
socket
|
||||
}
|
||||
|
||||
fn client(remote: SocketAddr) -> CultNetRudpSocketTransportConnection {
|
||||
let mut options =
|
||||
CultNetRudpSocketTransportOptions::client("peer", socket(), remote, CONNECTION_ID);
|
||||
options.resend_delay_ms = 10;
|
||||
CultNetRudpSocketTransportConnection::new(options).unwrap()
|
||||
}
|
||||
|
||||
fn connect(
|
||||
hub: &mut CultNetRudpServerHub,
|
||||
client: &mut CultNetRudpSocketTransportConnection,
|
||||
) -> CultNetRudpServerSessionContext {
|
||||
client.connect(Vec::new()).unwrap();
|
||||
let mut session = None;
|
||||
for _ in 0..20 {
|
||||
if let Some(CultNetRudpServerEvent::Connected { session: found }) =
|
||||
hub.receive_event_once().unwrap()
|
||||
{
|
||||
session = Some(found);
|
||||
break;
|
||||
}
|
||||
}
|
||||
client.receive_once().unwrap();
|
||||
assert!(client.connected());
|
||||
session.expect("hub saw the Connect")
|
||||
}
|
||||
|
||||
/// Two connected peers, ordered so the first is the one the hub visits first
|
||||
/// (the peer map is ordered by address).
|
||||
fn two_peers() -> (
|
||||
CultNetRudpServerHub,
|
||||
(
|
||||
CultNetRudpSocketTransportConnection,
|
||||
CultNetRudpServerSessionContext,
|
||||
),
|
||||
(
|
||||
CultNetRudpSocketTransportConnection,
|
||||
CultNetRudpServerSessionContext,
|
||||
),
|
||||
) {
|
||||
let server_socket = socket();
|
||||
let server_addr = server_socket.local_addr().unwrap();
|
||||
let mut options = CultNetRudpServerHubOptions::new("hub", server_socket, CONNECTION_ID);
|
||||
options.resend_delay_ms = 10;
|
||||
let mut hub = CultNetRudpServerHub::new(options).unwrap();
|
||||
let mut a = client(server_addr);
|
||||
let mut b = client(server_addr);
|
||||
let a_session = connect(&mut hub, &mut a);
|
||||
let b_session = connect(&mut hub, &mut b);
|
||||
if a_session.remote_addr < b_session.remote_addr {
|
||||
(hub, (a, a_session), (b, b_session))
|
||||
} else {
|
||||
(hub, (b, b_session), (a, a_session))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_resend_to_one_peer_does_not_stop_the_others_resends() {
|
||||
let (mut hub, (_x, x_session), (mut y, y_session)) = two_peers();
|
||||
// Both peers' first datagram is lost, so both have a reliable resend due.
|
||||
hub.failing_peers.insert(x_session.remote_addr);
|
||||
hub.failing_peers.insert(y_session.remote_addr);
|
||||
hub.send(&x_session, "schema", b"for-x".to_vec()).unwrap();
|
||||
hub.send(&y_session, "schema", b"for-y".to_vec()).unwrap();
|
||||
assert_eq!(hub.stats().send_failures, 2);
|
||||
|
||||
// Y's path recovers; X's stays dead. X is visited first.
|
||||
hub.failing_peers.remove(&y_session.remote_addr);
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
hub.poll_resends().unwrap();
|
||||
|
||||
let mut delivered = None;
|
||||
for _ in 0..20 {
|
||||
if let Some(frame) = y.receive_once().unwrap() {
|
||||
delivered = Some(frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(delivered.expect("Y receives its resend").payload, b"for-y");
|
||||
assert!(hub.stats().send_failures >= 3);
|
||||
assert_eq!(hub.sessions().len(), 2, "a send failure ends no session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_reply_to_one_peer_does_not_stop_the_hub_serving_the_others() {
|
||||
let (mut hub, (mut x, x_session), (mut y, _y_session)) = two_peers();
|
||||
hub.failing_peers.insert(x_session.remote_addr);
|
||||
|
||||
x.send("schema", b"from-x".to_vec()).unwrap();
|
||||
y.send("schema", b"from-y".to_vec()).unwrap();
|
||||
|
||||
let mut frames = Vec::new();
|
||||
for _ in 0..20 {
|
||||
if let Some(CultNetRudpServerEvent::Frame { frame, .. }) = hub.receive_event_once().unwrap()
|
||||
{
|
||||
frames.push(frame.payload);
|
||||
}
|
||||
if frames.len() == 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
frames.sort();
|
||||
assert_eq!(frames, vec![b"from-x".to_vec(), b"from-y".to_vec()]);
|
||||
assert!(
|
||||
hub.stats().send_failures >= 1,
|
||||
"X's ack was counted as lost"
|
||||
);
|
||||
assert_eq!(hub.sessions().len(), 2);
|
||||
}
|
||||
|
||||
// A permanent send failure is one that can never succeed for the datagram as
|
||||
// built. A caller-directed send returns it and queues nothing; inside a poll it
|
||||
// ends that peer's session and never the poll.
|
||||
|
||||
fn is_unsendable_reason(reason: &[u8]) -> bool {
|
||||
reason.starts_with(b"packet could not be sent: ")
|
||||
&& reason.len() > b"packet could not be sent: ".len()
|
||||
}
|
||||
|
||||
fn next_event(hub: &mut CultNetRudpServerHub) -> CultNetRudpServerEvent {
|
||||
for _ in 0..20 {
|
||||
if let Some(event) = hub.receive_event_once().unwrap() {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
panic!("the hub raised no event");
|
||||
}
|
||||
|
||||
fn one_peer(
|
||||
max_fragment_bytes: Option<u32>,
|
||||
) -> (
|
||||
CultNetRudpServerHub,
|
||||
CultNetRudpSocketTransportConnection,
|
||||
CultNetRudpServerSessionContext,
|
||||
) {
|
||||
let server_socket = socket();
|
||||
let server_addr = server_socket.local_addr().unwrap();
|
||||
let mut options = CultNetRudpServerHubOptions::new("hub", server_socket, CONNECTION_ID);
|
||||
options.resend_delay_ms = 10;
|
||||
options.max_fragment_bytes = max_fragment_bytes;
|
||||
let mut hub = CultNetRudpServerHub::new(options).unwrap();
|
||||
let mut peer = client(server_addr);
|
||||
let session = connect(&mut hub, &mut peer);
|
||||
(hub, peer, session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_send_returns_the_error_and_queues_nothing() {
|
||||
let (mut hub, mut peer, session) = one_peer(None);
|
||||
// The hub's own Accept is still awaiting its ack; the refused send adds nothing to it.
|
||||
let outstanding = hub.peers[&session.remote_addr]
|
||||
.session
|
||||
.outstanding_reliable_packet_count();
|
||||
|
||||
let error = hub
|
||||
.send(&session, "schema", vec![7; 70_000])
|
||||
.expect_err("70,000 bytes cannot be one datagram");
|
||||
let io_error = error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.expect("the send error is the socket's own");
|
||||
assert!(is_permanent_send_error(io_error), "{io_error}");
|
||||
assert_eq!(hub.stats().send_failures, 0);
|
||||
assert_eq!(hub.stats().frames_sent, 0);
|
||||
assert_eq!(hub.sessions().len(), 1, "the caller's error ends no session");
|
||||
let remote = session.remote_addr;
|
||||
assert_eq!(
|
||||
hub.peers[&remote].session.outstanding_reliable_packet_count(),
|
||||
outstanding,
|
||||
"nothing is pending or queued to resend"
|
||||
);
|
||||
|
||||
// The refused send consumed no sequence: the next frame is delivered in
|
||||
// order instead of waiting behind a gap that never fills.
|
||||
hub.send(&session, "schema", b"next".to_vec()).unwrap();
|
||||
let mut delivered = None;
|
||||
for _ in 0..20 {
|
||||
if let Some(frame) = peer.receive_once().unwrap() {
|
||||
delivered = Some(frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(delivered.expect("the next frame arrives").payload, b"next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_that_fails_permanently_after_a_fragment_left_ends_the_session() {
|
||||
let (mut hub, _peer, session) = one_peer(Some(1000));
|
||||
hub.unsendable_after.insert(session.remote_addr, 1);
|
||||
|
||||
let error = hub
|
||||
.send(&session, "schema", vec![7; 3000])
|
||||
.expect_err("the second fragment can never be sent");
|
||||
assert!(is_permanent_send_error(
|
||||
error.downcast_ref::<std::io::Error>().unwrap()
|
||||
));
|
||||
assert!(hub.sessions().is_empty());
|
||||
let CultNetRudpServerEvent::Disconnected { reason, .. } = next_event(&mut hub) else {
|
||||
panic!("the caller sees the session end");
|
||||
};
|
||||
assert!(is_unsendable_reason(&reason));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_in_resends_ends_only_that_peers_session() {
|
||||
let (mut hub, (mut x, x_session), (mut y, y_session)) = two_peers();
|
||||
// Both peers' first datagram is lost, so both have a reliable resend due.
|
||||
hub.failing_peers.insert(x_session.remote_addr);
|
||||
hub.failing_peers.insert(y_session.remote_addr);
|
||||
hub.send(&x_session, "schema", b"for-x".to_vec()).unwrap();
|
||||
hub.send(&y_session, "schema", b"for-y".to_vec()).unwrap();
|
||||
|
||||
// X's datagram can now never be sent; Y's path recovers. X is visited first.
|
||||
hub.failing_peers.clear();
|
||||
hub.unsendable_after.insert(x_session.remote_addr, 0);
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
hub.poll_resends().unwrap();
|
||||
|
||||
assert_eq!(hub.sessions(), vec![y_session.clone()]);
|
||||
let CultNetRudpServerEvent::Disconnected { session, reason } = next_event(&mut hub) else {
|
||||
panic!("the caller sees the session end");
|
||||
};
|
||||
assert_eq!(session, x_session);
|
||||
assert!(is_unsendable_reason(&reason));
|
||||
let mut delivered = None;
|
||||
for _ in 0..20 {
|
||||
if let Some(frame) = y.receive_once().unwrap() {
|
||||
delivered = Some(frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(delivered.expect("Y receives its resend").payload, b"for-y");
|
||||
// The goodbye reaches X and names the error.
|
||||
for _ in 0..20 {
|
||||
x.receive_once().unwrap();
|
||||
if x.disconnect_reason().is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(is_unsendable_reason(
|
||||
x.disconnect_reason().expect("X is told its session ended")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_replying_to_a_frame_ends_the_session_after_the_frame_is_delivered() {
|
||||
let (mut hub, (mut x, x_session), (_y, _y_session)) = two_peers();
|
||||
hub.unsendable_after.insert(x_session.remote_addr, 0);
|
||||
x.send("schema", b"from-x".to_vec()).unwrap();
|
||||
|
||||
let CultNetRudpServerEvent::Frame { frame, .. } = next_event(&mut hub) else {
|
||||
panic!("the frame is delivered before the session ends");
|
||||
};
|
||||
assert_eq!(frame.payload, b"from-x");
|
||||
let CultNetRudpServerEvent::Disconnected { session, reason } = next_event(&mut hub) else {
|
||||
panic!("the session ends");
|
||||
};
|
||||
assert_eq!(session, x_session);
|
||||
assert!(is_unsendable_reason(&reason));
|
||||
assert_eq!(hub.sessions().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_answering_a_repeated_connect_ends_the_session() {
|
||||
let server_socket = socket();
|
||||
let server_addr = server_socket.local_addr().unwrap();
|
||||
let mut options = CultNetRudpServerHubOptions::new("hub", server_socket, CONNECTION_ID);
|
||||
options.resend_delay_ms = 10;
|
||||
let mut hub = CultNetRudpServerHub::new(options).unwrap();
|
||||
let raw = socket();
|
||||
let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: CONNECTION_ID,
|
||||
initial_sequence: Some(5),
|
||||
resend_delay_ms: 10,
|
||||
max_pending_reliable_packets: None,
|
||||
});
|
||||
let wire = encode_rudp_packet(&session.create_connect(0, Vec::new()).unwrap()).unwrap();
|
||||
|
||||
raw.send_to(&wire, server_addr).unwrap();
|
||||
let CultNetRudpServerEvent::Connected { session: admitted } = next_event(&mut hub) else {
|
||||
panic!("the first Connect is admitted");
|
||||
};
|
||||
hub.unsendable_after.insert(raw.local_addr().unwrap(), 0);
|
||||
raw.send_to(&wire, server_addr).unwrap();
|
||||
|
||||
let CultNetRudpServerEvent::Disconnected {
|
||||
session: ended,
|
||||
reason,
|
||||
} = next_event(&mut hub)
|
||||
else {
|
||||
panic!("the reply to the repeat can never be sent, so the session ends");
|
||||
};
|
||||
assert_eq!(ended, admitted);
|
||||
assert!(is_unsendable_reason(&reason));
|
||||
assert!(hub.sessions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connect_whose_accept_can_never_be_sent_starts_no_session() {
|
||||
let server_socket = socket();
|
||||
let server_addr = server_socket.local_addr().unwrap();
|
||||
let mut options = CultNetRudpServerHubOptions::new("hub", server_socket, CONNECTION_ID);
|
||||
options.resend_delay_ms = 10;
|
||||
let mut hub = CultNetRudpServerHub::new(options).unwrap();
|
||||
let mut peer = client(server_addr);
|
||||
hub.unsendable_after
|
||||
.insert(peer.socket.local_addr().unwrap(), 0);
|
||||
|
||||
peer.connect(Vec::new()).unwrap();
|
||||
for _ in 0..5 {
|
||||
assert_eq!(hub.receive_event_once().unwrap(), None);
|
||||
}
|
||||
assert!(hub.sessions().is_empty());
|
||||
assert_eq!(hub.stats().packets_dropped, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_healthy_send_counts_its_bytes_and_no_failure() {
|
||||
let (mut hub, mut peer, session) = one_peer(None);
|
||||
let before = hub.stats().bytes_sent;
|
||||
let received_before = peer.stats().bytes_received;
|
||||
hub.send(&session, "schema", b"hello".to_vec()).unwrap();
|
||||
for _ in 0..20 {
|
||||
if peer.receive_once().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let sent = hub.stats().bytes_sent - before;
|
||||
assert!(sent > b"hello".len() as u64);
|
||||
assert_eq!(sent, peer.stats().bytes_received - received_before);
|
||||
assert_eq!(hub.stats().send_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_datagram_that_can_never_be_sent_is_a_permanent_failure() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
assert!(is_permanent_send_error(&Error::from(ErrorKind::InvalidInput)));
|
||||
assert!(!is_permanent_send_error(&Error::other("no os code")));
|
||||
assert!(!is_permanent_send_error(&Error::from(ErrorKind::WouldBlock)));
|
||||
assert!(!is_permanent_send_error(&Error::from(ErrorKind::ConnectionReset)));
|
||||
#[cfg(target_os = "linux")]
|
||||
let (permanent, transient) = (
|
||||
// EMSGSIZE, EAFNOSUPPORT, EINVAL
|
||||
[90, 97, 22],
|
||||
// EPERM, EINTR, EAGAIN, EACCES, ENETDOWN, ENETUNREACH, EADDRNOTAVAIL, ENOBUFS, ECONNREFUSED, EHOSTUNREACH
|
||||
[1, 4, 11, 13, 100, 101, 99, 105, 111, 113],
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let (permanent, transient) = (
|
||||
// WSAEMSGSIZE, WSAEAFNOSUPPORT, WSAEINVAL
|
||||
[10040, 10047, 10022],
|
||||
// WSAEACCES, WSAEWOULDBLOCK, WSAENETDOWN, WSAENETUNREACH, WSAEADDRNOTAVAIL, WSAENOBUFS, WSAECONNRESET,
|
||||
// WSAECONNREFUSED, WSAEHOSTUNREACH, WSAEINTR
|
||||
[10013, 10035, 10050, 10051, 10049, 10055, 10054, 10061, 10065, 10004],
|
||||
);
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
{
|
||||
for code in permanent {
|
||||
assert!(
|
||||
is_permanent_send_error(&Error::from_raw_os_error(code)),
|
||||
"os error {code} is permanent"
|
||||
);
|
||||
}
|
||||
for code in transient {
|
||||
assert!(
|
||||
!is_permanent_send_error(&Error::from_raw_os_error(code)),
|
||||
"os error {code} is transient"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permanent_failure_sending_a_disconnect_is_returned_to_the_caller() {
|
||||
let (mut hub, _peer, session) = one_peer(None);
|
||||
hub.unsendable_after.insert(session.remote_addr, 0);
|
||||
|
||||
let error = hub
|
||||
.disconnect(&session, b"bye".to_vec())
|
||||
.expect_err("the goodbye can never be sent");
|
||||
assert!(is_permanent_send_error(
|
||||
error.downcast_ref::<std::io::Error>().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_ended_in_the_resend_loop_is_sent_nothing_more_from_it() {
|
||||
let (mut hub, (_x, x_session), (_y, _y_session)) = two_peers();
|
||||
hub.failing_peers.insert(x_session.remote_addr);
|
||||
hub.send(&x_session, "schema", b"one".to_vec()).unwrap();
|
||||
hub.send(&x_session, "schema", b"two".to_vec()).unwrap();
|
||||
assert_eq!(hub.stats().send_failures, 2);
|
||||
|
||||
hub.unsendable_after.insert(x_session.remote_addr, 0);
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
hub.poll_resends().unwrap();
|
||||
|
||||
// Both resends are due. The first can never be sent and ends the session;
|
||||
// the goodbye is the only other datagram attempted at X.
|
||||
assert_eq!(hub.stats().send_failures, 3);
|
||||
assert_eq!(hub.sessions().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_send_withdraws_the_fragments_the_window_queued_as_well() {
|
||||
let (mut hub, _peer, session) = one_peer(Some(1));
|
||||
let outstanding = hub.peers[&session.remote_addr]
|
||||
.session
|
||||
.outstanding_reliable_packet_count();
|
||||
hub.unsendable_after.insert(session.remote_addr, 0);
|
||||
|
||||
// 1,100 one-byte fragments: the flow window admits the first ones and
|
||||
// queues the rest, and the first datagram can never be sent.
|
||||
hub.send(&session, "schema", vec![7; 1_100])
|
||||
.expect_err("the first fragment can never be sent");
|
||||
|
||||
assert_eq!(
|
||||
hub.peers[&session.remote_addr]
|
||||
.session
|
||||
.outstanding_reliable_packet_count(),
|
||||
outstanding,
|
||||
"nothing of the refused send stays queued"
|
||||
);
|
||||
}
|
||||
|
||||
fn receive_frame(
|
||||
peer: &mut CultNetRudpSocketTransportConnection,
|
||||
hub: &mut CultNetRudpServerHub,
|
||||
) -> Option<Vec<u8>> {
|
||||
for _ in 0..600 {
|
||||
if let Some(frame) = peer.receive_once().unwrap() {
|
||||
return Some(frame.payload);
|
||||
}
|
||||
let _ = hub.receive_event_once().unwrap();
|
||||
hub.poll_resends().unwrap();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Wherever in a fragmented send the datagram that can never be sent falls, the
|
||||
/// peer never delivers a frame built from it. At the first fragment nothing left,
|
||||
/// the send is withdrawn, and the next send is delivered whole; later, the peer has
|
||||
/// seen part of it, and the session ends with nothing delivered.
|
||||
#[test]
|
||||
fn a_send_refused_at_any_fragment_delivers_nothing_of_it() {
|
||||
for fragments in [5_usize, 40] {
|
||||
for failed in 0..fragments {
|
||||
let (mut hub, mut peer, session) = one_peer(Some(1000));
|
||||
hub.send(&session, "schema", b"warm".to_vec()).unwrap();
|
||||
assert_eq!(receive_frame(&mut peer, &mut hub).as_deref(), Some(&b"warm"[..]));
|
||||
let refused: Vec<u8> = (0..fragments * 1000).map(|i| (i % 251) as u8).collect();
|
||||
hub.unsendable_after.insert(session.remote_addr, failed);
|
||||
let result = hub.send(&session, "schema", refused);
|
||||
if hub.unsendable_after.remove(&session.remote_addr).is_some() {
|
||||
// The flow window queued that fragment rather than sending it now.
|
||||
assert!(result.is_ok(), "{fragments}/{failed}: not reached, yet refused");
|
||||
continue;
|
||||
}
|
||||
assert!(result.is_err(), "{fragments}/{failed}");
|
||||
if failed == 0 {
|
||||
assert_eq!(hub.sessions().len(), 1, "{fragments}/{failed}");
|
||||
let next: Vec<u8> = (0..fragments * 1000).map(|i| ((i % 13) + 100) as u8).collect();
|
||||
hub.send(&session, "schema", next.clone()).unwrap();
|
||||
let delivered = receive_frame(&mut peer, &mut hub).expect("the next send arrives");
|
||||
assert!(delivered == next, "{fragments}/{failed}: delivered something else");
|
||||
for _ in 0..30 {
|
||||
if let Some(frame) = peer.receive_once().unwrap() {
|
||||
panic!("{fragments}/{failed}: a stray frame of {} bytes", frame.payload.len());
|
||||
}
|
||||
let _ = hub.receive_event_once().unwrap();
|
||||
hub.poll_resends().unwrap();
|
||||
}
|
||||
} else {
|
||||
assert!(hub.sessions().is_empty(), "{fragments}/{failed}");
|
||||
for _ in 0..30 {
|
||||
match peer.receive_once() {
|
||||
Ok(Some(frame)) => panic!(
|
||||
"{fragments}/{failed}: the peer delivered {} bytes",
|
||||
frame.payload.len()
|
||||
),
|
||||
Ok(None) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,11 @@ pub struct CultNetTransportStats {
|
|||
/// unadmitted sender, or a packet the session refused. A steady rate means
|
||||
/// a flow is being moved or scanned, not that this transport is failing.
|
||||
pub packets_dropped: u64,
|
||||
/// Datagrams a multi-peer server could not send to one peer. Each is a lost
|
||||
/// datagram attributed to that peer: reliable packets stay pending and are
|
||||
/// resent, and no other peer's poll is affected. Zero for single-peer
|
||||
/// transports, whose callers get the send error itself.
|
||||
pub send_failures: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ export {
|
|||
createRudpTransportProfile,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
isPermanentSendError,
|
||||
sendRudpDatagram,
|
||||
rudpClientBindHost,
|
||||
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS,
|
||||
CultNetRudpReconnectLoop,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
CultNetRudpSocketTransportConnection,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
isPermanentSendError,
|
||||
sendRudpDatagram,
|
||||
rudpClientBindHost,
|
||||
type CultNetRudpPacket,
|
||||
} from "./rudp";
|
||||
|
|
@ -39,6 +41,12 @@ export interface CultNetOperationServer {
|
|||
readonly endpoint: string;
|
||||
/** Datagrams read and discarded: malformed, unadmitted, refused by their session, or failed in handling. */
|
||||
readonly packetsDropped: number;
|
||||
/**
|
||||
* Datagrams that could not be sent to a peer and may yet be (no route, full buffers):
|
||||
* each is that peer's lost datagram, resent if it was reliable. One that can never be
|
||||
* sent as built ends that peer's session instead and is not counted here.
|
||||
*/
|
||||
readonly sendFailures: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -56,8 +64,12 @@ interface RemoteSession {
|
|||
handling: number;
|
||||
/** When the last handler finished: the response still needs its acknowledgement. */
|
||||
lastHandledAtMs: number;
|
||||
/** A packet it owed could never be sent, so the session ended. */
|
||||
unsendable: boolean;
|
||||
}
|
||||
|
||||
type SendPacket = (key: string, peer: RemoteSession, packet: CultNetRudpPacket) => void;
|
||||
|
||||
export async function startCultNetOperationServer(
|
||||
options: CultNetOperationServerOptions,
|
||||
): Promise<CultNetOperationServer> {
|
||||
|
|
@ -66,16 +78,32 @@ export async function startCultNetOperationServer(
|
|||
const socket = createSocket("udp4");
|
||||
const connectionId = options.connectionId ?? DEFAULT_CONNECTION_ID;
|
||||
const sessions = new Map<string, RemoteSession>();
|
||||
const sendPacket = (remote: RemoteInfo, packet: CultNetRudpPacket): void => {
|
||||
const wire = encodeRudpPacket(packet);
|
||||
socket.send(wire, remote.port, remote.address);
|
||||
};
|
||||
let packetsDropped = 0;
|
||||
let sendFailures = 0;
|
||||
const sendPacket: SendPacket = (key, peer, packet) => {
|
||||
const generation = peer.session.generation;
|
||||
sendRudpDatagram(socket, encodeRudpPacket(packet), peer.remote.port, peer.remote.address, (error) => {
|
||||
if (!isPermanentSendError(error)) {
|
||||
sendFailures += 1;
|
||||
return;
|
||||
}
|
||||
// A failure reported after the generation that sent the datagram ended is owed by
|
||||
// nothing: that generation's end already told the peer, and a client that connected
|
||||
// again from the endpoint owns it now. A live generation is the one in `sessions`.
|
||||
if (peer.session.generation !== generation) return;
|
||||
// The datagram can never be sent as built: the session that owes it ends, never
|
||||
// the server, and the peer is told.
|
||||
peer.unsendable = true;
|
||||
sessions.delete(key);
|
||||
const goodbye = encodeRudpPacket(peer.session.endUnsendableSession(error));
|
||||
sendRudpDatagram(socket, goodbye, peer.remote.port, peer.remote.address, () => {});
|
||||
});
|
||||
};
|
||||
socket.on("message", (wire, remote) => {
|
||||
// What a datagram carries is the sender's business: a rejection here must
|
||||
// drop and count the packet, never become an unhandled rejection that ends
|
||||
// the process.
|
||||
handleServerDatagram(sessions, connectionId, options, wire, remote, sendPacket).then(
|
||||
handleServerDatagram(sessions, connectionId, options, wire, remote, socket, sendPacket).then(
|
||||
admitted => { if (!admitted) packetsDropped += 1; },
|
||||
() => { packetsDropped += 1; },
|
||||
);
|
||||
|
|
@ -90,8 +118,8 @@ export async function startCultNetOperationServer(
|
|||
if (peer.handling > 0 || now - peer.lastHandledAtMs < idleTimeoutMs) continue;
|
||||
if (peer.session.checkTimeout(now, idleTimeoutMs)) sessions.delete(key);
|
||||
}
|
||||
for (const peer of sessions.values()) {
|
||||
for (const packet of peer.session.dueResends(Date.now())) sendPacket(peer.remote, packet);
|
||||
for (const [key, peer] of sessions) {
|
||||
for (const packet of peer.session.dueResends(Date.now())) sendPacket(key, peer, packet);
|
||||
}
|
||||
}, 25);
|
||||
resendTimer.unref?.();
|
||||
|
|
@ -100,6 +128,7 @@ export async function startCultNetOperationServer(
|
|||
return {
|
||||
endpoint,
|
||||
get packetsDropped() { return packetsDropped; },
|
||||
get sendFailures() { return sendFailures; },
|
||||
close: async () => {
|
||||
clearInterval(resendTimer);
|
||||
await closeSocket(socket);
|
||||
|
|
@ -155,7 +184,8 @@ async function handleServerDatagram(
|
|||
options: CultNetOperationServerOptions,
|
||||
wire: Buffer,
|
||||
remote: RemoteInfo,
|
||||
sendPacket: (remote: RemoteInfo, packet: CultNetRudpPacket) => void,
|
||||
socket: Socket,
|
||||
sendPacket: SendPacket,
|
||||
): Promise<boolean> {
|
||||
let packet: CultNetRudpPacket;
|
||||
try {
|
||||
|
|
@ -167,14 +197,24 @@ async function handleServerDatagram(
|
|||
const key = `${remote.address}:${remote.port}`;
|
||||
let peer = sessions.get(key);
|
||||
if (packet.packetType === "connect") {
|
||||
if (!peer) {
|
||||
if (sessions.size >= MAX_OPERATION_SESSIONS) return false;
|
||||
peer = { session: new CultNetRudpSession({ connectionId, resendDelayMs: 25 }), remote, handling: 0, lastHandledAtMs: 0 };
|
||||
sessions.set(key, peer);
|
||||
if (peer) {
|
||||
// A Connect from an admitted peer repeats: the session answers with the
|
||||
// Accept already owed and queues nothing.
|
||||
sendPacket(key, peer, peer.session.acceptConnect(packet, Date.now(), encode("cultnet-operation-service")));
|
||||
return true;
|
||||
}
|
||||
// A Connect from an admitted peer repeats: the session answers with the
|
||||
// Accept already owed and queues nothing.
|
||||
sendPacket(remote, peer.session.acceptConnect(packet, Date.now(), encode("cultnet-operation-service")));
|
||||
if (sessions.size >= MAX_OPERATION_SESSIONS) return false;
|
||||
const candidate: RemoteSession = {
|
||||
session: new CultNetRudpSession({ connectionId, resendDelayMs: 25 }),
|
||||
remote,
|
||||
handling: 0,
|
||||
lastHandledAtMs: 0,
|
||||
unsendable: false,
|
||||
};
|
||||
sendPacket(key, candidate, candidate.session.acceptConnect(packet, Date.now(), encode("cultnet-operation-service")));
|
||||
// A peer whose Accept can never be sent cannot be answered, so no session starts.
|
||||
if (candidate.unsendable) return false;
|
||||
sessions.set(key, candidate);
|
||||
return true;
|
||||
}
|
||||
if (!peer) return false;
|
||||
|
|
@ -182,15 +222,20 @@ async function handleServerDatagram(
|
|||
try {
|
||||
result = peer.session.receive(packet, Date.now());
|
||||
} catch {
|
||||
endSession(sessions, key, peer, sendPacket);
|
||||
endSession(sessions, key, peer, socket);
|
||||
return false;
|
||||
}
|
||||
if (result.reply) sendPacket(remote, result.reply);
|
||||
for (const ready of result.readyToSend ?? []) sendPacket(remote, ready);
|
||||
if (result.reply) sendPacket(key, peer, result.reply);
|
||||
for (const ready of result.readyToSend ?? []) sendPacket(key, peer, ready);
|
||||
if (result.disconnected) {
|
||||
sessions.delete(key);
|
||||
return true;
|
||||
}
|
||||
// A request belongs to the session generation it arrived on. If that generation ends while a
|
||||
// handler runs (a goodbye, a refusal, a timeout, a new Connect from the same endpoint), what
|
||||
// the handler returns or throws belongs to no live session, and nothing of it may reach the
|
||||
// endpoint: a client that connected again there owns it now.
|
||||
const generation = peer.session.generation;
|
||||
for (const frame of result.delivered) {
|
||||
if (frame.channelId !== "schema") continue;
|
||||
try {
|
||||
|
|
@ -204,22 +249,24 @@ async function handleServerDatagram(
|
|||
peer.handling -= 1;
|
||||
peer.lastHandledAtMs = Date.now();
|
||||
}
|
||||
if (peer.session.generation !== generation) return true;
|
||||
const payload = encode(encodeCultNetMessageForWire(response, "cultnet.schema.v0"));
|
||||
for (const responsePacket of peer.session.sendMany("schema", payload, {
|
||||
reliable: true,
|
||||
ordered: true,
|
||||
nowMs: Date.now(),
|
||||
maxFragmentBytes: options.maxFragmentBytes ?? 2048,
|
||||
})) sendPacket(remote, responsePacket);
|
||||
})) sendPacket(key, peer, responsePacket);
|
||||
} catch {
|
||||
if (peer.session.generation !== generation) return true;
|
||||
// The session recorded this request's sequence, so keeping it would
|
||||
// acknowledge the retransmit of a request that was never handled.
|
||||
endSession(sessions, key, peer, sendPacket);
|
||||
endSession(sessions, key, peer, socket);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (packet.packetType === "data" || result.delivered.length > 0) {
|
||||
sendPacket(remote, peer.session.createAckForReceived(packet.sequence));
|
||||
sendPacket(key, peer, peer.session.createAckForReceived(packet.sequence));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -227,17 +274,19 @@ async function handleServerDatagram(
|
|||
/**
|
||||
* Ends a session that took a packet it could not serve, and tells the peer. The
|
||||
* reset comes first, or the goodbye's ack field would acknowledge the very
|
||||
* packet that was refused. A session already replaced under the same key is left.
|
||||
* packet that was refused. The goodbye is best-effort: the session ends whether
|
||||
* or not the peer hears it.
|
||||
*/
|
||||
function endSession(
|
||||
sessions: Map<string, RemoteSession>,
|
||||
key: string,
|
||||
peer: RemoteSession,
|
||||
sendPacket: (remote: RemoteInfo, packet: CultNetRudpPacket) => void,
|
||||
socket: Socket,
|
||||
): void {
|
||||
if (sessions.get(key) === peer) sessions.delete(key);
|
||||
sessions.delete(key);
|
||||
peer.session.resetPeerState();
|
||||
sendPacket(peer.remote, peer.session.createDisconnect(Buffer.from("session refused a packet", "utf8")));
|
||||
const goodbye = encodeRudpPacket(peer.session.createDisconnect(Buffer.from("session refused a packet", "utf8")));
|
||||
sendRudpDatagram(socket, goodbye, peer.remote.port, peer.remote.address, () => {});
|
||||
}
|
||||
|
||||
function parseRudpEndpoint(endpoint: string): { host: string; port: number } {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ import {
|
|||
const RUDP_MAGIC = [0x43, 0x4e, 0x52, 0x30] as const; // CNR0
|
||||
const RUDP_VERSION = 0;
|
||||
const RUDP_FIXED_HEADER_BYTES = 36;
|
||||
/// The largest UDP payload a datagram can carry: 65,535 less the UDP header and,
|
||||
/// over IPv4, the minimal IP header. IPv6's header is outside its payload length.
|
||||
const MAX_UDP4_DATAGRAM_BYTES = 65_507;
|
||||
const MAX_UDP6_DATAGRAM_BYTES = 65_527;
|
||||
const MAX_CHANNEL_ID_BYTES = 255;
|
||||
export const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS = 32;
|
||||
const RUDP_RECEIVED_SEQUENCE_WINDOW = 4_096;
|
||||
|
|
@ -668,6 +672,18 @@ export class CultNetRudpSession {
|
|||
});
|
||||
}
|
||||
|
||||
/// Ends a session that owes its peer a packet that can never be sent as
|
||||
/// built (see `isPermanentSendError`). 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's code, never its message, which can quote the address.
|
||||
endUnsendableSession(error: Error): CultNetRudpPacket {
|
||||
this.resetPeerState();
|
||||
return this.createDisconnect(
|
||||
Buffer.from(`${RUDP_UNSENDABLE_PACKET_REASON}: ${sendErrorCode(error)}`, "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
checkTimeout(nowMs: number, timeoutMs: number): boolean {
|
||||
if (!this.#connected || this.#lastReceivedAtMs === undefined) {
|
||||
return false;
|
||||
|
|
@ -1025,6 +1041,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
readonly #mode: "client" | "server";
|
||||
readonly #resendTimer: NodeJS.Timeout;
|
||||
readonly #maxFragmentBytes: number | undefined;
|
||||
readonly #maxDatagramBytes: number;
|
||||
#remoteHost: string | undefined;
|
||||
#remotePort: number | undefined;
|
||||
#closed = false;
|
||||
|
|
@ -1037,6 +1054,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
framesReceived: 0,
|
||||
framesSent: 0,
|
||||
packetsDropped: 0,
|
||||
sendFailures: 0,
|
||||
};
|
||||
|
||||
constructor(options: CultNetRudpSocketTransportOptions) {
|
||||
|
|
@ -1055,6 +1073,8 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
const address = this.#socket.address();
|
||||
const localPort = typeof address === "string" ? undefined : address.port;
|
||||
const localHost = typeof address === "string" ? undefined : address.address;
|
||||
this.#maxDatagramBytes =
|
||||
typeof address !== "string" && String(address.family).endsWith("6") ? MAX_UDP6_DATAGRAM_BYTES : MAX_UDP4_DATAGRAM_BYTES;
|
||||
this.profile = createRudpTransportProfile(options.runtimeId, {
|
||||
transportId: options.transportId,
|
||||
host: localHost,
|
||||
|
|
@ -1096,6 +1116,15 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
}
|
||||
|
||||
send(channelId: string, payload: Uint8Array): void {
|
||||
// A datagram larger than UDP can carry can never be sent, and Node reports that
|
||||
// only later, to the send's callback. Checked here it is the caller's error, as
|
||||
// in the other runtimes: the send throws, and nothing of it is queued or takes a
|
||||
// sequence. Every datagram of a send is at most one fragment of its payload.
|
||||
const largestPayload =
|
||||
this.#maxFragmentBytes === undefined ? payload.byteLength : Math.min(payload.byteLength, this.#maxFragmentBytes);
|
||||
if (RUDP_FIXED_HEADER_BYTES + Buffer.byteLength(channelId, "utf8") + largestPayload > this.#maxDatagramBytes) {
|
||||
throw Object.assign(new Error("RUDP datagram is larger than UDP can carry."), { code: "EMSGSIZE" });
|
||||
}
|
||||
const packets = this.#session.sendMany(channelId, payload, {
|
||||
...channelOptions(channelId),
|
||||
nowMs: Date.now(),
|
||||
|
|
@ -1170,11 +1199,22 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
// The goodbye is built after the reset, or its ack field would acknowledge
|
||||
// the very frame the session refused.
|
||||
this.#session.resetPeerState();
|
||||
this.#endWithGoodbye(this.#session.createDisconnect(reason));
|
||||
}
|
||||
|
||||
/** The session owes its peer a packet that can never be sent as built. */
|
||||
#endUnsendableSession(error: Error): void {
|
||||
this.#endWithGoodbye(this.#session.endUnsendableSession(error));
|
||||
}
|
||||
|
||||
#endWithGoodbye(goodbye: CultNetRudpPacket): void {
|
||||
const reason = goodbye.payload;
|
||||
this.#endedReason = reason;
|
||||
try {
|
||||
this.#sendPacket(this.#session.createDisconnect(reason));
|
||||
} catch {
|
||||
if (this.#remoteHost && this.#remotePort !== undefined) {
|
||||
// Best-effort: the session ends whether or not the peer hears it.
|
||||
const wire = encodeRudpPacket(goodbye);
|
||||
this.#stats.bytesSent += wire.length;
|
||||
sendRudpDatagram(this.#socket, wire, this.#remotePort, this.#remoteHost, () => {});
|
||||
}
|
||||
if (this.#mode === "server") {
|
||||
// Only a Connect can claim the endpoint again.
|
||||
|
|
@ -1245,7 +1285,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
this.#stats.packetsDropped += 1;
|
||||
return;
|
||||
}
|
||||
this.#sendPacket(accept);
|
||||
this.#sendInPoll(accept);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1262,10 +1302,10 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
}
|
||||
try {
|
||||
if (result.reply) {
|
||||
this.#sendPacket(result.reply);
|
||||
this.#sendInPoll(result.reply);
|
||||
}
|
||||
for (const ready of result.readyToSend ?? []) {
|
||||
this.#sendPacket(ready);
|
||||
this.#sendInPoll(ready);
|
||||
}
|
||||
if (result.pong) {
|
||||
this.emit("pong", { payload: result.pongPayload ?? new Uint8Array() });
|
||||
|
|
@ -1284,7 +1324,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
return;
|
||||
}
|
||||
if (packet.packetType === "accept" || packet.packetType === "data" || result.delivered.length > 0) {
|
||||
this.#sendPacket(this.#session.createAckForReceived(packet.sequence));
|
||||
this.#sendInPoll(this.#session.createAckForReceived(packet.sequence));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
|
|
@ -1292,18 +1332,63 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
|
|||
}
|
||||
|
||||
#sendDueResends(): void {
|
||||
const generation = this.#session.generation;
|
||||
for (const packet of this.#session.dueResends(Date.now())) {
|
||||
this.#sendPacket(packet);
|
||||
this.#sendInPoll(packet);
|
||||
// A resend that can never be sent ended the session and what it owed.
|
||||
if (this.#session.generation !== generation) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** A caller-directed send: a failure the send throws is the caller's error. */
|
||||
#sendPacket(packet: CultNetRudpPacket): void {
|
||||
const thrown = this.#transmit(packet);
|
||||
if (thrown) throw thrown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A send with no caller to hand a failure to (a receive or a resend): the failure
|
||||
* is handled as every later one is, and never escapes the timer or the socket's
|
||||
* "message" handler.
|
||||
*/
|
||||
#sendInPoll(packet: CultNetRudpPacket): void {
|
||||
const thrown = this.#transmit(packet);
|
||||
if (thrown) this.#sendFailed(thrown);
|
||||
}
|
||||
|
||||
/** Sends one packet and returns the failure the send threw, if any. */
|
||||
#transmit(packet: CultNetRudpPacket): Error | undefined {
|
||||
if (!this.#remoteHost || this.#remotePort === undefined) {
|
||||
throw new Error("RUDP socket transport does not have a remote endpoint.");
|
||||
}
|
||||
const wire = encodeRudpPacket(packet);
|
||||
this.#stats.bytesSent += wire.length;
|
||||
this.#socket.send(wire, this.#remotePort, this.#remoteHost);
|
||||
let thrown: Error | undefined;
|
||||
let sending = true;
|
||||
const generation = this.#session.generation;
|
||||
sendRudpDatagram(this.#socket, wire, this.#remotePort, this.#remoteHost, (error) => {
|
||||
if (sending) {
|
||||
thrown = error;
|
||||
} else if (this.#session.generation === generation) {
|
||||
// A later failure belongs to the session generation that sent it.
|
||||
this.#sendFailed(error);
|
||||
}
|
||||
});
|
||||
sending = false;
|
||||
return thrown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A datagram that can never be sent ends the session; any other failure is a lost
|
||||
* datagram, counted and never emitted. Node reports a failed send only to its callback,
|
||||
* not on the socket's "error" event, and an "error" with no listener ends the process.
|
||||
*/
|
||||
#sendFailed(error: Error): void {
|
||||
if (isPermanentSendError(error)) {
|
||||
this.#endUnsendableSession(error);
|
||||
return;
|
||||
}
|
||||
this.#stats.sendFailures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1397,6 +1482,61 @@ export class CultNetRudpReconnectLoop extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/** Prefix of the goodbye reason for a session that owed a packet which can never be sent. */
|
||||
const RUDP_UNSENDABLE_PACKET_REASON = "packet could not be sent";
|
||||
|
||||
/**
|
||||
* Failures that can never pass for the datagram as built: too large for a datagram
|
||||
* (`EMSGSIZE`), an address malformed for the socket (`EINVAL`, and Node's own
|
||||
* `ERR_SOCKET_BAD_PORT`, which it throws for port 0 before the system would say
|
||||
* `EINVAL`), or an address of another family (`EAFNOSUPPORT`). libuv names these the
|
||||
* same on every platform.
|
||||
*/
|
||||
const PERMANENT_SEND_ERROR_CODES: ReadonlySet<string> = new Set([
|
||||
"EMSGSIZE",
|
||||
"EINVAL",
|
||||
"EAFNOSUPPORT",
|
||||
"ERR_SOCKET_BAD_PORT",
|
||||
]);
|
||||
|
||||
function sendErrorCode(error: Error): string {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return typeof code === "string" && code.length > 0 ? code : "UNKNOWN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed UDP send can never succeed for the datagram as built, so resending it is
|
||||
* pointless and the session that owes it cannot be kept. Everything else (no route, full
|
||||
* buffers, a closed socket, an interrupted call) is a property of the path or the moment: a
|
||||
* lost datagram, not a reason to end a session.
|
||||
*/
|
||||
export function isPermanentSendError(error: Error): boolean {
|
||||
return PERMANENT_SEND_ERROR_CODES.has(sendErrorCode(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one datagram. Node reports a failed send two ways: it throws for an argument it
|
||||
* refuses (port 0 among them) and passes a system failure to the callback later. Both
|
||||
* reach `onFailure` and nothing else, so a datagram that cannot be sent to one peer never
|
||||
* escapes a timer or a receive handler, and never reaches the socket's "error" event. A
|
||||
* failure the send throws reaches `onFailure` before this returns.
|
||||
*/
|
||||
export function sendRudpDatagram(
|
||||
socket: Socket,
|
||||
wire: Uint8Array,
|
||||
port: number,
|
||||
address: string,
|
||||
onFailure: (error: Error) => void,
|
||||
): void {
|
||||
try {
|
||||
socket.send(wire, port, address, (error) => {
|
||||
if (error) onFailure(error);
|
||||
});
|
||||
} catch (error) {
|
||||
onFailure(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
export function createRudpTransportProfile(
|
||||
runtimeId: string,
|
||||
options: RudpTransportProfileOptions = {},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export interface CultNetTransportStats {
|
|||
framesSent: number;
|
||||
/** Datagrams read and discarded because they belong to no session on this transport. */
|
||||
packetsDropped: number;
|
||||
/**
|
||||
* Datagrams this transport could not send and treats as lost (no route, full buffers):
|
||||
* a reliable packet stays pending and is resent. A send a caller made throws its failure
|
||||
* to the caller instead, and a datagram that can never be sent as built ends the session.
|
||||
* Zero for stream transports.
|
||||
*/
|
||||
sendFailures: number;
|
||||
}
|
||||
|
||||
export interface CultNetReconnectPolicy {
|
||||
|
|
@ -195,6 +202,7 @@ export class TcpFramedTransportConnection extends EventEmitter implements CultNe
|
|||
framesReceived: 0,
|
||||
framesSent: 0,
|
||||
packetsDropped: 0,
|
||||
sendFailures: 0,
|
||||
};
|
||||
|
||||
constructor(stream: Duplex, profile: CultNetTransportProfile) {
|
||||
|
|
|
|||
|
|
@ -2825,6 +2825,89 @@ test("server-mode transport admits a restarted client and keeps its session when
|
|||
}
|
||||
});
|
||||
|
||||
// A request belongs to the session generation it arrived on. A client that connects again
|
||||
// from the same endpoint while a handler runs owns that endpoint now: nothing the handler
|
||||
// returns or throws for the old generation may reach it.
|
||||
async function restartDuringHandler(settle: (release: { resolve: () => void; reject: (error: Error) => void }) => void) {
|
||||
let release: { resolve: () => void; reject: (error: Error) => void } | undefined;
|
||||
const server = await startCultNetOperationServer({
|
||||
runtimeId: "sai-sidecar",
|
||||
handler: async (request) => {
|
||||
if (request.messageId === "held") {
|
||||
await new Promise<void>((resolve, reject) => { release = { resolve, reject: (error) => reject(error) }; });
|
||||
}
|
||||
return {
|
||||
schemaVersion: "cultnet.operation_response.v0",
|
||||
messageId: request.messageId,
|
||||
serviceId: request.serviceId,
|
||||
operation: request.operation,
|
||||
status: "ok",
|
||||
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: request.payload,
|
||||
diagnostics: [],
|
||||
sourceRuntimeId: "sai-sidecar",
|
||||
};
|
||||
},
|
||||
});
|
||||
const connectionId = 0x43554c54;
|
||||
const port = Number(new URL(server.endpoint).port);
|
||||
const socket = await bindUdpSocket();
|
||||
let session = new CultNetRudpSession({ connectionId, initialSequence: 50_000 });
|
||||
const answered: string[] = [];
|
||||
const goodbyes: string[] = [];
|
||||
socket.on("message", (wire) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
if (packet.packetType === "disconnect") goodbyes.push(Buffer.from(packet.payload ?? []).toString("utf8"));
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (packet.packetType === "accept" || packet.reliable) {
|
||||
socket.send(encodeRudpPacket(session.createAckForReceived(packet.sequence)), port, "127.0.0.1");
|
||||
}
|
||||
for (const frame of result.delivered) {
|
||||
answered.push((decodeMsgpack(frame.payload) as { messageId: string }).messageId);
|
||||
}
|
||||
});
|
||||
const toServer = (packet: CultNetRudpPacket) => socket.send(encodeRudpPacket(packet), port, "127.0.0.1");
|
||||
const request = (messageId: string) => Buffer.from(encode(encodeCultNetMessageForWire({
|
||||
schemaVersion: "cultnet.operation_request.v0",
|
||||
messageId,
|
||||
serviceId: "sai.vn",
|
||||
operation: "describe",
|
||||
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: "gaZzY2hlbWE=",
|
||||
} satisfies CultNetOperationRequestMessage)));
|
||||
try {
|
||||
toServer(session.createConnect(Date.now()));
|
||||
await waitFor(() => session.connected, "the first Accept");
|
||||
toServer(session.send("schema", request("held"), { reliable: true, ordered: true }));
|
||||
await waitFor(() => release !== undefined, "the held handler");
|
||||
|
||||
session = new CultNetRudpSession({ connectionId, initialSequence: 7 });
|
||||
toServer(session.createConnect(Date.now()));
|
||||
await waitFor(() => session.connected, "the restarted client's Accept");
|
||||
settle(release!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
toServer(session.send("schema", request("after-restart"), { reliable: true, ordered: true }));
|
||||
await waitFor(() => answered.includes("after-restart"), "the restarted client's answer");
|
||||
|
||||
assert.deepEqual(answered, ["after-restart"], "the old generation's response reached the new session");
|
||||
assert.deepEqual(goodbyes, [], "a goodbye for the old generation reached the new session");
|
||||
assert.equal(session.connected, true);
|
||||
} finally {
|
||||
socket.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("an operation response for a generation a new Connect replaced is not sent into the new session", async () => {
|
||||
await restartDuringHandler((release) => release.resolve());
|
||||
});
|
||||
|
||||
test("an operation handler that fails after a new Connect replaced its generation says no goodbye to the new session", async () => {
|
||||
await restartDuringHandler((release) => release.reject(new Error("handler failed")));
|
||||
});
|
||||
|
||||
test("RUDP operation service admits a restarted client on the same address", async () => {
|
||||
const server = await startCultNetOperationServer({
|
||||
runtimeId: "sai-sidecar",
|
||||
|
|
|
|||
|
|
@ -61,13 +61,6 @@ const cultMeshTsRoot = resolve(cultLibRoot, "packages", "cultmesh-ts");
|
|||
const cultMeshQuicPeerScript = resolve(cultMeshTsRoot, "dist-test", "test", "interop", "cultmesh-quic-peer.js");
|
||||
const quicNativeTestsProject = resolve(cultLibRoot, "tests", "GameCult.Mesh.Quic.Native.Tests", "GameCult.Mesh.Quic.Native.Tests.csproj");
|
||||
const quicManagedTestsProject = resolve(cultLibRoot, "tests", "GameCult.Mesh.Quic.Tests", "GameCult.Mesh.Quic.Tests.csproj");
|
||||
const rustBinaryPath = resolve(
|
||||
cultnetRsRoot,
|
||||
"target",
|
||||
"debug",
|
||||
"examples",
|
||||
process.platform === "win32" ? "cultnet_interop_peer.exe" : "cultnet_interop_peer",
|
||||
);
|
||||
|
||||
const discoveryGroup = "239.77.44.11";
|
||||
const serveReadyTimeoutMs = 90_000;
|
||||
|
|
@ -78,6 +71,67 @@ let csharpInteropPeerBuild: Promise<void> | undefined;
|
|||
let kotlinInteropPeerBuild: Promise<void> | undefined;
|
||||
let cultMeshTsInteropPeerBuild: Promise<void> | undefined;
|
||||
|
||||
let rustInteropPeerPathCache: string | undefined;
|
||||
let tsInteropPeerChecked = false;
|
||||
let pythonInteropChecked = false;
|
||||
|
||||
// Cargo decides where it builds: CARGO_TARGET_DIR, a config's build.target-dir, or the crate's own
|
||||
// target directory. The peer's path is asked of cargo, not assumed.
|
||||
function rustInteropPeerPath(): string {
|
||||
if (rustInteropPeerPathCache !== undefined) {
|
||||
return rustInteropPeerPathCache;
|
||||
}
|
||||
const metadata = spawnSync(cargoCommand, ["metadata", "--format-version", "1", "--no-deps"], {
|
||||
cwd: cultnetRsRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (metadata.status !== 0) {
|
||||
throw new Error(
|
||||
`The Rust interop peer's target directory is unknown: \`cargo metadata\` failed in ${cultnetRsRoot}.\n${metadata.stderr ?? metadata.error?.message ?? ""}`,
|
||||
);
|
||||
}
|
||||
const targetDirectory = (JSON.parse(metadata.stdout) as { target_directory: string }).target_directory;
|
||||
rustInteropPeerPathCache = resolve(
|
||||
targetDirectory,
|
||||
"debug",
|
||||
"examples",
|
||||
process.platform === "win32" ? "cultnet_interop_peer.exe" : "cultnet_interop_peer",
|
||||
);
|
||||
return rustInteropPeerPathCache;
|
||||
}
|
||||
|
||||
// The TypeScript peer imports @gamecult/cultcache-ts from its built output. Unbuilt, the peer dies
|
||||
// on import and a lane fails with a spawn or timeout error that names neither.
|
||||
function tsInteropPeerScript(): string {
|
||||
if (!tsInteropPeerChecked) {
|
||||
try {
|
||||
require.resolve("@gamecult/cultcache-ts");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"@gamecult/cultcache-ts is not built, and the TypeScript interop peer imports it. Run `npm run build --workspace packages/cultcache-ts` first.",
|
||||
);
|
||||
}
|
||||
tsInteropPeerChecked = true;
|
||||
}
|
||||
return tsPeerScript;
|
||||
}
|
||||
|
||||
// The Python peers import msgpack. Missing, each Python lane fails with a traceback in a child's
|
||||
// stderr instead of a reason.
|
||||
function pythonInteropCommand(): string {
|
||||
if (!pythonInteropChecked) {
|
||||
const probe = spawnSync(pythonCommand, ["-c", "import msgpack"], { encoding: "utf8" });
|
||||
if (probe.status !== 0) {
|
||||
throw new Error(
|
||||
`${pythonCommand} cannot import msgpack, which the Python interop peers need. Install the Python packages first: \`python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py\`.\n${probe.stderr ?? probe.error?.message ?? ""}`,
|
||||
);
|
||||
}
|
||||
pythonInteropChecked = true;
|
||||
}
|
||||
return pythonCommand;
|
||||
}
|
||||
|
||||
function resolvePythonCommand(): string {
|
||||
const codexPythonCommand = join(homedir(), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "python", "python.exe");
|
||||
const candidates = process.platform === "win32"
|
||||
|
|
@ -743,7 +797,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
|
||||
logInteropPhase("schema-v0", "start rust peer");
|
||||
servers.push(await spawnServeProcess("rust", {
|
||||
command: rustBinaryPath,
|
||||
command: rustInteropPeerPath(),
|
||||
args: [
|
||||
"serve",
|
||||
"--runtime-id", "rust-peer",
|
||||
|
|
@ -765,7 +819,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
servers.push(await spawnServeProcess("ts", {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"serve",
|
||||
"--runtime-id", "ts-peer",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -805,7 +859,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
|
||||
logInteropPhase("schema-v0", "start python peer");
|
||||
servers.push(await spawnServeProcess("python", {
|
||||
command: pythonCommand,
|
||||
command: pythonInteropCommand(),
|
||||
args: [
|
||||
"-m", "cultnet_py.interop_peer",
|
||||
"serve",
|
||||
|
|
@ -827,7 +881,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
|
||||
logInteropPhase("schema-v0", "typescript dials rust");
|
||||
const tsDial = await runJsonCommand("ts-dial", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"dial",
|
||||
"--runtime-id", "ts-client",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -846,7 +900,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
|
||||
logInteropPhase("schema-v0", "typescript dials typescript over rudp");
|
||||
const tsRudpDial = await runJsonCommand("ts-rudp-dial", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"dial",
|
||||
"--runtime-id", "ts-rudp-client",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -864,7 +918,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
assert.equal(tsRudpDial.fireReceipt.accepted, true);
|
||||
|
||||
logInteropPhase("schema-v0", "rust dials csharp");
|
||||
const rustDial = await runJsonCommand("rust-dial", rustBinaryPath, [
|
||||
const rustDial = await runJsonCommand("rust-dial", rustInteropPeerPath(), [
|
||||
"dial",
|
||||
"--runtime-id", "rust-client",
|
||||
"--runtime-kind", "rust",
|
||||
|
|
@ -899,7 +953,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
assert.equal(csharpDial.fireReceipt.accepted, true);
|
||||
|
||||
logInteropPhase("schema-v0", "python dials rust");
|
||||
const pythonDial = await runJsonCommand("python-dial", pythonCommand, [
|
||||
const pythonDial = await runJsonCommand("python-dial", pythonInteropCommand(), [
|
||||
"-m", "cultnet_py.interop_peer",
|
||||
"dial",
|
||||
"--runtime-id", "python-client",
|
||||
|
|
@ -917,7 +971,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
assert.equal(pythonDial.fireReceipt.accepted, true);
|
||||
|
||||
logInteropPhase("schema-v0", "python dials python over rudp");
|
||||
const pythonRudpDial = await runJsonCommand("python-rudp-dial", pythonCommand, [
|
||||
const pythonRudpDial = await runJsonCommand("python-rudp-dial", pythonInteropCommand(), [
|
||||
"-m", "cultnet_py.interop_peer",
|
||||
"dial",
|
||||
"--runtime-id", "python-rudp-client",
|
||||
|
|
@ -937,7 +991,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
|
||||
logInteropPhase("schema-v0", "typescript dials python");
|
||||
const tsDialPython = await runJsonCommand("ts-dial-python", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"dial",
|
||||
"--runtime-id", "ts-python-client",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -1257,7 +1311,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
|
||||
logInteropPhase("schema-v0", "typescript discovery probe");
|
||||
await expectProbePeers("ts-probe", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"probe",
|
||||
"--runtime-id", "ts-prober",
|
||||
"--discovery-port", String(discoveryPort),
|
||||
|
|
@ -1266,7 +1320,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
], cultNetTsRoot, {}, expectedPeers);
|
||||
|
||||
logInteropPhase("schema-v0", "rust discovery probe");
|
||||
await expectProbePeers("rust-probe", rustBinaryPath, [
|
||||
await expectProbePeers("rust-probe", rustInteropPeerPath(), [
|
||||
"probe",
|
||||
"--runtime-id", "rust-prober",
|
||||
"--discovery-port", String(discoveryPort),
|
||||
|
|
@ -1285,7 +1339,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state
|
|||
], cultLibRoot, {}, expectedPeers);
|
||||
|
||||
logInteropPhase("schema-v0", "python discovery probe");
|
||||
await expectProbePeers("python-probe", pythonCommand, [
|
||||
await expectProbePeers("python-probe", pythonInteropCommand(), [
|
||||
"-m", "cultnet_py.interop_peer",
|
||||
"probe",
|
||||
"--runtime-id", "python-prober",
|
||||
|
|
@ -1319,7 +1373,7 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD
|
|||
});
|
||||
|
||||
servers.push(await spawnServeProcess("rust-rudp", {
|
||||
command: rustBinaryPath,
|
||||
command: rustInteropPeerPath(),
|
||||
args: [
|
||||
"serve",
|
||||
"--runtime-id", "rust-rudp-peer",
|
||||
|
|
@ -1341,7 +1395,7 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD
|
|||
servers.push(await spawnServeProcess("ts-rust-rudp", {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"serve",
|
||||
"--runtime-id", "ts-rust-rudp-peer",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -1361,7 +1415,7 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD
|
|||
|
||||
logInteropPhase("ts-rust-full-rudp", "typescript dials rust over rudp");
|
||||
const tsRudpDial = await runJsonCommand("ts-rust-rudp-dial", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"dial",
|
||||
"--runtime-id", "ts-rust-rudp-client",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -1379,7 +1433,7 @@ test("CultNet TypeScript and Rust full interop peers exchange schema-v0 over RUD
|
|||
assert.equal(tsRudpDial.fireReceipt.accepted, true);
|
||||
|
||||
logInteropPhase("ts-rust-full-rudp", "rust dials typescript over rudp");
|
||||
const rustRudpDial = await runJsonCommand("rust-rudp-dial", rustBinaryPath, [
|
||||
const rustRudpDial = await runJsonCommand("rust-rudp-dial", rustInteropPeerPath(), [
|
||||
"dial",
|
||||
"--runtime-id", "rust-rudp-client",
|
||||
"--runtime-kind", "rust",
|
||||
|
|
@ -2186,7 +2240,7 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP"
|
|||
servers.push(await spawnServeProcess("ts-csharp-rudp", {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"serve",
|
||||
"--runtime-id", "ts-csharp-rudp-peer",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -2206,7 +2260,7 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP"
|
|||
|
||||
logInteropPhase("ts-csharp-full-rudp", "typescript dials csharp over rudp");
|
||||
const tsRudpDial = await runJsonCommand("ts-csharp-rudp-dial", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"dial",
|
||||
"--runtime-id", "ts-csharp-rudp-client",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -2289,7 +2343,7 @@ test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over R
|
|||
servers.push(await spawnServeProcess("ts-kotlin-rudp", {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"serve",
|
||||
"--runtime-id", "ts-kotlin-rudp-peer",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -2309,7 +2363,7 @@ test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over R
|
|||
|
||||
logInteropPhase("ts-kotlin-full-rudp", "typescript dials kotlin over rudp");
|
||||
const tsRudpDial = await runJsonCommand("ts-kotlin-rudp-dial", process.execPath, [
|
||||
tsPeerScript,
|
||||
tsInteropPeerScript(),
|
||||
"dial",
|
||||
"--runtime-id", "ts-kotlin-rudp-client",
|
||||
"--runtime-kind", "node",
|
||||
|
|
@ -2962,7 +3016,7 @@ interface RunningPythonRudpPeer {
|
|||
}
|
||||
|
||||
function spawnPythonRudpPeer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
||||
const child = spawn(pythonCommand, ["-c", pythonRudpPeerScript], {
|
||||
const child = spawn(pythonInteropCommand(), ["-c", pythonRudpPeerScript], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -3018,7 +3072,7 @@ function spawnPythonRudpPeer(options: RudpServerSpawnOptions = {}): RunningPytho
|
|||
}
|
||||
|
||||
function spawnPythonRudpMessagePeer(): RunningPythonRudpPeer {
|
||||
const child = spawn(pythonCommand, ["-c", pythonRudpMessagePeerScript], {
|
||||
const child = spawn(pythonInteropCommand(), ["-c", pythonRudpMessagePeerScript], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
|
@ -3066,7 +3120,7 @@ function spawnPythonRudpMessagePeer(): RunningPythonRudpPeer {
|
|||
}
|
||||
|
||||
function spawnRustRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
||||
const child = spawn(rustBinaryPath, [
|
||||
const child = spawn(rustInteropPeerPath(), [
|
||||
"rudp-serve-once",
|
||||
"--bind-host", "127.0.0.1",
|
||||
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
||||
|
|
@ -3124,7 +3178,7 @@ function spawnRustRudpServer(options: RudpServerSpawnOptions = {}): RunningPytho
|
|||
}
|
||||
|
||||
function spawnRustRudpMessageServer(): RunningPythonRudpPeer {
|
||||
const child = spawn(rustBinaryPath, [
|
||||
const child = spawn(rustInteropPeerPath(), [
|
||||
"rudp-serve-message-once",
|
||||
"--bind-host", "127.0.0.1",
|
||||
], {
|
||||
|
|
@ -3405,7 +3459,7 @@ function spawnKotlinRudpMessageServer(): RunningPythonRudpPeer {
|
|||
}
|
||||
|
||||
function spawnPythonRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
||||
const child = spawn(pythonCommand, ["-c", pythonRudpClientScript, String(remotePort)], {
|
||||
const child = spawn(pythonInteropCommand(), ["-c", pythonRudpClientScript, String(remotePort)], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
|
@ -3420,7 +3474,7 @@ function spawnPythonRudpClient(remotePort: number): Omit<RunningPythonRudpPeer,
|
|||
}
|
||||
|
||||
function spawnPythonRudpMessageClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
||||
const child = spawn(pythonCommand, ["-c", pythonRudpMessageClientScript, String(remotePort)], {
|
||||
const child = spawn(pythonInteropCommand(), ["-c", pythonRudpMessageClientScript, String(remotePort)], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
|
@ -3517,7 +3571,7 @@ function spawnCSharpRudpMessageClient(remotePort: number): Omit<RunningPythonRud
|
|||
}
|
||||
|
||||
function spawnRustRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
||||
const child = spawn(rustBinaryPath, [
|
||||
const child = spawn(rustInteropPeerPath(), [
|
||||
"rudp-dial-once",
|
||||
"--target-host", "127.0.0.1",
|
||||
"--target-port", String(remotePort),
|
||||
|
|
@ -3536,7 +3590,7 @@ function spawnRustRudpClient(remotePort: number): Omit<RunningPythonRudpPeer, "r
|
|||
}
|
||||
|
||||
function spawnRustRudpMessageClient(remotePort: number): Omit<RunningPythonRudpPeer, "ready"> {
|
||||
const child = spawn(rustBinaryPath, [
|
||||
const child = spawn(rustInteropPeerPath(), [
|
||||
"rudp-dial-message-once",
|
||||
"--target-host", "127.0.0.1",
|
||||
"--target-port", String(remotePort),
|
||||
|
|
@ -3832,7 +3886,7 @@ async function runPythonCultMeshClient(port: number): Promise<any> {
|
|||
" 'peers': [{'peerId': peer.peer_id, 'roles': list(peer.roles)} for peer in peers],",
|
||||
"}))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
||||
const { stdout } = await execFileAsync(pythonInteropCommand(), ["-c", script], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -3857,7 +3911,7 @@ async function runPythonCultNetRawClient(port: number): Promise<any> {
|
|||
" 'shards': [{'shardId': shard.get('shardId'), 'epoch': shard.get('epoch')} for shard in shards.get('shards', [])],",
|
||||
"}))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
||||
const { stdout } = await execFileAsync(pythonInteropCommand(), ["-c", script], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -3894,7 +3948,7 @@ async function runPythonCultMeshNodeSync(port: number): Promise<any> {
|
|||
" 'note': note,",
|
||||
"}))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
||||
const { stdout } = await execFileAsync(pythonInteropCommand(), ["-c", script], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -3959,7 +4013,7 @@ async function runPythonCultMeshNodeEmit(port: number): Promise<any> {
|
|||
" 'note': note,",
|
||||
"}))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
||||
const { stdout } = await execFileAsync(pythonInteropCommand(), ["-c", script], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -4013,7 +4067,7 @@ async function runPythonCultNetSubscription(port: number): Promise<any> {
|
|||
" 'recordKey': change.get('document', {}).get('recordKey'),",
|
||||
"}))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
||||
const { stdout } = await execFileAsync(pythonInteropCommand(), ["-c", script], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -4067,7 +4121,7 @@ async function runPythonCultMeshPredictionReconcile(port: number): Promise<any>
|
|||
" 'note': note,",
|
||||
" }))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(pythonCommand, ["-c", script], {
|
||||
const { stdout } = await execFileAsync(pythonInteropCommand(), ["-c", script], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
|
|||
520
packages/cultnet-ts/test/rudp-send-failure.test.ts
Normal file
520
packages/cultnet-ts/test/rudp-send-failure.test.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
// A datagram that cannot be sent to one peer is that peer's problem alone. Node throws
|
||||
// for a send it refuses outright (a peer at UDP port 0, which any spoofer can claim)
|
||||
// and reports a system failure later through the callback; neither may escape a
|
||||
// timer or a receive handler, and only a datagram that can never be sent as built
|
||||
// ends the session that owes it.
|
||||
import assert from "node:assert/strict";
|
||||
import dgram, { type RemoteInfo, type Socket } from "node:dgram";
|
||||
import test from "node:test";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
CultNetRudpSocketTransportConnection,
|
||||
decodeRudpPacket,
|
||||
encodeCultNetMessageForWire,
|
||||
encodeRudpPacket,
|
||||
invokeCultNetOperation,
|
||||
isPermanentSendError,
|
||||
startCultNetOperationServer,
|
||||
type CultNetOperationRequestMessage,
|
||||
type CultNetOperationResponseMessage,
|
||||
} from "../src";
|
||||
|
||||
async function bindUdpSocket(): Promise<Socket> {
|
||||
const socket = dgram.createSocket("udp4");
|
||||
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
||||
return socket;
|
||||
}
|
||||
|
||||
function udpPort(socket: Socket): number {
|
||||
const address = socket.address();
|
||||
assert.notEqual(typeof address, "string");
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, description: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - startedAt > 2_000) {
|
||||
throw new Error(`Timed out waiting for ${description}.`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** A Connect as it arrives from UDP source port 0: Node refuses to send anything back. */
|
||||
function connectFromPortZero(socket: Socket, connectionId: number): void {
|
||||
const wire = Buffer.from(encodeRudpPacket(new CultNetRudpSession({ connectionId }).createConnect(Date.now())));
|
||||
const remote: RemoteInfo = { address: "127.0.0.1", family: "IPv4", port: 0, size: wire.length };
|
||||
socket.emit("message", wire, remote);
|
||||
}
|
||||
|
||||
/** Records every exception that would otherwise end the process. */
|
||||
function watchUncaught(): { errors: unknown[]; stop: () => void } {
|
||||
const errors: unknown[] = [];
|
||||
const listener = (error: unknown) => errors.push(error);
|
||||
process.on("uncaughtException", listener);
|
||||
return { errors, stop: () => process.off("uncaughtException", listener) };
|
||||
}
|
||||
|
||||
test("only a datagram that can never be sent as built is a permanent send failure", () => {
|
||||
const failure = (code: string) => Object.assign(new Error("send failed"), { code });
|
||||
for (const code of ["EMSGSIZE", "EINVAL", "EAFNOSUPPORT", "ERR_SOCKET_BAD_PORT"]) {
|
||||
assert.equal(isPermanentSendError(failure(code)), true, code);
|
||||
}
|
||||
for (const code of ["EHOSTUNREACH", "ENETUNREACH", "ECONNREFUSED", "ENOBUFS", "EAGAIN", "ERR_SOCKET_DGRAM_NOT_RUNNING"]) {
|
||||
assert.equal(isPermanentSendError(failure(code)), false, code);
|
||||
}
|
||||
assert.equal(isPermanentSendError(new Error("no code")), false);
|
||||
});
|
||||
|
||||
test("a server transport answering a Connect from port 0 ends that session and keeps serving", async () => {
|
||||
const serverSocket = await bindUdpSocket();
|
||||
const clientSocket = await bindUdpSocket();
|
||||
const connectionId = 0x10203090;
|
||||
const uncaught = watchUncaught();
|
||||
const server = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-server",
|
||||
socket: serverSocket,
|
||||
mode: "server",
|
||||
connectionId,
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 5,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
const reasons: string[] = [];
|
||||
server.on("error", (error: Error) => errors.push(error));
|
||||
server.on("disconnect", ({ reason }: { reason: Uint8Array }) => reasons.push(Buffer.from(reason).toString("utf8")));
|
||||
const client = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-client",
|
||||
socket: clientSocket,
|
||||
mode: "client",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: udpPort(serverSocket),
|
||||
connectionId,
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 5,
|
||||
});
|
||||
try {
|
||||
connectFromPortZero(serverSocket, connectionId);
|
||||
// Many resend ticks: nothing the port-0 session owed is ever retried.
|
||||
await sleep(100);
|
||||
// The goodbye names the error by its code, never by a message quoting the address.
|
||||
assert.deepEqual(reasons, ["packet could not be sent: ERR_SOCKET_BAD_PORT"]);
|
||||
|
||||
const frames: string[] = [];
|
||||
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(Buffer.from(frame.payload).toString("utf8")));
|
||||
client.connect();
|
||||
await waitFor(() => client.connected && server.connected, "a real client's handshake");
|
||||
client.send("schema", Buffer.from("after", "utf8"));
|
||||
await waitFor(() => frames.length === 1, "the real client's frame");
|
||||
assert.deepEqual(frames, ["after"]);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
client.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an operation server answering a Connect from port 0 starts no session and keeps serving", async () => {
|
||||
const connectionId = 0x43554c54;
|
||||
const uncaught = watchUncaught();
|
||||
// The server owns its socket; this captures it so a datagram can arrive from port 0.
|
||||
const createSocket = dgram.createSocket;
|
||||
let serverSocket: Socket | undefined;
|
||||
Object.assign(dgram, {
|
||||
createSocket: (...args: Parameters<typeof dgram.createSocket>) => {
|
||||
serverSocket = createSocket(...args);
|
||||
return serverSocket;
|
||||
},
|
||||
});
|
||||
const server = await startCultNetOperationServer({
|
||||
runtimeId: "operations",
|
||||
handler: (request): CultNetOperationResponseMessage => ({
|
||||
schemaVersion: "cultnet.operation_response.v0",
|
||||
messageId: request.messageId,
|
||||
serviceId: request.serviceId,
|
||||
operation: request.operation,
|
||||
status: "ok",
|
||||
payloadSchema: request.payloadSchema,
|
||||
payloadEncoding: request.payloadEncoding,
|
||||
payload: request.payload,
|
||||
diagnostics: [],
|
||||
sourceRuntimeId: "operations",
|
||||
}),
|
||||
}).finally(() => Object.assign(dgram, { createSocket }));
|
||||
const raw = await bindUdpSocket();
|
||||
const port = Number(new URL(server.endpoint).port);
|
||||
const accepts: number[] = [];
|
||||
raw.on("message", () => accepts.push(Date.now()));
|
||||
try {
|
||||
connectFromPortZero(serverSocket!, connectionId);
|
||||
// The resend timer runs every 25 ms; a session that had started would owe its Accept.
|
||||
await sleep(150);
|
||||
assert.equal(server.packetsDropped, 1, "the Connect started no session");
|
||||
assert.equal(server.sendFailures, 0);
|
||||
|
||||
// A real peer is still answered.
|
||||
raw.send(encodeRudpPacket(new CultNetRudpSession({ connectionId }).createConnect(0)), port, "127.0.0.1");
|
||||
await waitFor(() => accepts.length > 0, "the real peer's Accept");
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
raw.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a server transport whose resend can never be sent ends the session and sends nothing more of it", async () => {
|
||||
const serverSocket = await bindUdpSocket();
|
||||
const clientSocket = await bindUdpSocket();
|
||||
const connectionId = 0x10203094;
|
||||
const uncaught = watchUncaught();
|
||||
const clientPort = udpPort(clientSocket);
|
||||
// Sends to the client fail as the test says: later through the callback, or at once.
|
||||
let mode: "healthy" | "lost" | "never" = "healthy";
|
||||
const send = serverSocket.send.bind(serverSocket) as (...args: unknown[]) => void;
|
||||
Object.assign(serverSocket, {
|
||||
send: (message: Uint8Array, port: number, address: string, callback?: (error: Error | null) => void) => {
|
||||
if (port !== clientPort || mode === "healthy") {
|
||||
send(message, port, address, callback);
|
||||
} else if (mode === "lost") {
|
||||
process.nextTick(() => callback?.(Object.assign(new Error("lost"), { code: "EHOSTUNREACH" })));
|
||||
} else {
|
||||
throw Object.assign(new Error("never"), { code: "EMSGSIZE" });
|
||||
}
|
||||
},
|
||||
});
|
||||
const server = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-server",
|
||||
socket: serverSocket,
|
||||
mode: "server",
|
||||
connectionId,
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 5,
|
||||
});
|
||||
const errors: string[] = [];
|
||||
const reasons: string[] = [];
|
||||
server.on("error", (error: NodeJS.ErrnoException) => errors.push(error.code ?? ""));
|
||||
server.on("disconnect", ({ reason }: { reason: Uint8Array }) => reasons.push(Buffer.from(reason).toString("utf8")));
|
||||
const client = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-client",
|
||||
socket: clientSocket,
|
||||
mode: "client",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: udpPort(serverSocket),
|
||||
connectionId,
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 5,
|
||||
});
|
||||
try {
|
||||
client.connect();
|
||||
await waitFor(() => client.connected && server.connected, "the handshake");
|
||||
mode = "lost";
|
||||
server.send("schema", Buffer.from("one", "utf8"));
|
||||
server.send("schema", Buffer.from("two", "utf8"));
|
||||
await waitFor(() => server.stats.sendFailures >= 4, "both writes lost, and lost again when resent");
|
||||
assert.deepEqual(errors, [], "a loss that may pass is counted, never emitted");
|
||||
assert.deepEqual(reasons, []);
|
||||
|
||||
// Both writes are due again together; the first can never be sent.
|
||||
mode = "never";
|
||||
await waitFor(() => reasons.length > 0, "the session to end");
|
||||
await sleep(50);
|
||||
assert.deepEqual(reasons, ["packet could not be sent: EMSGSIZE"]);
|
||||
assert.equal(server.connected, false);
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
client.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The kernel refuses a datagram to the limited broadcast address from a socket that has
|
||||
// not asked for broadcast (EACCES): a real failure that may pass once the socket or the
|
||||
// route changes, so it is a lost datagram. Node reports it only to the send's callback,
|
||||
// never on the socket's "error" event, so nothing may turn it into an "error" either.
|
||||
const UNREACHABLE = { host: "255.255.255.255", port: 9 };
|
||||
|
||||
test("a client transport with no error listener counts a send the network refuses and does not end the process", async () => {
|
||||
const socket = await new Promise<Socket>((resolve) => {
|
||||
const created = dgram.createSocket("udp4");
|
||||
created.bind(0, "0.0.0.0", () => resolve(created));
|
||||
});
|
||||
const uncaught = watchUncaught();
|
||||
const client = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-client",
|
||||
socket,
|
||||
mode: "client",
|
||||
remoteHost: UNREACHABLE.host,
|
||||
remotePort: UNREACHABLE.port,
|
||||
connectionId: 0x10203095,
|
||||
resendDelayMs: 10,
|
||||
resendPollMs: 5,
|
||||
});
|
||||
try {
|
||||
client.connect();
|
||||
// The Connect and its resends, each refused by the kernel.
|
||||
await waitFor(() => client.stats.sendFailures >= 5, "the refused Connect and its resends");
|
||||
assert.equal(client.connected, false);
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an operation call the network refuses rejects with a timeout and does not end the process", async () => {
|
||||
const uncaught = watchUncaught();
|
||||
try {
|
||||
await assert.rejects(
|
||||
invokeCultNetOperation(
|
||||
`rudp://${UNREACHABLE.host}:${UNREACHABLE.port}`,
|
||||
{
|
||||
schemaVersion: "cultnet.operation_request.v0",
|
||||
messageId: "refused",
|
||||
serviceId: "service",
|
||||
operation: "operation",
|
||||
payloadSchema: "payload",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: "gaZzY2hlbWE=",
|
||||
sourceRuntimeId: "caller",
|
||||
},
|
||||
{ runtimeId: "caller", timeoutMs: 400 },
|
||||
),
|
||||
/CultNet operation connection timed out/,
|
||||
);
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// A send's failure can be reported after the generation that sent it ended: the client
|
||||
// connected again from the same endpoint in the meantime. That failure is owed by nothing;
|
||||
// it may not end the generation that replaced it or say goodbye to the endpoint.
|
||||
|
||||
/** Makes the next send to `port` fail as can never pass as built, reported `delayMs` later. */
|
||||
function failNextSendLate(socket: Socket, port: number, delayMs: number): { arm: () => void } {
|
||||
let armed = false;
|
||||
const send = socket.send.bind(socket) as (...args: unknown[]) => void;
|
||||
Object.assign(socket, {
|
||||
send: (message: Uint8Array, toPort: number, address: string, callback?: (error: Error | null) => void) => {
|
||||
if (toPort !== port || !armed) {
|
||||
send(message, toPort, address, callback);
|
||||
return;
|
||||
}
|
||||
armed = false;
|
||||
setTimeout(() => callback?.(Object.assign(new Error("late"), { code: "EMSGSIZE" })), delayMs);
|
||||
},
|
||||
});
|
||||
return { arm: () => { armed = true; } };
|
||||
}
|
||||
|
||||
/** A client driven packet by packet from one socket, so it can connect again from the same endpoint. */
|
||||
async function rawClient(serverPort: number, connectionId: number) {
|
||||
const socket = await bindUdpSocket();
|
||||
const received: ReturnType<typeof decodeRudpPacket>[] = [];
|
||||
socket.on("message", (wire) => received.push(decodeRudpPacket(wire)));
|
||||
const toServer = (packet: Parameters<typeof encodeRudpPacket>[0]) => socket.send(encodeRudpPacket(packet), serverPort, "127.0.0.1");
|
||||
const connect = async (initialSequence: number) => {
|
||||
const session = new CultNetRudpSession({ connectionId, initialSequence });
|
||||
received.length = 0;
|
||||
toServer(session.createConnect(Date.now()));
|
||||
await waitFor(() => received.some((packet) => packet.packetType === "accept"), "the Accept");
|
||||
const accept = received.find((packet) => packet.packetType === "accept")!;
|
||||
session.receive(accept, Date.now());
|
||||
toServer(session.createAckForReceived(accept.sequence));
|
||||
return session;
|
||||
};
|
||||
return { socket, received, toServer, connect };
|
||||
}
|
||||
|
||||
test("a transport ignores a send failure reported after the generation that sent it ended", async () => {
|
||||
const serverSocket = await bindUdpSocket();
|
||||
const connectionId = 0x10203096;
|
||||
const uncaught = watchUncaught();
|
||||
const server = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-server",
|
||||
socket: serverSocket,
|
||||
mode: "server",
|
||||
connectionId,
|
||||
resendPollMs: 1_000,
|
||||
});
|
||||
const reasons: string[] = [];
|
||||
server.on("disconnect", ({ reason }: { reason: Uint8Array }) => reasons.push(Buffer.from(reason).toString("utf8")));
|
||||
const client = await rawClient(udpPort(serverSocket), connectionId);
|
||||
const late = failNextSendLate(serverSocket, udpPort(client.socket), 150);
|
||||
try {
|
||||
await client.connect(10);
|
||||
late.arm();
|
||||
server.send("schema", Buffer.from("old", "utf8"));
|
||||
await client.connect(5_000);
|
||||
await sleep(250);
|
||||
|
||||
assert.deepEqual(reasons, []);
|
||||
assert.equal(server.connected, true);
|
||||
assert.deepEqual(client.received.filter((packet) => packet.packetType === "disconnect"), []);
|
||||
assert.deepEqual(uncaught.errors, []);
|
||||
} finally {
|
||||
uncaught.stop();
|
||||
client.socket.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an operation server ignores a send failure reported after the generation that sent it ended", async () => {
|
||||
const connectionId = 0x43554c54;
|
||||
const createSocket = dgram.createSocket;
|
||||
let serverSocket: Socket | undefined;
|
||||
Object.assign(dgram, {
|
||||
createSocket: (...args: Parameters<typeof dgram.createSocket>) => {
|
||||
serverSocket = createSocket(...args);
|
||||
return serverSocket;
|
||||
},
|
||||
});
|
||||
const server = await startCultNetOperationServer({
|
||||
runtimeId: "operations",
|
||||
handler: (request): CultNetOperationResponseMessage => ({
|
||||
schemaVersion: "cultnet.operation_response.v0",
|
||||
messageId: request.messageId,
|
||||
serviceId: request.serviceId,
|
||||
operation: request.operation,
|
||||
status: "ok",
|
||||
payloadSchema: request.payloadSchema,
|
||||
payloadEncoding: request.payloadEncoding,
|
||||
payload: request.payload,
|
||||
diagnostics: [],
|
||||
sourceRuntimeId: "operations",
|
||||
}),
|
||||
}).finally(() => Object.assign(dgram, { createSocket }));
|
||||
const client = await rawClient(Number(new URL(server.endpoint).port), connectionId);
|
||||
const late = failNextSendLate(serverSocket!, udpPort(client.socket), 150);
|
||||
const request = (session: CultNetRudpSession, messageId: string) => session.sendMany("schema", encode(encodeCultNetMessageForWire({
|
||||
schemaVersion: "cultnet.operation_request.v0",
|
||||
messageId,
|
||||
serviceId: "service",
|
||||
operation: "operation",
|
||||
payloadSchema: "payload",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: "gaZzY2hlbWE=",
|
||||
sourceRuntimeId: "caller",
|
||||
} satisfies CultNetOperationRequestMessage, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() });
|
||||
try {
|
||||
const old = await client.connect(10);
|
||||
// The response to this request is the next send, and it fails after the client reconnected.
|
||||
late.arm();
|
||||
for (const packet of request(old, "old")) client.toServer(packet);
|
||||
const renewed = await client.connect(5_000);
|
||||
await sleep(250);
|
||||
assert.deepEqual(client.received.filter((packet) => packet.packetType === "disconnect"), []);
|
||||
assert.equal(server.sendFailures, 0);
|
||||
|
||||
// The session that replaced it is still served.
|
||||
let read = client.received.length;
|
||||
let answered = 0;
|
||||
for (const packet of request(renewed, "after")) client.toServer(packet);
|
||||
await waitFor(() => {
|
||||
for (; read < client.received.length; read += 1) {
|
||||
const packet = client.received[read]!;
|
||||
if (packet.packetType === "data") answered += renewed.receive(packet, Date.now()).delivered.length;
|
||||
}
|
||||
return answered > 0;
|
||||
}, "the renewed session's response");
|
||||
} finally {
|
||||
client.socket.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a caller's send larger than a UDP datagram throws and takes nothing from the session", async () => {
|
||||
const serverSocket = await bindUdpSocket();
|
||||
const clientSocket = await bindUdpSocket();
|
||||
const connectionId = 0x10203097;
|
||||
const server = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-server",
|
||||
socket: serverSocket,
|
||||
mode: "server",
|
||||
connectionId,
|
||||
});
|
||||
const client = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-client",
|
||||
socket: clientSocket,
|
||||
mode: "client",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: udpPort(serverSocket),
|
||||
connectionId,
|
||||
});
|
||||
const frames: number[] = [];
|
||||
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(frame.payload.byteLength));
|
||||
// 36 header bytes and the six of "schema": the largest payload one IPv4 datagram carries.
|
||||
const largest = 65_507 - 36 - "schema".length;
|
||||
try {
|
||||
client.connect();
|
||||
await waitFor(() => client.connected && server.connected, "the handshake");
|
||||
await waitFor(() => client.outstandingReliablePacketCount === 0, "the Connect acknowledged");
|
||||
|
||||
client.send("schema", new Uint8Array(largest));
|
||||
assert.throws(() => client.send("schema", new Uint8Array(largest + 1)), { code: "EMSGSIZE" });
|
||||
assert.equal(client.stats.framesSent, 1);
|
||||
client.send("schema", Buffer.from("next", "utf8"));
|
||||
|
||||
// The refused send took no sequence: the next frame is not held behind a gap.
|
||||
await waitFor(() => frames.length === 2, "both sent frames");
|
||||
assert.deepEqual(frames, [largest, 4]);
|
||||
assert.equal(client.connected, true);
|
||||
await waitFor(() => client.outstandingReliablePacketCount === 0, "both frames acknowledged");
|
||||
} finally {
|
||||
client.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The pre-check measures one fragment, the largest datagram a fragmented send makes. Fragments
|
||||
// of 1 KiB and of 40,000 bytes both leave a 70,000-byte send legal; the second size is large
|
||||
// enough that any small multiple of it would not be.
|
||||
for (const maxFragmentBytes of [1024, 40_000]) {
|
||||
test(`a fragmented send larger than one UDP datagram is sent, because each datagram carries one fragment (${maxFragmentBytes}-byte fragments)`, async () => {
|
||||
const serverSocket = await bindUdpSocket();
|
||||
const clientSocket = await bindUdpSocket();
|
||||
const connectionId = 0x10203095;
|
||||
const server = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-server",
|
||||
socket: serverSocket,
|
||||
mode: "server",
|
||||
connectionId,
|
||||
});
|
||||
const client = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "rudp-client",
|
||||
socket: clientSocket,
|
||||
mode: "client",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: udpPort(serverSocket),
|
||||
connectionId,
|
||||
maxFragmentBytes,
|
||||
});
|
||||
const frames: number[] = [];
|
||||
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(frame.payload.byteLength));
|
||||
// Larger than any one datagram can carry, over IPv4 or IPv6.
|
||||
const payloadBytes = 70_000;
|
||||
try {
|
||||
client.connect();
|
||||
await waitFor(() => client.connected && server.connected, "the handshake");
|
||||
|
||||
assert.doesNotThrow(() => client.send("schema", new Uint8Array(payloadBytes)));
|
||||
assert.equal(client.stats.framesSent, 1);
|
||||
|
||||
await waitFor(() => frames.length === 1, "the reassembled frame");
|
||||
assert.deepEqual(frames, [payloadBytes]);
|
||||
assert.equal(client.connected, true);
|
||||
} finally {
|
||||
client.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Concurrent;
|
|||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Logging;
|
||||
|
||||
|
|
@ -169,6 +170,9 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public CultNetTransportStats Stats => _transport.Stats;
|
||||
|
||||
// The listener this server drives, for tests that inject send failures through its seams.
|
||||
internal CultNetRudpSocketTransportServer Transport => _transport;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently tracked RUDP peers.
|
||||
/// </summary>
|
||||
|
|
@ -283,26 +287,47 @@ namespace GameCult.Networking
|
|||
return new RudpCultNetSchemaPollResult(consumed, dispatched);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The goodbye's reason when a handler fails on a peer's message: the server could not handle
|
||||
/// it, which says nothing about whether the peer's packet was well formed.
|
||||
/// </summary>
|
||||
internal static readonly byte[] HandlerFailedReason = Encoding.UTF8.GetBytes("server could not handle a message");
|
||||
|
||||
private static string SessionOutcome(bool ended) =>
|
||||
ended ? "that peer's session ended." : "that peer's session had already ended.";
|
||||
|
||||
private async Task<bool> DispatchAsync(CultNetRudpSocketServerFrame delivered)
|
||||
{
|
||||
if (!string.Equals(delivered.Frame.ChannelId, "schema", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var message = CultNetSchemaMessageSerialization.Deserialize(delivered.Frame.Payload);
|
||||
// A peer's message that cannot be decoded (malformed bytes, an unknown schemaVersion) or
|
||||
// handled ends that peer's session, never the poll: the drain goes on serving every other
|
||||
// peer. The transport already acknowledged the frame, so the session cannot be kept (a
|
||||
// retransmit would never be handled). The peer is told, and the drop is counted and logged.
|
||||
// The C# TCP schema server (CultNetTcpSchemaTransport.ServePeerAsync) likewise ends the one
|
||||
// peer whose message fails to decode or handle. A handler is still expected to answer its
|
||||
// own typed refusals on the wire (HandleSnapshotRequestV1Async does, R-AM); this is the
|
||||
// backstop for whatever it does not. The log names the fault's type only: its message can
|
||||
// quote what the peer sent.
|
||||
ICultNetSchemaMessage message;
|
||||
try
|
||||
{
|
||||
message = CultNetSchemaMessageSerialization.Deserialize(delivered.Frame.Payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The peer sent something no runtime can read: the refusal is the peer's.
|
||||
var ended = _transport.EndPeerSession(delivered.Peer, CultNetRudpSession.RefusedPacketReason);
|
||||
Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); {SessionOutcome(ended)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_handlers.TryGetValue(message.GetType(), out var handler) || handler == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// R-AG: a handler is expected to catch its own typed refusals (HandleSnapshotRequestV1Async
|
||||
// catches the three selection exceptions and answers cursor_invalid/selection_invalid/
|
||||
// reference_outside_target on the wire, R-AM) - this catch is the backstop for whatever a
|
||||
// handler does not, so an untyped exception from a malformed/hostile message can never
|
||||
// reach the poll loop and stall the server for every other peer. It still answers nothing
|
||||
// on the wire (an exception this generic carries no typed refusal to report) and drops the
|
||||
// one malformed dispatch, exactly as an unregistered handler already does just above -
|
||||
// R-AM: but it always logs. A backstop that swallows silently is a compensator, and an
|
||||
// untested, unlogged one hid the SM-6 ArgumentNullException regression for a whole batch.
|
||||
try
|
||||
{
|
||||
var result = handler.DynamicInvoke(message, new RudpCultNetSchemaServerPeer(this, delivered.Peer));
|
||||
|
|
@ -313,11 +338,14 @@ namespace GameCult.Networking
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// DynamicInvoke wraps a synchronously-thrown handler fault in TargetInvocationException,
|
||||
// whose own .Message is the generic "Exception has been thrown by the target of an
|
||||
// invocation." - log the real fault, not that wrapper.
|
||||
// DynamicInvoke wraps a synchronously-thrown handler fault in TargetInvocationException;
|
||||
// name the real fault, not that wrapper. The fault is the server's (a response too large
|
||||
// to send, a handler bug) or a refusal the handler did not type, so the goodbye does not
|
||||
// say the peer's packet was refused. A response that failed after its first fragment
|
||||
// already ended the session over the send, and the log says so.
|
||||
var reported = ex is System.Reflection.TargetInvocationException { InnerException: { } inner } ? inner : ex;
|
||||
Logger.LogError($"CultNet RUDP schema dispatch failed for {message.GetType().Name}: {reported.Message}");
|
||||
var ended = _transport.EndPeerSession(delivered.Peer, HandlerFailedReason);
|
||||
Logger.LogError($"CultNet RUDP schema server's {message.GetType().Name} handler failed ({reported.GetType().Name}); {SessionOutcome(ended)}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
|
|
@ -39,6 +40,12 @@ namespace GameCult.Networking
|
|||
/// the peer, or a packet the session refuses.
|
||||
/// </summary>
|
||||
public long PacketsDropped { get; internal set; }
|
||||
/// <summary>
|
||||
/// Gets the number of datagrams a multi-peer server could not send to one peer. Each is
|
||||
/// that peer's lost datagram: a reliable packet stays pending and is resent, and no other
|
||||
/// peer is affected. Zero for single-peer transports, whose callers get the send error.
|
||||
/// </summary>
|
||||
public long SendFailures { get; internal set; }
|
||||
|
||||
internal CultNetTransportStats Snapshot()
|
||||
{
|
||||
|
|
@ -48,7 +55,8 @@ namespace GameCult.Networking
|
|||
FramesReceived = FramesReceived,
|
||||
BytesSent = BytesSent,
|
||||
BytesReceived = BytesReceived,
|
||||
PacketsDropped = PacketsDropped
|
||||
PacketsDropped = PacketsDropped,
|
||||
SendFailures = SendFailures
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1454,10 +1462,64 @@ namespace GameCult.Networking
|
|||
/// sequence, so the peer's sequences are forgotten before the goodbye is built: its ack field
|
||||
/// would otherwise acknowledge the very frame the session refused.
|
||||
/// </summary>
|
||||
internal CultNetRudpPacket EndRefused()
|
||||
internal CultNetRudpPacket EndRefused() => EndWith(RefusedPacketReason);
|
||||
|
||||
/// <summary>
|
||||
/// Ends a session whose peer's frame the application above it refused or failed on, after the
|
||||
/// session acknowledged it. As with <see cref="EndRefused"/>, the peer's sequences are forgotten
|
||||
/// before the goodbye is built.
|
||||
/// </summary>
|
||||
internal CultNetRudpPacket EndWith(byte[] reason)
|
||||
{
|
||||
ResetPeerState();
|
||||
return CreateDisconnect(RefusedPacketReason);
|
||||
return CreateDisconnect(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a failed send can never succeed for the datagram as built, so resending it is
|
||||
/// pointless: it is too large for a datagram (EMSGSIZE, WSAEMSGSIZE), its address is
|
||||
/// malformed for the socket (EINVAL, WSAEINVAL), or belongs to another address family
|
||||
/// (EAFNOSUPPORT, WSAEAFNOSUPPORT). 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.
|
||||
/// </summary>
|
||||
internal static bool IsPermanentSendError(SocketException error) =>
|
||||
error.SocketErrorCode == SocketError.MessageSize ||
|
||||
error.SocketErrorCode == SocketError.InvalidArgument ||
|
||||
error.SocketErrorCode == SocketError.AddressFamilyNotSupported;
|
||||
|
||||
internal const string UnsendablePacketReason = "packet could not be sent";
|
||||
|
||||
/// <summary>
|
||||
/// Ends a session that owes its peer a packet that can never be sent as built. 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. The goodbye's reason names the error.
|
||||
/// </summary>
|
||||
internal CultNetRudpPacket EndUnsendable(SocketException error)
|
||||
{
|
||||
ResetPeerState();
|
||||
return CreateDisconnect(Encoding.UTF8.GetBytes(UnsendablePacketReason + ": " + error.Message));
|
||||
}
|
||||
|
||||
internal uint NextSequence => _nextSequence;
|
||||
|
||||
/// <summary>
|
||||
/// Undoes a send that never reached the wire: every packet it created at or after
|
||||
/// <paramref name="firstSequence"/>, pending or queued, leaves no trace and its sequences are
|
||||
/// reissued, so the peer never sees a gap. Only valid while nothing else has been sent since.
|
||||
/// </summary>
|
||||
internal void WithdrawSend(uint firstSequence)
|
||||
{
|
||||
lock (_pendingReliableGate)
|
||||
{
|
||||
foreach (var sequence in _pendingReliable.Keys.Where(sequence => sequence >= firstSequence).ToArray())
|
||||
_pendingReliable.Remove(sequence);
|
||||
var kept = _queuedReliable.Where(packet => packet.Sequence < firstSequence).ToArray();
|
||||
_queuedReliable.Clear();
|
||||
foreach (var packet in kept)
|
||||
_queuedReliable.Enqueue(packet);
|
||||
}
|
||||
_nextSequence = firstSequence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2677,6 +2739,11 @@ namespace GameCult.Networking
|
|||
/// Gets the last transport-level remote disconnect reason, if one was received.
|
||||
/// </summary>
|
||||
public byte[]? DisconnectReason { get; internal set; }
|
||||
|
||||
// The goodbye's reason once the session ended over a packet it could never send. Written
|
||||
// under SessionGate by whichever thread's send found the failure; the polling thread reads
|
||||
// it when it retires the peer.
|
||||
internal byte[]? UnsendableReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2714,6 +2781,10 @@ namespace GameCult.Networking
|
|||
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
|
||||
private readonly Dictionary<string, CultNetRudpSocketServerPeer> _peers = new Dictionary<string, CultNetRudpSocketServerPeer>(StringComparer.Ordinal);
|
||||
private readonly Queue<CultNetRudpSocketServerFrame> _deliveredFrames = new Queue<CultNetRudpSocketServerFrame>();
|
||||
// Peers whose session ended over a packet it could never send, waiting for the polling thread
|
||||
// to retire them. Only the polling thread changes _peers or raises PeerDisconnected; a send on
|
||||
// a caller's thread ends the session and leaves the peer here.
|
||||
private readonly ConcurrentQueue<CultNetRudpSocketServerPeer> _unsendablePeers = new ConcurrentQueue<CultNetRudpSocketServerPeer>();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2775,8 +2846,28 @@ namespace GameCult.Networking
|
|||
public void Send(CultNetRudpSocketServerPeer peer, string channelId, byte[] payload)
|
||||
{
|
||||
if (peer == null) throw new ArgumentNullException(nameof(peer));
|
||||
SocketException? unsendable = null;
|
||||
var failedIndex = 0;
|
||||
long generation;
|
||||
lock (peer.SessionGate)
|
||||
SendPackets(peer, peer.Session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes));
|
||||
{
|
||||
var firstSequence = peer.Session.NextSequence;
|
||||
var packets = peer.Session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes);
|
||||
generation = peer.Session.Generation;
|
||||
unsendable = SendPackets(peer, packets, out failedIndex);
|
||||
// A caller-directed send that can never succeed is the caller's error and queues
|
||||
// nothing. Only when earlier fragments are already on the wire has the peer seen
|
||||
// part of it, and then the session cannot be kept.
|
||||
if (unsendable != null && failedIndex == 0)
|
||||
peer.Session.WithdrawSend(firstSequence);
|
||||
}
|
||||
if (unsendable != null)
|
||||
{
|
||||
// The polling thread retires the peer; this thread only ends its session.
|
||||
if (failedIndex > 0)
|
||||
EndUnsendableSession(peer, unsendable, generation);
|
||||
throw unsendable;
|
||||
}
|
||||
_stats.FramesSent++;
|
||||
}
|
||||
|
||||
|
|
@ -2821,6 +2912,7 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public bool TryReceiveOnce(out CultNetRudpSocketServerFrame? delivered)
|
||||
{
|
||||
RetireUnsendablePeers();
|
||||
if (_deliveredFrames.Count > 0)
|
||||
{
|
||||
delivered = _deliveredFrames.Dequeue();
|
||||
|
|
@ -2883,13 +2975,24 @@ namespace GameCult.Networking
|
|||
_peers.TryGetValue(peerKey, out var admitted);
|
||||
// The admitted peer's session decides whether this Connect repeats the one it accepted:
|
||||
// answer a repeat with the Accept already owed and queue nothing. Any other Connect is
|
||||
// a new generation.
|
||||
// a new generation, and the admitted peer's session ends here, under the gate every
|
||||
// Send takes: a caller still holding the replaced peer can send nothing more into the
|
||||
// endpoint the new session now owns.
|
||||
if (admitted != null)
|
||||
{
|
||||
var repeats = false;
|
||||
SocketException? unsendableReply = null;
|
||||
long admittedGeneration;
|
||||
lock (admitted.SessionGate)
|
||||
{
|
||||
if (admitted.Session.ConnectRepeats(packet))
|
||||
admittedGeneration = admitted.Session.Generation;
|
||||
if (!admitted.Session.ConnectRepeats(packet))
|
||||
{
|
||||
admitted.Session.ResetPeerState();
|
||||
}
|
||||
else
|
||||
{
|
||||
repeats = true;
|
||||
CultNetRudpPacket reply;
|
||||
try
|
||||
{
|
||||
|
|
@ -2900,10 +3003,15 @@ namespace GameCult.Networking
|
|||
_stats.PacketsDropped++;
|
||||
return true;
|
||||
}
|
||||
SendPacket(admitted.RemoteEndPoint, reply);
|
||||
return true;
|
||||
unsendableReply = SendPacket(admitted.RemoteEndPoint, reply);
|
||||
}
|
||||
}
|
||||
if (repeats)
|
||||
{
|
||||
if (unsendableReply != null)
|
||||
EndUnsendablePeer(admitted, unsendableReply, admittedGeneration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var peer = new CultNetRudpSocketServerPeer(
|
||||
|
|
@ -2916,9 +3024,20 @@ namespace GameCult.Networking
|
|||
ResendDelayMs = _resendDelayMs,
|
||||
MaxPendingReliablePackets = _maxPendingReliablePackets
|
||||
}));
|
||||
_peers[peerKey] = peer;
|
||||
SocketException? unsendableAccept;
|
||||
lock (peer.SessionGate)
|
||||
SendPacket(peer.RemoteEndPoint, peer.Session.AcceptConnect(packet, NowMs(), _acceptPayload));
|
||||
unsendableAccept = SendPacket(peer.RemoteEndPoint, peer.Session.AcceptConnect(packet, NowMs(), _acceptPayload));
|
||||
if (unsendableAccept != null)
|
||||
{
|
||||
// The peer cannot be answered, so no session starts, and the one it replaced has
|
||||
// already ended.
|
||||
_peers.Remove(peerKey);
|
||||
_stats.PacketsDropped++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_peers[peerKey] = peer;
|
||||
}
|
||||
if (admitted != null)
|
||||
{
|
||||
admitted.DisconnectReason = ReplacedPeerReason;
|
||||
|
|
@ -2935,6 +3054,8 @@ namespace GameCult.Networking
|
|||
|
||||
CultNetRudpReceiveResult? outcome;
|
||||
CultNetRudpPacket? acknowledgement = null;
|
||||
SocketException? unsendable = null;
|
||||
long existingGeneration = 0;
|
||||
lock (existingPeer.SessionGate)
|
||||
{
|
||||
try
|
||||
|
|
@ -2951,20 +3072,15 @@ namespace GameCult.Networking
|
|||
|
||||
if (outcome == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.EndRefused());
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Best effort: the session ends whether or not the peer hears it.
|
||||
}
|
||||
// The session ends whether or not the peer hears the goodbye.
|
||||
SendPacket(existingPeer.RemoteEndPoint, existingPeer.Session.EndRefused());
|
||||
}
|
||||
else
|
||||
{
|
||||
existingGeneration = existingPeer.Session.Generation;
|
||||
if (outcome.Reply != null)
|
||||
SendPacket(existingPeer.RemoteEndPoint, outcome.Reply);
|
||||
SendPackets(existingPeer, outcome.ReadyToSend);
|
||||
unsendable = SendPacket(existingPeer.RemoteEndPoint, outcome.Reply);
|
||||
unsendable ??= SendPackets(existingPeer, outcome.ReadyToSend, out _);
|
||||
if (packet.PacketType == CultNetRudpPacketType.Data)
|
||||
acknowledgement = existingPeer.Session.CreateAckForReceived(packet.Sequence);
|
||||
}
|
||||
|
|
@ -3001,8 +3117,10 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
delivered = _deliveredFrames.Count > 0 ? _deliveredFrames.Dequeue() : null;
|
||||
if (acknowledgement != null)
|
||||
SendPacket(existingPeer.RemoteEndPoint, acknowledgement);
|
||||
if (acknowledgement != null && unsendable == null)
|
||||
unsendable = SendPacket(existingPeer.RemoteEndPoint, acknowledgement);
|
||||
if (unsendable != null)
|
||||
EndUnsendablePeer(existingPeer, unsendable, existingGeneration);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3012,23 +3130,118 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public void PollResends()
|
||||
{
|
||||
RetireUnsendablePeers();
|
||||
if (_socket.Poll(0, SelectMode.SelectRead))
|
||||
return;
|
||||
foreach (var peer in _peers.Values.ToArray())
|
||||
{
|
||||
SocketException? unsendable;
|
||||
long generation;
|
||||
lock (peer.SessionGate)
|
||||
SendPackets(peer, peer.Session.DueResends(NowMs()));
|
||||
{
|
||||
var due = peer.Session.DueResends(NowMs());
|
||||
generation = peer.Session.Generation;
|
||||
unsendable = SendPackets(peer, due, out _);
|
||||
}
|
||||
if (unsendable != null)
|
||||
EndUnsendablePeer(peer, unsendable, generation);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendPackets(CultNetRudpSocketServerPeer peer, IReadOnlyList<CultNetRudpPacket> packets)
|
||||
// Stops at the first packet that can never be sent as built and returns its error and
|
||||
// index; a transient failure is a lost datagram and the loop goes on.
|
||||
private SocketException? SendPackets(CultNetRudpSocketServerPeer peer, IReadOnlyList<CultNetRudpPacket> packets, out int failedIndex)
|
||||
{
|
||||
failedIndex = 0;
|
||||
for (var index = 0; index < packets.Count; index++)
|
||||
{
|
||||
SendPacket(peer.RemoteEndPoint, packets[index]);
|
||||
var unsendable = SendPacket(peer.RemoteEndPoint, packets[index]);
|
||||
if (unsendable != null)
|
||||
{
|
||||
failedIndex = index;
|
||||
return unsendable;
|
||||
}
|
||||
if ((index + 1) % WireBurstPackets == 0 && index + 1 < packets.Count)
|
||||
Thread.Yield();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// An admitted peer owes a packet that can never be sent as built. Inside a poll that ends the
|
||||
// peer's session, never the poll: the peer is told, and PeerDisconnected carries a reason that
|
||||
// names the error.
|
||||
private void EndUnsendablePeer(CultNetRudpSocketServerPeer peer, SocketException error, long generation)
|
||||
{
|
||||
EndUnsendableSession(peer, error, generation);
|
||||
RetireUnsendablePeers();
|
||||
}
|
||||
|
||||
// Ends the session and tells the peer, on whichever thread found the failure. The peer stays in
|
||||
// _peers until the polling thread retires it. The failure belongs to the generation the thread
|
||||
// read under the gate when it sent; if that generation has already ended (another thread
|
||||
// ended it over its own failure, or a new Connect from the endpoint replaced it), or the
|
||||
// thread read it only after the session had ended (a Pong or ack answered on an ended
|
||||
// session the polling thread has not yet retired), there is nothing left to end, and a
|
||||
// goodbye would reach the session that now owns the endpoint or repeat the first one. The
|
||||
// goodbye is built and sent under the gate, so no Connect is admitted between the two.
|
||||
private void EndUnsendableSession(CultNetRudpSocketServerPeer peer, SocketException error, long generation)
|
||||
{
|
||||
lock (peer.SessionGate)
|
||||
{
|
||||
if (peer.Session.Generation != generation || peer.Session.Ended)
|
||||
return;
|
||||
var goodbye = peer.Session.EndUnsendable(error);
|
||||
peer.UnsendableReason = goodbye.Payload;
|
||||
SendPacket(peer.RemoteEndPoint, goodbye);
|
||||
}
|
||||
_unsendablePeers.Enqueue(peer);
|
||||
}
|
||||
|
||||
// Polling thread only. Ends the session of a peer whose delivered frame the application could not
|
||||
// decode or handle, with a goodbye carrying the application's reason. The frame is already
|
||||
// acknowledged, so the session cannot be kept: a retransmit would never be handled. The peer
|
||||
// is told, the drop is counted, and the peer's frames still queued are not delivered. A
|
||||
// session that already ended (a new Connect replaced it, or a send failure ended it) owes no
|
||||
// goodbye, since the endpoint may belong to a new session; the call then returns false.
|
||||
internal bool EndPeerSession(CultNetRudpSocketServerPeer peer, byte[] reason)
|
||||
{
|
||||
if (peer == null) throw new ArgumentNullException(nameof(peer));
|
||||
if (reason == null) throw new ArgumentNullException(nameof(reason));
|
||||
_stats.PacketsDropped++;
|
||||
lock (peer.SessionGate)
|
||||
{
|
||||
if (peer.Session.Ended)
|
||||
return false;
|
||||
SendPacket(peer.RemoteEndPoint, peer.Session.EndWith(reason));
|
||||
}
|
||||
|
||||
var kept = _deliveredFrames.Where(frame => !ReferenceEquals(frame.Peer, peer)).ToArray();
|
||||
_deliveredFrames.Clear();
|
||||
foreach (var frame in kept)
|
||||
_deliveredFrames.Enqueue(frame);
|
||||
|
||||
var key = RemoteKey(peer.RemoteEndPoint);
|
||||
if (!_peers.TryGetValue(key, out var current) || !ReferenceEquals(current, peer))
|
||||
return true;
|
||||
_peers.Remove(key);
|
||||
peer.DisconnectReason = reason;
|
||||
PeerDisconnected?.Invoke(peer);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Polling thread only. A peer a new Connect already replaced was reported then, and its key now
|
||||
// names the peer that replaced it.
|
||||
private void RetireUnsendablePeers()
|
||||
{
|
||||
while (_unsendablePeers.TryDequeue(out var peer))
|
||||
{
|
||||
var key = RemoteKey(peer.RemoteEndPoint);
|
||||
if (!_peers.TryGetValue(key, out var current) || !ReferenceEquals(current, peer))
|
||||
continue;
|
||||
_peers.Remove(key);
|
||||
peer.DisconnectReason = peer.UnsendableReason;
|
||||
PeerDisconnected?.Invoke(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -3043,13 +3256,55 @@ namespace GameCult.Networking
|
|||
_socket.Dispose();
|
||||
}
|
||||
|
||||
private void SendPacket(EndPoint remoteEndPoint, CultNetRudpPacket packet)
|
||||
// A transient send failure is that peer's lost datagram: counted, never thrown. A reliable
|
||||
// packet stays pending and is resent; an unreliable one was allowed to be lost. A permanent
|
||||
// failure (CultNetRudpSession.IsPermanentSendError) is returned: the datagram can never be
|
||||
// sent as built, and the caller decides between refusing the send and ending the peer's
|
||||
// session. Only an encode failure, the listener's own bug, throws.
|
||||
private SocketException? SendPacket(EndPoint remoteEndPoint, CultNetRudpPacket packet)
|
||||
{
|
||||
var wire = CultNetRudpPacketCodec.Encode(packet);
|
||||
var sent = _socket.SendTo(wire, remoteEndPoint);
|
||||
_stats.BytesSent += sent;
|
||||
BeforeSend?.Invoke(remoteEndPoint, packet);
|
||||
try
|
||||
{
|
||||
if (UnsendableAfter.TryGetValue(remoteEndPoint, out var remaining))
|
||||
{
|
||||
if (remaining > 0)
|
||||
{
|
||||
UnsendableAfter[remoteEndPoint] = remaining - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
UnsendableAfter.Remove(remoteEndPoint);
|
||||
throw new SocketException((int)SocketError.MessageSize);
|
||||
}
|
||||
}
|
||||
if (FailingSendPeers.Contains(remoteEndPoint))
|
||||
throw new SocketException((int)SocketError.HostUnreachable);
|
||||
var sent = _socket.SendTo(wire, remoteEndPoint);
|
||||
_stats.BytesSent += sent;
|
||||
}
|
||||
catch (SocketException error) when (CultNetRudpSession.IsPermanentSendError(error))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
_stats.SendFailures++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Test seams, standing in for paths a loopback peer cannot be made to have. Peers whose every
|
||||
// datagram fails to send transiently:
|
||||
internal HashSet<EndPoint> FailingSendPeers { get; } = new HashSet<EndPoint>();
|
||||
|
||||
// Peers whose next datagram after this many fails permanently, once.
|
||||
internal Dictionary<EndPoint, int> UnsendableAfter { get; } = new Dictionary<EndPoint, int>();
|
||||
|
||||
// Runs before each datagram is sent, to order another thread's work between two sends.
|
||||
internal Action<EndPoint, CultNetRudpPacket>? BeforeSend { get; set; }
|
||||
|
||||
private static string RemoteKey(EndPoint endpoint)
|
||||
{
|
||||
return endpoint.ToString() ?? string.Empty;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameCult.Networking.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\..\contracts\cultnet\*.schema.json" LogicalName="GameCult.Networking.Contracts.%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Logging;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// A schema server's poll serves every peer whatever one peer sends. A message that cannot be
|
||||
/// decoded or handled is that peer's failure: its session ends, the drop is counted and logged,
|
||||
/// and the drain goes on to the other peers.
|
||||
/// </summary>
|
||||
public class CultNetRudpSchemaServerPeerInputTests
|
||||
{
|
||||
private const uint ConnectionId = 0x10203096;
|
||||
private const string Canary = "canary-5f1c9e0b";
|
||||
|
||||
private sealed class Client
|
||||
{
|
||||
public Socket Socket = null!;
|
||||
public CultNetRudpSession Session = null!;
|
||||
public EndPoint EndPoint => Socket.LocalEndPoint!;
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public List<string> Errors { get; } = new();
|
||||
public void LogInfo(string message) { }
|
||||
public void LogWarning(string message) { }
|
||||
public void LogError(string message) => Errors.Add(message);
|
||||
public void LogDebug(string message) { }
|
||||
}
|
||||
|
||||
private static Socket Bind()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static List<CultNetRudpPacket> Drain(Socket socket)
|
||||
{
|
||||
var packets = new List<CultNetRudpPacket>();
|
||||
var buffer = new byte[65535];
|
||||
EndPoint from = new IPEndPoint(IPAddress.Any, 0);
|
||||
while (socket.Poll(20_000, SelectMode.SelectRead))
|
||||
{
|
||||
var received = socket.ReceiveFrom(buffer, ref from);
|
||||
packets.Add(CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray()));
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
private static async Task<Client> Connect(RudpCultNetSchemaServer server)
|
||||
{
|
||||
var client = new Client
|
||||
{
|
||||
Socket = Bind(),
|
||||
Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId })
|
||||
};
|
||||
client.Socket.SendTo(CultNetRudpPacketCodec.Encode(client.Session.CreateConnect(0)), server.LocalEndPoint);
|
||||
await server.PollAvailableAsync(8);
|
||||
client.Session.Receive(Drain(client.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void SendSchema(RudpCultNetSchemaServer server, Client client, byte[] payload) =>
|
||||
client.Socket.SendTo(
|
||||
CultNetRudpPacketCodec.Encode(client.Session.Send("schema", payload, new CultNetRudpSendOptions { Reliable = true, Ordered = true })),
|
||||
server.LocalEndPoint);
|
||||
|
||||
private static byte[] Malformed() => new byte[] { 0xc1 };
|
||||
|
||||
// Well-formed MessagePack naming a schema version no runtime knows. The decoder's own error
|
||||
// quotes that version.
|
||||
private static byte[] UnknownSchemaVersion() =>
|
||||
MessagePackSerializer.Serialize(
|
||||
new Dictionary<string, object> { ["schemaVersion"] = "cultnet." + Canary + ".v0", ["messageId"] = Canary },
|
||||
CultNetSchemaMessageSerialization.Options);
|
||||
|
||||
private sealed class Scene : IDisposable
|
||||
{
|
||||
public RudpCultNetSchemaServer Server = null!;
|
||||
public Client Bad = null!;
|
||||
public Client Good = null!;
|
||||
public CapturingLogger Logger = new();
|
||||
public List<(string MessageId, EndPoint From)> Served = new();
|
||||
public List<EndPoint> Ended = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Server.Dispose();
|
||||
Bad.Socket.Dispose();
|
||||
Good.Socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// The bad peer's frame is read first, so the drain reaches the good peer only by getting past it.
|
||||
private static async Task<Scene> BadThenGood(byte[] badPayload)
|
||||
{
|
||||
var scene = new Scene
|
||||
{
|
||||
Server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-peer-input",
|
||||
ConnectionId = ConnectionId
|
||||
})
|
||||
};
|
||||
scene.Server.Logger = scene.Logger;
|
||||
scene.Server.OnCultNet<CultNetShardCatalogRequestMessage>((message, peer) =>
|
||||
scene.Served.Add((message.MessageId, peer.RemoteEndPoint)));
|
||||
scene.Server.PeerDisconnected += peer => scene.Ended.Add(((RudpCultNetSchemaServerPeer)peer).RemoteEndPoint);
|
||||
scene.Bad = await Connect(scene.Server);
|
||||
scene.Good = await Connect(scene.Server);
|
||||
|
||||
SendSchema(scene.Server, scene.Bad, badPayload);
|
||||
SendSchema(scene.Server, scene.Good, CultNetSchemaMessageSerialization.Serialize(
|
||||
new CultNetShardCatalogRequestMessage { MessageId = "good-request" }));
|
||||
return scene;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMalformedPayloadDoesNotStopTheDrainServingAnotherPeer()
|
||||
{
|
||||
using var scene = await BadThenGood(Malformed());
|
||||
|
||||
Assert.DoesNotThrowAsync(async () => await scene.Server.PollAvailableAsync(16));
|
||||
|
||||
Assert.That(scene.Served, Is.EqualTo(new[] { ("good-request", scene.Good.EndPoint) }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnUnknownSchemaVersionDoesNotStopTheDrainServingAnotherPeer()
|
||||
{
|
||||
using var scene = await BadThenGood(UnknownSchemaVersion());
|
||||
|
||||
Assert.DoesNotThrowAsync(async () => await scene.Server.PollAvailableAsync(16));
|
||||
|
||||
Assert.That(scene.Served, Is.EqualTo(new[] { ("good-request", scene.Good.EndPoint) }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PollOnceDoesNotThrowForAPeersUndecodableMessage()
|
||||
{
|
||||
using var scene = await BadThenGood(Malformed());
|
||||
|
||||
Assert.DoesNotThrowAsync(async () =>
|
||||
{
|
||||
for (var attempt = 0; attempt < 16; attempt++)
|
||||
await scene.Server.PollOnceAsync();
|
||||
});
|
||||
|
||||
Assert.That(scene.Served, Is.EqualTo(new[] { ("good-request", scene.Good.EndPoint) }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APeerWhoseMessageCannotBeDecodedHasItsSessionEnded()
|
||||
{
|
||||
using var scene = await BadThenGood(Malformed());
|
||||
var dropped = scene.Server.Stats.PacketsDropped;
|
||||
|
||||
try { await scene.Server.PollAvailableAsync(16); }
|
||||
catch (MessagePackSerializationException) { Assert.Fail("the poll threw for a peer's input"); }
|
||||
|
||||
// The frame was already acknowledged, so the session cannot be kept: the peer is told,
|
||||
// the listener forgets it, and the drop is counted.
|
||||
Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }));
|
||||
Assert.That(scene.Server.Peers.Select(peer => peer.RemoteEndPoint), Is.EqualTo(new[] { scene.Good.EndPoint }));
|
||||
Assert.That(scene.Server.Stats.PacketsDropped, Is.EqualTo(dropped + 1));
|
||||
var goodbye = Drain(scene.Bad.Socket).Single(packet => packet.PacketType == CultNetRudpPacketType.Disconnect);
|
||||
Assert.That(scene.Bad.Session.Receive(goodbye, 1).Disconnected, Is.True);
|
||||
Assert.That(Drain(scene.Good.Socket).Select(packet => packet.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect));
|
||||
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
// The peer's malformed message and a well-formed one after it arrive in one datagram's
|
||||
// delivery: the second is held for order, and both are released together. The session ends
|
||||
// on the first, so the second belongs to no session and is never handled.
|
||||
[Test]
|
||||
public async Task ARefusedPeersFramesStillQueuedAreNotHandled()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-peer-input",
|
||||
ConnectionId = ConnectionId
|
||||
});
|
||||
server.Logger = logger;
|
||||
var served = new List<string>();
|
||||
server.OnCultNet<CultNetShardCatalogRequestMessage>((message, _) => served.Add(message.MessageId));
|
||||
var bad = await Connect(server);
|
||||
var options = new CultNetRudpSendOptions { Reliable = true, Ordered = true };
|
||||
var malformed = bad.Session.Send("schema", Malformed(), options);
|
||||
var after = bad.Session.Send("schema", CultNetSchemaMessageSerialization.Serialize(
|
||||
new CultNetShardCatalogRequestMessage { MessageId = "after-the-refused-one" }), options);
|
||||
bad.Socket.SendTo(CultNetRudpPacketCodec.Encode(after), server.LocalEndPoint);
|
||||
bad.Socket.SendTo(CultNetRudpPacketCodec.Encode(malformed), server.LocalEndPoint);
|
||||
|
||||
await server.PollAvailableAsync(16);
|
||||
|
||||
Assert.That(served, Is.Empty);
|
||||
Assert.That(server.Peers, Is.Empty);
|
||||
Assert.That(logger.Errors, Has.Count.EqualTo(1));
|
||||
bad.Socket.Dispose();
|
||||
}
|
||||
|
||||
private static async Task<(RudpCultNetSchemaServer Server, Client Client, CapturingLogger Logger)> ServerWhoseHandlerAnswers(
|
||||
byte[] response, int? maxFragmentBytes, int? unsendableAfter = null)
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-peer-input",
|
||||
ConnectionId = ConnectionId,
|
||||
MaxFragmentBytes = maxFragmentBytes
|
||||
});
|
||||
server.Logger = logger;
|
||||
server.OnCultNet<CultNetShardCatalogRequestMessage>((_, peer) =>
|
||||
{
|
||||
if (unsendableAfter is { } count)
|
||||
server.Transport.UnsendableAfter[peer.RemoteEndPoint] = count;
|
||||
server.Transport.SendSchema(peer.TransportPeer, response);
|
||||
});
|
||||
var client = await Connect(server);
|
||||
SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(
|
||||
new CultNetShardCatalogRequestMessage { MessageId = "ask" }));
|
||||
return (server, client, logger);
|
||||
}
|
||||
|
||||
// A handler whose own response can never be sent (larger than a datagram, unfragmented) fails
|
||||
// on the server's account, not the peer's. The session still ends, but its goodbye does not say
|
||||
// the peer's packet was refused.
|
||||
[Test]
|
||||
public async Task AHandlerFaultEndsTheSessionWithAReasonThatDoesNotBlameThePeer()
|
||||
{
|
||||
var (server, client, logger) = await ServerWhoseHandlerAnswers(new byte[70_000], maxFragmentBytes: null);
|
||||
using (server)
|
||||
{
|
||||
await server.PollAvailableAsync(16);
|
||||
|
||||
var goodbye = Drain(client.Socket).Single(packet => packet.PacketType == CultNetRudpPacketType.Disconnect);
|
||||
Assert.That(Encoding.UTF8.GetString(goodbye.Payload!), Is.EqualTo("server could not handle a message"));
|
||||
Assert.That(server.Peers, Is.Empty);
|
||||
Assert.That(logger.Errors, Has.Count.EqualTo(1));
|
||||
Assert.That(logger.Errors[0], Does.Contain("handler failed (SocketException)"));
|
||||
Assert.That(logger.Errors[0], Does.Contain("that peer's session ended."));
|
||||
}
|
||||
client.Socket.Dispose();
|
||||
}
|
||||
|
||||
// A response that fails after its first fragment has already ended the session over the send,
|
||||
// with a goodbye that names the send failure. The backstop ends nothing more and says so.
|
||||
[Test]
|
||||
public async Task AHandlerWhoseResponseEndedTheSessionIsLoggedAsAlreadyEnded()
|
||||
{
|
||||
var (server, client, logger) = await ServerWhoseHandlerAnswers(new byte[3000], maxFragmentBytes: 1000, unsendableAfter: 1);
|
||||
using (server)
|
||||
{
|
||||
await server.PollAvailableAsync(16);
|
||||
await server.PollAvailableAsync(16);
|
||||
|
||||
var goodbyes = Drain(client.Socket).Where(packet => packet.PacketType == CultNetRudpPacketType.Disconnect).ToList();
|
||||
Assert.That(goodbyes, Has.Count.EqualTo(1));
|
||||
Assert.That(Encoding.UTF8.GetString(goodbyes[0].Payload!), Does.StartWith("packet could not be sent: "));
|
||||
Assert.That(server.Peers, Is.Empty);
|
||||
Assert.That(logger.Errors, Has.Count.EqualTo(1));
|
||||
Assert.That(logger.Errors[0], Does.Contain("that peer's session had already ended."));
|
||||
}
|
||||
client.Socket.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheDropIsLoggedByErrorTypeWithoutEchoingWhatThePeerSent()
|
||||
{
|
||||
using var scene = await BadThenGood(UnknownSchemaVersion());
|
||||
|
||||
try { await scene.Server.PollAvailableAsync(16); }
|
||||
catch (MessagePackSerializationException) { Assert.Fail("the poll threw for a peer's input"); }
|
||||
|
||||
// The decoder's own error message quotes the unknown version, which is the peer's input.
|
||||
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
|
||||
Assert.That(scene.Logger.Errors[0], Does.Contain(nameof(MessagePackSerializationException)));
|
||||
Assert.That(scene.Logger.Errors[0], Does.Not.Contain(Canary));
|
||||
}
|
||||
}
|
||||
}
|
||||
646
tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs
Normal file
646
tests/GameCult.Networking.Tests/CultNetRudpSendFailureTests.cs
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// One peer whose datagrams cannot be sent must not stop the listener serving the others. The
|
||||
/// failure is injected at the listener's one send function through <c>FailingSendPeers</c>.
|
||||
/// </summary>
|
||||
public class CultNetRudpSendFailureTests
|
||||
{
|
||||
private const uint ConnectionId = 0x10203099;
|
||||
|
||||
private sealed class Peer
|
||||
{
|
||||
public Socket Socket = null!;
|
||||
public CultNetRudpSession Session = null!;
|
||||
public CultNetRudpSocketServerPeer Server = null!;
|
||||
public EndPoint EndPoint => Socket.LocalEndPoint!;
|
||||
}
|
||||
|
||||
private static Socket Bind()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static List<CultNetRudpPacket> Drain(Socket socket)
|
||||
{
|
||||
var packets = new List<CultNetRudpPacket>();
|
||||
var buffer = new byte[65535];
|
||||
EndPoint from = new IPEndPoint(IPAddress.Any, 0);
|
||||
while (socket.Poll(20_000, SelectMode.SelectRead))
|
||||
{
|
||||
var received = socket.ReceiveFrom(buffer, ref from);
|
||||
packets.Add(CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray()));
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
private static Peer Connect(CultNetRudpSocketTransportServer server, EndPoint serverEndPoint)
|
||||
{
|
||||
var peer = new Peer
|
||||
{
|
||||
Socket = Bind(),
|
||||
Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId })
|
||||
};
|
||||
peer.Socket.SendTo(CultNetRudpPacketCodec.Encode(peer.Session.CreateConnect(0)), serverEndPoint);
|
||||
server.ReceiveOnce();
|
||||
peer.Session.Receive(Drain(peer.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
|
||||
peer.Server = server.Peers.Single(p => p.RemoteEndPoint.Equals(peer.EndPoint));
|
||||
return peer;
|
||||
}
|
||||
|
||||
private static (CultNetRudpSocketTransportServer Server, EndPoint ServerEndPoint, Peer X, Peer Y) TwoPeers(int? maxFragmentBytes = null, uint? initialSequence = null)
|
||||
{
|
||||
var serverSocket = Bind();
|
||||
var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-send-failure",
|
||||
Socket = serverSocket,
|
||||
ConnectionId = ConnectionId,
|
||||
ResendDelayMs = 5,
|
||||
MaxFragmentBytes = maxFragmentBytes,
|
||||
InitialSequence = initialSequence
|
||||
});
|
||||
var x = Connect(server, serverSocket.LocalEndPoint!);
|
||||
var y = Connect(server, serverSocket.LocalEndPoint!);
|
||||
return (server, serverSocket.LocalEndPoint!, x, y);
|
||||
}
|
||||
|
||||
private static CultNetRudpPacket ReliableFrame(Peer peer, string text) =>
|
||||
peer.Session.Send("schema", Encoding.UTF8.GetBytes(text), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
|
||||
|
||||
[Test]
|
||||
public void AFailedResendToOnePeerDoesNotStopTheOthersResends()
|
||||
{
|
||||
var (server, _, x, y) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
// Both peers' first datagram is lost, so both have a reliable resend due.
|
||||
server.FailingSendPeers.Add(x.EndPoint);
|
||||
server.FailingSendPeers.Add(y.EndPoint);
|
||||
server.SendSchema(x.Server, "for-x");
|
||||
server.SendSchema(y.Server, "for-y");
|
||||
Assert.That(server.Stats.SendFailures, Is.EqualTo(2));
|
||||
|
||||
// Y's path recovers; X's stays dead. X is visited first.
|
||||
server.FailingSendPeers.Remove(y.EndPoint);
|
||||
Thread.Sleep(30);
|
||||
Assert.DoesNotThrow(() => server.PollResends());
|
||||
|
||||
var resent = Drain(y.Socket).Where(p => p.PacketType == CultNetRudpPacketType.Data).ToList();
|
||||
Assert.That(resent, Has.Count.EqualTo(1));
|
||||
Assert.That(Encoding.UTF8.GetString(resent[0].Payload!), Is.EqualTo("for-y"));
|
||||
Assert.That(server.Stats.SendFailures, Is.GreaterThanOrEqualTo(3));
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(2), "a send failure ends no session");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFailedAckToOnePeerDoesNotStopTheListenerServingTheOthers()
|
||||
{
|
||||
var (server, listenerEndPoint, x, y) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
server.FailingSendPeers.Add(x.EndPoint);
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(ReliableFrame(x, "from-x")), listenerEndPoint);
|
||||
y.Socket.SendTo(CultNetRudpPacketCodec.Encode(ReliableFrame(y, "from-y")), listenerEndPoint);
|
||||
|
||||
var frames = new List<string>();
|
||||
for (var attempt = 0; attempt < 20 && frames.Count < 2; attempt++)
|
||||
{
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
if (server.ReceiveOnce() is { } frame)
|
||||
frames.Add(Encoding.UTF8.GetString(frame.Frame.Payload));
|
||||
});
|
||||
}
|
||||
|
||||
Assert.That(frames, Is.EquivalentTo(new[] { "from-x", "from-y" }));
|
||||
Assert.That(server.Stats.SendFailures, Is.GreaterThanOrEqualTo(1), "X's ack was counted as lost");
|
||||
Assert.That(Drain(y.Socket).Select(p => p.PacketType), Does.Contain(CultNetRudpPacketType.Ack));
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(2));
|
||||
}
|
||||
}
|
||||
|
||||
// A permanent send failure is one that can never succeed for the datagram as built. A
|
||||
// caller-directed send throws it and queues nothing; inside a poll it ends that peer's
|
||||
// session and never the poll.
|
||||
|
||||
private static bool IsUnsendableReason(byte[]? reason)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(reason ?? Array.Empty<byte>());
|
||||
return text.StartsWith("packet could not be sent: ", StringComparison.Ordinal)
|
||||
&& text.Length > "packet could not be sent: ".Length;
|
||||
}
|
||||
|
||||
private static List<CultNetRudpSocketServerPeer> RecordDisconnects(CultNetRudpSocketTransportServer server)
|
||||
{
|
||||
var ended = new List<CultNetRudpSocketServerPeer>();
|
||||
server.PeerDisconnected += ended.Add;
|
||||
return ended;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOversizedSendThrowsTheErrorAndQueuesNothing()
|
||||
{
|
||||
var (server, _, x, y) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
// Each peer's own Accept is still awaiting its ack; the refused send adds nothing to it.
|
||||
var outstanding = x.Server.Session.OutstandingReliablePacketCount;
|
||||
var otherOutstanding = y.Server.Session.OutstandingReliablePacketCount;
|
||||
var error = Assert.Throws<SocketException>(() => server.SendSchema(x.Server, new byte[70_000]))!;
|
||||
Assert.That(CultNetRudpSession.IsPermanentSendError(error), Is.True, error.SocketErrorCode.ToString());
|
||||
Assert.That(server.Stats.SendFailures, Is.Zero);
|
||||
Assert.That(server.Stats.FramesSent, Is.Zero);
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(2), "the caller's error ends no session");
|
||||
Assert.That(x.Server.Session.OutstandingReliablePacketCount, Is.EqualTo(outstanding), "nothing is pending or queued to resend");
|
||||
|
||||
// The refused send consumed no sequence: the next frame is delivered in order instead
|
||||
// of waiting behind a gap that never fills.
|
||||
server.SendSchema(x.Server, "next");
|
||||
var data = Drain(x.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Data);
|
||||
var delivered = x.Session.Receive(data, 1).Delivered;
|
||||
Assert.That(delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)), Is.EqualTo(new[] { "next" }));
|
||||
Assert.That(y.Server.Session.OutstandingReliablePacketCount, Is.EqualTo(otherOutstanding));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARefusedSendWithdrawsTheFragmentsTheWindowQueuedAsWell()
|
||||
{
|
||||
var (server, _, x, _) = TwoPeers(maxFragmentBytes: 1);
|
||||
using (server)
|
||||
{
|
||||
var outstanding = x.Server.Session.OutstandingReliablePacketCount;
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
|
||||
// 1,100 one-byte fragments: the flow window admits the first ones and queues the rest,
|
||||
// and the first datagram can never be sent.
|
||||
Assert.Throws<SocketException>(() => server.SendSchema(x.Server, new byte[1_100]));
|
||||
|
||||
Assert.That(x.Server.Session.OutstandingReliablePacketCount, Is.EqualTo(outstanding), "nothing of the refused send stays queued");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASendThatFailsPermanentlyAfterAFragmentLeftEndsTheSession()
|
||||
{
|
||||
var (server, _, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var ended = RecordDisconnects(server);
|
||||
server.UnsendableAfter[x.EndPoint] = 1;
|
||||
|
||||
var error = Assert.Throws<SocketException>(() => server.SendSchema(x.Server, new byte[3000]))!;
|
||||
Assert.That(CultNetRudpSession.IsPermanentSendError(error), Is.True);
|
||||
// The send ends the session and tells the peer; it leaves the peer for the polling
|
||||
// thread to retire, because only that thread changes the peer set.
|
||||
Assert.That(x.Server.Connected, Is.False);
|
||||
Assert.That(Drain(x.Socket).Last().PacketType, Is.EqualTo(CultNetRudpPacketType.Disconnect));
|
||||
Assert.That(ended, Is.Empty);
|
||||
|
||||
server.ReceiveOnce();
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
Assert.That(ended, Is.EqualTo(new[] { x.Server }));
|
||||
Assert.That(IsUnsendableReason(x.Server.DisconnectReason), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APeerEndedByASendOnAnotherThreadIsRetiredByThePollingThread()
|
||||
{
|
||||
var (server, _, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var retiredOn = new List<int>();
|
||||
server.PeerDisconnected += _ => retiredOn.Add(Environment.CurrentManagedThreadId);
|
||||
server.UnsendableAfter[x.EndPoint] = 1;
|
||||
|
||||
SocketException? thrown = null;
|
||||
var sender = new Thread(() =>
|
||||
{
|
||||
try { server.SendSchema(x.Server, new byte[3000]); }
|
||||
catch (SocketException error) { thrown = error; }
|
||||
});
|
||||
sender.Start();
|
||||
sender.Join();
|
||||
Assert.That(thrown, Is.Not.Null);
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(2), "the sending thread leaves the peer set alone");
|
||||
|
||||
server.PollResends();
|
||||
Assert.That(retiredOn, Is.EqualTo(new[] { Environment.CurrentManagedThreadId }));
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
// A client that connects again from the same endpoint replaces the peer the listener held. A
|
||||
// caller still holding the replaced peer (a handler still running for it, say) reaches nothing
|
||||
// of the session that replaced it: no data and no goodbye.
|
||||
|
||||
/// <summary>X connects again as a new session, and the handshake completes.</summary>
|
||||
private static (CultNetRudpSocketServerPeer Stale, CultNetRudpSocketServerPeer Replacing) Reconnect(
|
||||
CultNetRudpSocketTransportServer server, EndPoint listenerEndPoint, Peer x)
|
||||
{
|
||||
var stale = x.Server;
|
||||
Drain(x.Socket);
|
||||
x.Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId });
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(x.Session.CreateConnect(0)), listenerEndPoint);
|
||||
server.ReceiveOnce();
|
||||
x.Session.Receive(Drain(x.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
|
||||
x.Server = server.Peers.Single(p => p.RemoteEndPoint.Equals(x.EndPoint));
|
||||
Assert.That(x.Server, Is.Not.SameAs(stale));
|
||||
Assert.That(x.Session.Connected, Is.True);
|
||||
return (stale, x.Server);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASendOnAReplacedPeerThrowsAndNoGoodbyeReachesTheSessionThatReplacedIt()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var (stale, replacing) = Reconnect(server, listenerEndPoint, x);
|
||||
var ended = RecordDisconnects(server);
|
||||
Assert.That(stale.Connected, Is.False, "the replaced peer's session ended with the replacement");
|
||||
|
||||
// Had the stale send gone out, a failure after its first fragment would end the
|
||||
// session that sent it and say goodbye to the endpoint.
|
||||
server.UnsendableAfter[x.EndPoint] = 1;
|
||||
Assert.Throws<InvalidOperationException>(() => server.SendSchema(stale, new byte[3000]));
|
||||
server.ReceiveOnce();
|
||||
|
||||
Assert.That(Drain(x.Socket), Is.Empty, "nothing of the stale send reached the client");
|
||||
Assert.That(x.Session.Connected, Is.True);
|
||||
Assert.That(replacing.Connected, Is.True);
|
||||
Assert.That(server.Peers, Has.Member(replacing));
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(2));
|
||||
Assert.That(ended, Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
// With a fixed initial sequence the stale frame would take the sequence the real one gets, and
|
||||
// the real one would be dropped as a duplicate. With a drawn one it would land far ahead of
|
||||
// the new session's window, and nothing after it would be delivered.
|
||||
[TestCase(1000u)]
|
||||
[TestCase(null)]
|
||||
public void ASendOnAReplacedPeerDeliversNothingAndTheReplacingPeersFrameArrives(uint? initialSequence)
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers(initialSequence: initialSequence);
|
||||
using (server)
|
||||
{
|
||||
var (stale, replacing) = Reconnect(server, listenerEndPoint, x);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => server.SendSchema(stale, "STALE"));
|
||||
server.SendSchema(replacing, "REAL");
|
||||
|
||||
var delivered = Drain(x.Socket)
|
||||
.SelectMany(packet => x.Session.Receive(packet, 1).Delivered)
|
||||
.Select(frame => Encoding.UTF8.GetString(frame.Payload))
|
||||
.ToList();
|
||||
Assert.That(delivered, Is.EqualTo(new[] { "REAL" }));
|
||||
}
|
||||
}
|
||||
|
||||
// A send that fails after its first fragment ends the generation it began in, and only that
|
||||
// one. Here X connects again while the send is under way, and the polling thread admits the
|
||||
// new Connect before the fragment that can never be sent. The hook runs that poll inside the
|
||||
// send so the order is fixed; in production it runs on the polling thread between the send
|
||||
// and the failure's handling. The goodbye belonged to the ended generation, and the client
|
||||
// must not hear it: it would believe itself disconnected while the listener holds it
|
||||
// connected.
|
||||
[Test]
|
||||
public void ASendFailureAfterANewConnectReplacedThePeerSaysNoGoodbyeToTheNewSession()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var stale = x.Server;
|
||||
Drain(x.Socket);
|
||||
x.Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId });
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(x.Session.CreateConnect(0)), listenerEndPoint);
|
||||
|
||||
var fragments = 0;
|
||||
server.BeforeSend = (remote, packet) =>
|
||||
{
|
||||
if (!remote.Equals(x.EndPoint) || packet.PacketType != CultNetRudpPacketType.Data || ++fragments < 2)
|
||||
return;
|
||||
server.BeforeSend = null;
|
||||
server.ReceiveOnce();
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
};
|
||||
Assert.Throws<SocketException>(() => server.SendSchema(stale, new byte[3000]));
|
||||
server.ReceiveOnce();
|
||||
|
||||
var packets = Drain(x.Socket);
|
||||
Assert.That(packets.Select(p => p.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect),
|
||||
"the ended generation's goodbye reached the session that replaced it");
|
||||
x.Session.Receive(packets.Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0);
|
||||
Assert.That(x.Session.Connected, Is.True);
|
||||
var replacing = server.Peers.Single(p => p.RemoteEndPoint.Equals(x.EndPoint));
|
||||
Assert.That(replacing, Is.Not.SameAs(stale));
|
||||
Assert.That(replacing.Connected, Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
// A caller's send ends X's session over a fragment it could never send. Before the listener
|
||||
// retires X, the polling thread answers X's Ping on the ended session, and that Pong can never
|
||||
// be sent either. The polling thread read the generation only after the session ended, so the
|
||||
// generation alone cannot tell it the session is gone. It says no second goodbye, and the
|
||||
// first failure's reason stands. The hook admits the Ping while the first goodbye is on its way,
|
||||
// after the session ended and before the peer is queued for retirement.
|
||||
[Test]
|
||||
public void AFailureAnsweringAPingOnAnAlreadyEndedSessionSaysNoSecondGoodbye()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var ended = RecordDisconnects(server);
|
||||
byte[]? firstReason = null;
|
||||
server.BeforeSend = (remote, packet) =>
|
||||
{
|
||||
if (!remote.Equals(x.EndPoint) || packet.PacketType != CultNetRudpPacketType.Disconnect)
|
||||
return;
|
||||
server.BeforeSend = null;
|
||||
firstReason = x.Server.UnsendableReason;
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
server.ReceiveOnce();
|
||||
};
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(x.Session.CreatePing(Encoding.UTF8.GetBytes("ping"))), listenerEndPoint);
|
||||
server.UnsendableAfter[x.EndPoint] = 1;
|
||||
|
||||
Assert.Throws<SocketException>(() => server.SendSchema(x.Server, new byte[3000]));
|
||||
server.ReceiveOnce();
|
||||
|
||||
var goodbyes = Drain(x.Socket).Where(p => p.PacketType == CultNetRudpPacketType.Disconnect).ToList();
|
||||
Assert.That(goodbyes, Has.Count.EqualTo(1));
|
||||
Assert.That(firstReason, Is.Not.Null);
|
||||
Assert.That(x.Server.UnsendableReason, Is.SameAs(firstReason), "the second failure overwrote the first's reason");
|
||||
Assert.That(ended, Is.EqualTo(new[] { x.Server }));
|
||||
Assert.That(x.Server.DisconnectReason, Is.SameAs(firstReason));
|
||||
}
|
||||
}
|
||||
|
||||
// The goodbye for a failed send is sent under the session's gate. The polling thread, admitting
|
||||
// a new Connect from X, waits on that gate, so the ended generation's goodbye can never follow
|
||||
// the new session's Accept. The hook starts the polling thread just before the goodbye leaves
|
||||
// and gives it a second to get ahead if nothing holds it back.
|
||||
[Test]
|
||||
public void AFailedSendsGoodbyeLeavesBeforeANewConnectFromTheEndpointIsAdmitted()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var stale = x.Server;
|
||||
Drain(x.Socket);
|
||||
x.Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId });
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(x.Session.CreateConnect(0)), listenerEndPoint);
|
||||
|
||||
Thread? poller = null;
|
||||
server.BeforeSend = (remote, packet) =>
|
||||
{
|
||||
if (!remote.Equals(x.EndPoint) || packet.PacketType != CultNetRudpPacketType.Disconnect)
|
||||
return;
|
||||
server.BeforeSend = null;
|
||||
poller = new Thread(() => server.ReceiveOnce());
|
||||
poller.Start();
|
||||
poller.Join(1000);
|
||||
};
|
||||
server.UnsendableAfter[x.EndPoint] = 1;
|
||||
Assert.Throws<SocketException>(() => server.SendSchema(stale, new byte[3000]));
|
||||
Assert.That(poller, Is.Not.Null);
|
||||
poller!.Join();
|
||||
|
||||
var types = Drain(x.Socket).Select(p => p.PacketType).ToList();
|
||||
Assert.That(types, Does.Contain(CultNetRudpPacketType.Disconnect));
|
||||
Assert.That(types, Does.Contain(CultNetRudpPacketType.Accept));
|
||||
Assert.That(types.IndexOf(CultNetRudpPacketType.Disconnect), Is.LessThan(types.IndexOf(CultNetRudpPacketType.Accept)),
|
||||
"the ended generation's goodbye followed the new session's Accept");
|
||||
}
|
||||
}
|
||||
|
||||
// An application ending a peer's session (a message it could not decode or handle) says no
|
||||
// goodbye to a session that already ended: a new Connect replaced it, and the endpoint now
|
||||
// belongs to the session that replaced it.
|
||||
[Test]
|
||||
public void EndingAReplacedPeersSessionSaysNoGoodbyeToTheSessionThatReplacedIt()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
var (stale, replacing) = Reconnect(server, listenerEndPoint, x);
|
||||
var ended = RecordDisconnects(server);
|
||||
|
||||
Assert.That(server.EndPeerSession(stale, Encoding.UTF8.GetBytes("application reason")), Is.False);
|
||||
|
||||
Assert.That(Drain(x.Socket).Select(p => p.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect));
|
||||
Assert.That(x.Session.Connected, Is.True);
|
||||
Assert.That(replacing.Connected, Is.True);
|
||||
Assert.That(server.Peers, Has.Member(replacing));
|
||||
Assert.That(ended, Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASessionEndedByACallerAndThePollingThreadTogetherSaysGoodbyeOnce()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers(maxFragmentBytes: 1000);
|
||||
using (server)
|
||||
{
|
||||
var ended = RecordDisconnects(server);
|
||||
// The polling thread acknowledges X's frame outside the session's gate. Just before
|
||||
// that ack goes out, a caller's send to X fails after its first fragment and ends the
|
||||
// session; then the ack can never be sent either.
|
||||
server.BeforeSend = (remote, packet) =>
|
||||
{
|
||||
if (!remote.Equals(x.EndPoint) || packet.PacketType != CultNetRudpPacketType.Ack)
|
||||
return;
|
||||
server.BeforeSend = null;
|
||||
server.UnsendableAfter[x.EndPoint] = 1;
|
||||
var caller = new Thread(() =>
|
||||
{
|
||||
try { server.SendSchema(x.Server, new byte[3000]); }
|
||||
catch (SocketException) { }
|
||||
});
|
||||
caller.Start();
|
||||
caller.Join();
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
};
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(ReliableFrame(x, "from-x")), listenerEndPoint);
|
||||
server.ReceiveOnce();
|
||||
server.PollResends();
|
||||
|
||||
var goodbyes = Drain(x.Socket).Where(p => p.PacketType == CultNetRudpPacketType.Disconnect).ToList();
|
||||
Assert.That(goodbyes, Has.Count.EqualTo(1));
|
||||
Assert.That(ended, Is.EqualTo(new[] { x.Server }));
|
||||
Assert.That(IsUnsendableReason(x.Server.DisconnectReason), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APermanentFailureInResendsEndsOnlyThatPeersSession()
|
||||
{
|
||||
var (server, _, x, y) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
var ended = RecordDisconnects(server);
|
||||
server.FailingSendPeers.Add(x.EndPoint);
|
||||
server.FailingSendPeers.Add(y.EndPoint);
|
||||
server.SendSchema(x.Server, "for-x");
|
||||
server.SendSchema(y.Server, "for-y");
|
||||
|
||||
// X's datagram can now never be sent as built; Y's path recovers. X is visited first.
|
||||
server.FailingSendPeers.Clear();
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
Thread.Sleep(30);
|
||||
Assert.DoesNotThrow(() => server.PollResends());
|
||||
|
||||
Assert.That(server.Peers, Is.EqualTo(new[] { y.Server }));
|
||||
Assert.That(ended, Is.EqualTo(new[] { x.Server }));
|
||||
Assert.That(IsUnsendableReason(x.Server.DisconnectReason), Is.True);
|
||||
var resent = Drain(y.Socket).Where(p => p.PacketType == CultNetRudpPacketType.Data).ToList();
|
||||
Assert.That(resent, Has.Count.EqualTo(1));
|
||||
Assert.That(Encoding.UTF8.GetString(resent[0].Payload!), Is.EqualTo("for-y"));
|
||||
var goodbye = Drain(x.Socket).Single(p => p.PacketType == CultNetRudpPacketType.Disconnect);
|
||||
Assert.That(IsUnsendableReason(goodbye.Payload), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APermanentFailureAcknowledgingAFrameEndsTheSessionAfterTheFrameIsDelivered()
|
||||
{
|
||||
var (server, listenerEndPoint, x, _) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
var ended = RecordDisconnects(server);
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
x.Socket.SendTo(CultNetRudpPacketCodec.Encode(ReliableFrame(x, "from-x")), listenerEndPoint);
|
||||
|
||||
CultNetRudpSocketServerFrame? frame = null;
|
||||
Assert.DoesNotThrow(() => frame = server.ReceiveOnce());
|
||||
|
||||
Assert.That(frame, Is.Not.Null, "the frame is delivered before the session ends");
|
||||
Assert.That(Encoding.UTF8.GetString(frame!.Frame.Payload), Is.EqualTo("from-x"));
|
||||
Assert.That(ended, Is.EqualTo(new[] { x.Server }));
|
||||
Assert.That(IsUnsendableReason(x.Server.DisconnectReason), Is.True);
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
Assert.That(server.Stats.SendFailures, Is.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APermanentFailureAnsweringARepeatedConnectEndsTheSession()
|
||||
{
|
||||
var serverSocket = Bind();
|
||||
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-send-failure",
|
||||
Socket = serverSocket,
|
||||
ConnectionId = ConnectionId,
|
||||
ResendDelayMs = 5
|
||||
});
|
||||
var ended = RecordDisconnects(server);
|
||||
var peer = Bind();
|
||||
var connect = CultNetRudpPacketCodec.Encode(
|
||||
new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId }).CreateConnect(0));
|
||||
peer.SendTo(connect, serverSocket.LocalEndPoint!);
|
||||
server.ReceiveOnce();
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
var admitted = server.Peers.Single();
|
||||
|
||||
server.UnsendableAfter[peer.LocalEndPoint!] = 0;
|
||||
peer.SendTo(connect, serverSocket.LocalEndPoint!);
|
||||
Assert.DoesNotThrow(() => server.ReceiveOnce());
|
||||
|
||||
Assert.That(server.Peers, Is.Empty);
|
||||
Assert.That(ended, Is.EqualTo(new[] { admitted }));
|
||||
Assert.That(IsUnsendableReason(admitted.DisconnectReason), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AConnectWhoseAcceptCanNeverBeSentStartsNoSession()
|
||||
{
|
||||
var serverSocket = Bind();
|
||||
using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
|
||||
{
|
||||
RuntimeId = "csharp-send-failure",
|
||||
Socket = serverSocket,
|
||||
ConnectionId = ConnectionId,
|
||||
ResendDelayMs = 5
|
||||
});
|
||||
var peer = Bind();
|
||||
server.UnsendableAfter[peer.LocalEndPoint!] = 0;
|
||||
peer.SendTo(
|
||||
CultNetRudpPacketCodec.Encode(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId }).CreateConnect(0)),
|
||||
serverSocket.LocalEndPoint!);
|
||||
|
||||
Assert.DoesNotThrow(() => server.ReceiveOnce());
|
||||
|
||||
Assert.That(server.Peers, Is.Empty);
|
||||
Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APeerEndedInTheResendLoopIsSentNothingMoreFromIt()
|
||||
{
|
||||
var (server, _, x, _) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
server.FailingSendPeers.Add(x.EndPoint);
|
||||
server.SendSchema(x.Server, "one");
|
||||
server.SendSchema(x.Server, "two");
|
||||
Assert.That(server.Stats.SendFailures, Is.EqualTo(2));
|
||||
|
||||
server.UnsendableAfter[x.EndPoint] = 0;
|
||||
Thread.Sleep(30);
|
||||
server.PollResends();
|
||||
|
||||
// Both resends are due. The first can never be sent and ends the session; the
|
||||
// goodbye is the only other datagram attempted at X.
|
||||
Assert.That(server.Stats.SendFailures, Is.EqualTo(3));
|
||||
Assert.That(server.Peers, Has.Count.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHealthySendCountsItsBytesAndNoFailure()
|
||||
{
|
||||
var (server, _, x, _) = TwoPeers();
|
||||
using (server)
|
||||
{
|
||||
var before = server.Stats.BytesSent;
|
||||
server.SendSchema(x.Server, "hello");
|
||||
var received = Drain(x.Socket).Sum(p => CultNetRudpPacketCodec.Encode(p).Length);
|
||||
Assert.That(received, Is.GreaterThan("hello".Length));
|
||||
Assert.That(server.Stats.BytesSent - before, Is.EqualTo(received));
|
||||
Assert.That(server.Stats.SendFailures, Is.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyADatagramThatCanNeverBeSentIsAPermanentFailure()
|
||||
{
|
||||
foreach (var permanent in new[] { SocketError.MessageSize, SocketError.InvalidArgument, SocketError.AddressFamilyNotSupported })
|
||||
Assert.That(CultNetRudpSession.IsPermanentSendError(new SocketException((int)permanent)), Is.True, permanent.ToString());
|
||||
foreach (var transient in new[]
|
||||
{
|
||||
SocketError.HostUnreachable, SocketError.NetworkUnreachable, SocketError.NoBufferSpaceAvailable,
|
||||
SocketError.WouldBlock, SocketError.ConnectionReset, SocketError.ConnectionRefused,
|
||||
SocketError.AccessDenied, SocketError.NetworkDown, SocketError.AddressNotAvailable,
|
||||
SocketError.Interrupted, SocketError.TimedOut, SocketError.Shutdown
|
||||
})
|
||||
Assert.That(CultNetRudpSession.IsPermanentSendError(new SocketException((int)transient)), Is.False, transient.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -796,8 +796,11 @@ namespace GameCult.Networking.Tests
|
|||
serverThread.Join();
|
||||
}
|
||||
|
||||
Assert.That(logger.Errors, Has.Some.Contains("dispatch-backstop-probe"),
|
||||
"the backstop must log the handler's own exception message, not swallow it silently");
|
||||
// The log names the fault's type. The fault's own message can quote what the peer sent,
|
||||
// so it is never logged.
|
||||
Assert.That(logger.Errors, Has.Some.Contains(nameof(InvalidOperationException)),
|
||||
"the backstop must log the handler's fault, not swallow it silently");
|
||||
Assert.That(logger.Errors, Has.None.Contains("dispatch-backstop-probe"));
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
|
|
|
|||
Loading…
Reference in a new issue