Merge origin/main into hands/ack-cut3: Cut 3 and D Soul rulings, RUDP client bind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 14:44:03 +02:00
commit 93f1a50895
17 changed files with 471 additions and 43 deletions

View file

@ -1584,3 +1584,12 @@ profiles.
detection; the random default closes it. Document on the option.
- Repeated failed attempts walk the sequence up 4095 per 3 s and clamp at `u32::MAX−1` after ~18-27 days on one session;
make exhaustion a hard error the caller sees (the clamp removal mutant survives).
- **Cuts 3 and D Soul pass (Self, 2026-09-30).** Cut 3: the 4 MiB bound counts every byte sent above the lowest
unacknowledged sequence, acked or not (the spec's wording; the first implementation counted only pending packets).
Cut D: a send failure is either permanent (the datagram can never be sent as built, e.g. `EMSGSIZE`) or transient;
a permanent failure is returned from a caller-directed send and, inside a poll, ends only that peer's session with a
typed reason; transient failures stay counted losses. Windows `ConnectionResetError` on receive is idle, never fatal,
in every server. Cut D's negative grep `send_packet(...)?;` is obsolete: the `?` now propagates encode errors only.
- **Recorded (liveness, not fixed):** a lowest reliable packet lost forever stalls the sender 1,023 sequences ahead
while the peer stays alive; session timeout doesn't end it. TCP ends such a connection after a retransmission
limit (R2). Decide with Cut F (abandonment) whether a reliable packet outstanding past a bound ends the session.

View file

@ -11,6 +11,7 @@ import java.io.InputStream
import java.io.OutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
@ -581,7 +582,7 @@ object CultMesh {
connectionId: Long,
remoteHost: String,
remotePort: Int,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
): CultNetRudpSocketTransportConnection = cultNetRudpClient(
@ -600,7 +601,7 @@ object CultMesh {
runtimeId: String,
connectionId: Long,
endpoint: CultNetRudpEndpoint,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
): CultNetRudpSocketTransportConnection = createRudpClient(
@ -617,7 +618,7 @@ object CultMesh {
runtimeId: String,
connectionId: Long,
endpoint: String,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
): CultNetRudpSocketTransportConnection = createRudpClient(
@ -633,7 +634,7 @@ object CultMesh {
runtimeId: String,
connectionId: Long,
peer: CultMeshPeerCard,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
): CultNetRudpSocketTransportConnection {
@ -651,7 +652,7 @@ object CultMesh {
role: String,
shardId: String? = null,
at: Instant = Instant.now(),
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
): CultNetRudpSocketTransportConnection {
@ -664,7 +665,7 @@ object CultMesh {
runtimeId: String,
connectionId: Long,
endpoint: CultNetRudpEndpoint,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
connectPayload: ByteArray = ByteArray(0),
@ -683,7 +684,7 @@ object CultMesh {
runtimeId: String,
connectionId: Long,
endpoint: String,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
connectPayload: ByteArray = ByteArray(0),
@ -705,7 +706,7 @@ object CultMesh {
runtimeId: String,
connectionId: Long,
peer: CultMeshPeerCard,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
connectPayload: ByteArray = ByteArray(0),
@ -736,7 +737,7 @@ object CultMesh {
role: String,
shardId: String? = null,
at: Instant = Instant.now(),
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
connectPayload: ByteArray = ByteArray(0),
@ -3028,22 +3029,36 @@ fun cultNetRudpServer(
)
}
/**
* The address an RUDP client binds when its caller names none: loopback for a
* loopback endpoint, otherwise the unspecified address of the endpoint's family.
* A socket bound to loopback cannot send off the host on Windows.
*/
fun cultNetRudpClientBindAddress(remote: InetAddress): InetAddress = when {
remote is Inet6Address && remote.isLoopbackAddress -> InetAddress.getByName("::1")
remote is Inet6Address -> InetAddress.getByName("::")
remote.isLoopbackAddress -> InetAddress.getByName("127.0.0.1")
else -> InetAddress.getByName("0.0.0.0")
}
fun cultNetRudpClient(
runtimeId: String,
connectionId: Long,
remoteHost: String,
remotePort: Int,
bindHost: String = "127.0.0.1",
bindHost: String? = null,
bindPort: Int = 0,
tuning: CultNetRudpSocketTuning = CultNetRudpSocketTuning(),
): CultNetRudpSocketTransportConnection {
val socket = DatagramSocket(bindPort, InetAddress.getByName(bindHost)).also { it.soTimeout = 20 }
val remote = InetAddress.getByName(remoteHost)
val bindAddress = bindHost?.let(InetAddress::getByName) ?: cultNetRudpClientBindAddress(remote)
val socket = DatagramSocket(bindPort, bindAddress).also { it.soTimeout = 20 }
return CultNetRudpSocketTransportConnection(
socket = socket,
mode = CultNetRudpSocketMode.Client,
runtimeId = runtimeId,
connectionId = connectionId,
remoteAddress = InetSocketAddress(InetAddress.getByName(remoteHost), remotePort),
remoteAddress = InetSocketAddress(remote, remotePort),
initialSequence = tuning.initialSequence,
resendDelayMs = tuning.resendDelayMs,
maxFragmentBytes = tuning.maxFragmentBytes,
@ -3368,6 +3383,7 @@ fun main(args: Array<String>) {
rudpSessionFragmentsAndReassemblesReliableOrderedPayloads()
rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow()
rudpSocketTransportErgonomicFactoriesCarrySchemaFrames()
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
return
@ -5378,6 +5394,27 @@ private fun rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() {
check(oldAck.ackMask == 0L)
}
private fun rudpClientBoundHost(endpoint: String, bindHost: String? = null): InetAddress =
CultMesh.createRudpClient("kotlin-rudp-bind-client", 0x10203060L, endpoint, bindHost).use { client ->
InetAddress.getByName(client.profile.transports.single().host)
}
// A client bound to loopback cannot send to another host on Windows, so a
// client for a remote endpoint binds the unspecified address. A JVM wildcard
// socket is dual-stack and reports one wildcard for either family, so the
// family is not observable here.
private fun rudpClientBindsLoopbackOnlyForALoopbackEndpoint() {
check(rudpClientBoundHost("rudp://10.77.0.1:17872").isAnyLocalAddress)
check(rudpClientBoundHost("rudp://[2001:db8::1]:17872").isAnyLocalAddress)
check(rudpClientBoundHost("rudp://127.0.0.1:17872") == InetAddress.getByName("127.0.0.1"))
check(rudpClientBoundHost("rudp://[::1]:17872") == InetAddress.getByName("::1"))
check(rudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1") == InetAddress.getByName("127.0.0.1"))
check(rudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0").isAnyLocalAddress)
CultMesh.createRudpServer("kotlin-rudp-bind-server", 0x10203061L).use { server ->
check(InetAddress.getByName(server.profile.transports.single().host) == InetAddress.getByName("127.0.0.1"))
}
}
private fun rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() {
val connectionId = 0x10203042L
CultMesh.createRudpServer(

View file

@ -18,6 +18,7 @@ from cultnet_py import (
CultNetShardCatalog,
CultNetSimulationObservationHub,
create_rudp_schema_transport,
rudp_client_bind_host,
wire_message_schema_catalog,
)
@ -188,7 +189,7 @@ class CultMesh:
connection_id: int,
endpoint: str | CultMeshRudpEndpoint,
*,
bind_host: str = "127.0.0.1",
bind_host: str | None = None,
bind_port: int = 0,
socket: socket_module.socket | None = None,
initial_sequence: int | None = None,
@ -204,7 +205,10 @@ class CultMesh:
if isinstance(endpoint, str)
else endpoint
)
transport_socket = socket or _bind_rudp_socket(bind_host, bind_port)
transport_socket = socket or _bind_rudp_socket(
bind_host if bind_host is not None else rudp_client_bind_host(parsed_endpoint.host),
bind_port,
)
return CultNetRudpSocketTransportConnection(
CultNetRudpSocketTransportOptions(
runtime_id=runtime_id,
@ -429,7 +433,7 @@ class CultMesh:
endpoint: str | CultMeshRudpEndpoint | None = None,
connection_id: int = 0x43554C54,
runtime_id: str = "cultmesh-python-rudp-client",
bind_host: str = "127.0.0.1",
bind_host: str | None = None,
bind_port: int = 0,
) -> CultNetRawClient:
if endpoint is None and host.lower().startswith("rudp://"):
@ -465,7 +469,7 @@ class CultMesh:
endpoint: str | CultMeshRudpEndpoint | None = None,
connection_id: int = 0x43554C54,
runtime_id: str = "cultmesh-python-rudp-client",
bind_host: str = "127.0.0.1",
bind_host: str | None = None,
bind_port: int = 0,
) -> CultNetRawClient:
return CultMesh.create_client(
@ -481,7 +485,10 @@ class CultMesh:
def _bind_rudp_socket(bind_host: str, bind_port: int) -> socket_module.socket:
transport_socket = socket_module.socket(socket_module.AF_INET, socket_module.SOCK_DGRAM)
transport_socket = socket_module.socket(
socket_module.AF_INET6 if ":" in bind_host else socket_module.AF_INET,
socket_module.SOCK_DGRAM,
)
transport_socket.bind((bind_host, bind_port))
transport_socket.settimeout(0.02)
return transport_socket

View file

@ -213,6 +213,37 @@ class CultMeshTests(unittest.TestCase):
self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0")
self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory")
def _rudp_client_bound_host(self, endpoint: str, bind_host: str | None = None) -> str:
client = CultMesh.create_rudp_client("python-cultmesh-bind-client", 0x10203060, endpoint, bind_host=bind_host)
try:
return client.socket.getsockname()[0]
finally:
client.socket.close()
# A client bound to loopback cannot send to another host on Windows, so a
# client for a remote endpoint binds the unspecified address of its family.
def test_cultmesh_rudp_client_for_a_remote_endpoint_binds_the_unspecified_address(self) -> None:
self.assertEqual(self._rudp_client_bound_host("rudp://10.77.0.1:17872"), "0.0.0.0")
self.assertEqual(self._rudp_client_bound_host("rudp://[2001:db8::1]:17872"), "::")
def test_cultmesh_rudp_client_for_a_loopback_endpoint_binds_loopback(self) -> None:
self.assertEqual(self._rudp_client_bound_host("rudp://127.0.0.1:17872"), "127.0.0.1")
self.assertEqual(self._rudp_client_bound_host("rudp://[::1]:17872"), "::1")
def test_cultmesh_rudp_client_binds_an_explicit_bind_host_whatever_the_endpoint(self) -> None:
self.assertEqual(self._rudp_client_bound_host("rudp://10.77.0.1:17872", "127.0.0.1"), "127.0.0.1")
self.assertEqual(self._rudp_client_bound_host("rudp://127.0.0.1:17872", "0.0.0.0"), "0.0.0.0")
def test_cultmesh_rudp_server_binds_loopback_unless_told_otherwise(self) -> None:
loopback = CultMesh.create_rudp_server("python-cultmesh-bind-server", 0x10203061)
anywhere = CultMesh.create_rudp_server("python-cultmesh-bind-server", 0x10203062, bind_host="0.0.0.0")
try:
self.assertEqual(loopback.socket.getsockname()[0], "127.0.0.1")
self.assertEqual(anywhere.socket.getsockname()[0], "0.0.0.0")
finally:
loopback.socket.close()
anywhere.socket.close()
def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None:
connection_id = 0x10203042
server = CultMesh.create_rudp_server(

View file

@ -14,6 +14,7 @@ import {
CultNetRudpSession,
CultNetPeer,
CultNetRudpSocketTransportConnection,
rudpClientBindHost,
CultNetSchemaCatalog,
CultNetShardCatalog,
cultNetBuiltinSchemaRegistry,
@ -4330,6 +4331,11 @@ export interface CultMeshRudpEndpoint {
}
export interface CultMeshRudpSocketOptions {
/**
* Local address to bind. Unset, a server binds loopback, and a client binds
* loopback for a loopback endpoint and the unspecified address of the
* endpoint's family otherwise, so it can reach a remote host.
*/
bindHost?: string;
bindPort?: number;
socket?: Socket;
@ -6135,7 +6141,7 @@ export class CultMesh {
options: CultMeshRudpSocketOptions = {},
): Promise<CultNetRudpSocketTransportConnection> {
requireNonEmpty(runtimeId, "runtimeId");
const socket = options.socket ?? (await bindRudpSocket(options));
const socket = options.socket ?? (await bindRudpSocket(options.bindHost ?? "127.0.0.1", options));
return new CultNetRudpSocketTransportConnection({
runtimeId,
socket,
@ -6161,7 +6167,10 @@ export class CultMesh {
requireNonEmpty(runtimeId, "runtimeId");
const parsedEndpoint =
typeof endpoint === "string" ? CultMesh.parseRudpEndpoint(endpoint) : endpoint;
const socket = options.socket ?? (await bindRudpSocket(options));
const socket = options.socket ?? (await bindRudpSocket(
options.bindHost ?? (await rudpClientBindHost(parsedEndpoint.host)),
options,
));
return new CultNetRudpSocketTransportConnection({
runtimeId,
socket,
@ -6586,9 +6595,8 @@ function toUint8Array(value: unknown): Uint8Array {
throw new Error("CultNet raw document payload was not binary.");
}
async function bindRudpSocket(options: CultMeshRudpSocketOptions): Promise<Socket> {
const socket = createSocket("udp4");
const host = options.bindHost ?? "127.0.0.1";
async function bindRudpSocket(host: string, options: CultMeshRudpSocketOptions): Promise<Socket> {
const socket = createSocket(host.includes(":") ? "udp6" : "udp4");
const port = options.bindPort ?? 0;
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);

View file

@ -2547,6 +2547,44 @@ test("CultMesh TS branded facade exposes schema and shard catalogs", () => {
);
});
async function rudpClientBoundHost(endpoint: string, bindHost?: string): Promise<string | undefined> {
const client = await CultMesh.createRudpClient("cultmesh-ts-bind-client", 0x10203060, endpoint, { bindHost });
try {
return client.profile.transports[0]?.host;
} finally {
client.close();
}
}
// A client bound to loopback cannot send to another host on Windows, so a
// client for a remote endpoint binds the unspecified address of its family.
test("CultMesh TS RUDP client for a remote endpoint binds the unspecified address", async () => {
assert.equal(await rudpClientBoundHost("rudp://10.77.0.1:17872"), "0.0.0.0");
assert.equal(await rudpClientBoundHost("rudp://[2001:db8::1]:17872"), "::");
});
test("CultMesh TS RUDP client for a loopback endpoint binds loopback", async () => {
assert.equal(await rudpClientBoundHost("rudp://127.0.0.1:17872"), "127.0.0.1");
assert.equal(await rudpClientBoundHost("rudp://[::1]:17872"), "::1");
});
test("CultMesh TS RUDP client binds an explicit bindHost whatever the endpoint", async () => {
assert.equal(await rudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1"), "127.0.0.1");
assert.equal(await rudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0"), "0.0.0.0");
});
test("CultMesh TS RUDP server binds loopback unless told otherwise", async () => {
const loopback = await CultMesh.createRudpServer("cultmesh-ts-bind-server", 0x10203061);
const any = await CultMesh.createRudpServer("cultmesh-ts-bind-server", 0x10203062, { bindHost: "0.0.0.0" });
try {
assert.equal(loopback.profile.transports[0]?.host, "127.0.0.1");
assert.equal(any.profile.transports[0]?.host, "0.0.0.0");
} finally {
loopback.close();
any.close();
}
});
test("CultMesh TS branded facade creates RUDP clients from peer endpoints", async () => {
const connectionId = 0x10203044;
const server = await CultMesh.createRudpServer(

View file

@ -6,6 +6,7 @@ from .client import (
CultNetSchemaTransportFactory,
create_rudp_schema_transport,
create_tcp_framed_schema_transport,
rudp_client_bind_host,
)
from .cultmesh_contracts import (
PEER_EXCHANGE_REQUEST,
@ -228,6 +229,7 @@ __all__ = [
"compute_reconnect_delay_ms",
"create_reconnect_policy",
"create_rudp_schema_transport",
"rudp_client_bind_host",
"create_tcp_framed_schema_transport",
"create_rudp_transport_profile",
"create_tcp_framed_transport_profile",

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import ipaddress
import socket
import time
from collections.abc import Callable
@ -384,6 +385,22 @@ def create_tcp_framed_schema_transport(
)
def rudp_client_bind_host(endpoint_host: str) -> str:
"""Return the address an RUDP client binds when its caller names none.
Loopback for a loopback endpoint, otherwise the unspecified address of the
endpoint's family. A socket bound to loopback cannot send off the host on
Windows.
"""
family, _, _, _, address = socket.getaddrinfo(
endpoint_host.removeprefix("[").removesuffix("]"), None, type=socket.SOCK_DGRAM
)[0]
loopback = ipaddress.ip_address(address[0]).is_loopback
if family == socket.AF_INET6:
return "::1" if loopback else "::"
return "127.0.0.1" if loopback else "0.0.0.0"
def create_rudp_schema_transport(
*,
host: str,
@ -391,7 +408,7 @@ def create_rudp_schema_transport(
connection_id: int,
timeout_seconds: float = 4.0,
runtime_id: str = "cultnet-python-rudp-client",
bind_host: str = "127.0.0.1",
bind_host: str | None = None,
bind_port: int = 0,
initial_sequence: int | None = None,
resend_delay_ms: int = 25,
@ -400,7 +417,11 @@ def create_rudp_schema_transport(
max_fragment_bytes: int | None = None,
max_pending_reliable_packets: int | None = None,
) -> CultNetRudpSocketTransportConnection:
transport_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if bind_host is None:
bind_host = rudp_client_bind_host(host)
transport_socket = socket.socket(
socket.AF_INET6 if ":" in bind_host else socket.AF_INET, socket.SOCK_DGRAM
)
transport_socket.bind((bind_host, bind_port))
transport_socket.settimeout(min(timeout_seconds, 0.02))
transport = CultNetRudpSocketTransportConnection(

View file

@ -30,6 +30,8 @@ from cultnet_py.interop_peer import (
)
from cultnet_py import (
compute_simulation_claim_hash,
create_rudp_schema_transport,
rudp_client_bind_host,
CultNetDatabaseChange,
CultNetRawClient,
CultNetRawDocumentRecord,
@ -2537,6 +2539,73 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0])
def _non_loopback_ipv4_address() -> str | None:
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
probe.connect(("192.0.2.1", 9))
address = probe.getsockname()[0]
except OSError:
return None
finally:
probe.close()
return None if address.startswith("127.") or address == "0.0.0.0" else address
class CultNetRudpClientBindTests(unittest.TestCase):
def test_an_rudp_client_binds_loopback_only_for_a_loopback_endpoint(self) -> None:
self.assertEqual(rudp_client_bind_host("10.77.0.1"), "0.0.0.0")
self.assertEqual(rudp_client_bind_host("2001:db8::1"), "::")
self.assertEqual(rudp_client_bind_host("[2001:db8::1]"), "::")
self.assertEqual(rudp_client_bind_host("127.0.0.1"), "127.0.0.1")
self.assertEqual(rudp_client_bind_host("127.0.0.2"), "127.0.0.1")
self.assertEqual(rudp_client_bind_host("::1"), "::1")
self.assertEqual(rudp_client_bind_host("[::1]"), "::1")
# A socket bound to loopback cannot send off the host on Windows. On Linux
# it reaches a local non-loopback address, but from 127.0.0.1, so the
# source address shows which one the client bound.
def test_a_schema_transport_for_a_non_loopback_endpoint_does_not_send_from_loopback(self) -> None:
address = _non_loopback_ipv4_address()
if address is None:
self.skipTest("no non-loopback IPv4 address on this host")
listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
listener.bind(("0.0.0.0", 0))
listener.settimeout(2.0)
try:
with self.assertRaises(TimeoutError):
create_rudp_schema_transport(
host=address,
port=listener.getsockname()[1],
connection_id=0x43554C54,
timeout_seconds=0.2,
)
_, source = listener.recvfrom(65535)
self.assertEqual(source[0], address)
finally:
listener.close()
def test_a_schema_transport_binds_an_explicit_bind_host(self) -> None:
address = _non_loopback_ipv4_address()
if address is None:
self.skipTest("no non-loopback IPv4 address on this host")
listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
listener.bind(("0.0.0.0", 0))
listener.settimeout(2.0)
try:
with self.assertRaises(TimeoutError):
create_rudp_schema_transport(
host=address,
port=listener.getsockname()[1],
connection_id=0x43554C54,
timeout_seconds=0.2,
bind_host="127.0.0.1",
)
_, source = listener.recvfrom(65535)
self.assertEqual(source[0], "127.0.0.1")
finally:
listener.close()
class CultNetRudpFragmentBoundTests(unittest.TestCase):
def _pair(self) -> tuple[CultNetRudpSession, CultNetRudpSession]:
sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=311, initial_sequence=1))

View file

@ -3,6 +3,9 @@ use anyhow::anyhow;
use chrono::DateTime;
use chrono::Utc;
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::net::SocketAddr;
use std::net::ToSocketAddrs;
use std::net::UdpSocket;
@ -240,7 +243,10 @@ impl CultNetRudpEndpoint {
#[derive(Clone, Debug)]
pub struct CultMeshRudpSocketOptions {
pub bind_host: String,
/// Local address to bind. Unset, a server binds loopback, and a client binds
/// loopback for a loopback endpoint and the unspecified address of the
/// endpoint's family otherwise, so it can reach a remote host.
pub bind_host: Option<String>,
pub bind_port: u16,
pub read_timeout: Option<Duration>,
pub initial_sequence: Option<u32>,
@ -253,7 +259,7 @@ pub struct CultMeshRudpSocketOptions {
impl Default for CultMeshRudpSocketOptions {
fn default() -> Self {
Self {
bind_host: "127.0.0.1".to_string(),
bind_host: None,
bind_port: 0,
read_timeout: Some(Duration::from_millis(20)),
initial_sequence: None,
@ -316,7 +322,8 @@ impl CultMesh {
connection_id: u32,
options: CultMeshRudpSocketOptions,
) -> Result<CultNetRudpSocketTransportConnection> {
let socket = bind_rudp_socket(&options)?;
let bind_host = options.bind_host.as_deref().unwrap_or("127.0.0.1");
let socket = bind_rudp_socket(bind_host, &options)?;
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
media_reliable_expire_after_ms: None,
media_delivery: None,
@ -341,14 +348,19 @@ impl CultMesh {
endpoint: &CultNetRudpEndpoint,
options: CultMeshRudpSocketOptions,
) -> Result<CultNetRudpSocketTransportConnection> {
let socket = bind_rudp_socket(&options)?;
let remote_addr = endpoint.socket_addr()?;
let bind_host = match options.bind_host.as_deref() {
Some(host) => host.to_string(),
None => client_bind_ip(remote_addr).to_string(),
};
let socket = bind_rudp_socket(&bind_host, &options)?;
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
media_reliable_expire_after_ms: None,
media_delivery: None,
runtime_id: runtime_id.into(),
socket,
mode: CultNetRudpSocketMode::Client,
remote_addr: Some(endpoint.socket_addr()?),
remote_addr: Some(remote_addr),
connection_id,
initial_sequence: options.initial_sequence,
resend_delay_ms: options.resend_delay_ms,
@ -503,10 +515,21 @@ pub fn cultnet_rudp_endpoint(endpoint: &str) -> Result<CultNetRudpEndpoint> {
Ok(CultNetRudpEndpoint { host, port })
}
fn bind_rudp_socket(options: &CultMeshRudpSocketOptions) -> Result<UdpSocket> {
/// A socket bound to loopback cannot send off the host on Windows, so a client
/// keeps loopback only when its peer is on loopback.
fn client_bind_ip(remote_addr: SocketAddr) -> IpAddr {
match (remote_addr.ip().is_loopback(), remote_addr) {
(true, SocketAddr::V4(_)) => IpAddr::V4(Ipv4Addr::LOCALHOST),
(true, SocketAddr::V6(_)) => IpAddr::V6(Ipv6Addr::LOCALHOST),
(false, SocketAddr::V4(_)) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
(false, SocketAddr::V6(_)) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
}
}
fn bind_rudp_socket(bind_host: &str, options: &CultMeshRudpSocketOptions) -> Result<UdpSocket> {
let socket = UdpSocket::bind(format!(
"{}:{}",
socket_host(&options.bind_host),
socket_host(bind_host),
options.bind_port
))?;
socket.set_read_timeout(options.read_timeout)?;

View file

@ -1334,6 +1334,71 @@ fn rudp_socket_flush_waits_for_large_fragment_delivery() -> Result<()> {
Ok(())
}
fn rudp_client_bound_host(endpoint: &str, bind_host: Option<&str>) -> Result<String> {
let client = CultMesh::create_rudp_client_for_endpoint(
"rust-cultmesh-bind-client",
0x2030_4060,
endpoint,
CultMeshRudpSocketOptions {
bind_host: bind_host.map(str::to_string),
..CultMeshRudpSocketOptions::default()
},
)?;
Ok(client.profile.transports[0]
.host
.clone()
.expect("RUDP client profile advertises its bound host"))
}
/// A client bound to loopback cannot send to another host on Windows, so a
/// client for a remote endpoint binds the unspecified address of its family.
#[test]
fn cultmesh_rudp_client_for_a_remote_endpoint_binds_the_unspecified_address() -> Result<()> {
assert_eq!(rudp_client_bound_host("rudp://10.77.0.1:17872", None)?, "0.0.0.0");
assert_eq!(rudp_client_bound_host("rudp://[2001:db8::1]:17872", None)?, "::");
Ok(())
}
#[test]
fn cultmesh_rudp_client_for_a_loopback_endpoint_binds_loopback() -> Result<()> {
assert_eq!(rudp_client_bound_host("rudp://127.0.0.1:17872", None)?, "127.0.0.1");
assert_eq!(rudp_client_bound_host("rudp://[::1]:17872", None)?, "::1");
Ok(())
}
#[test]
fn cultmesh_rudp_client_binds_an_explicit_bind_host_whatever_the_endpoint() -> Result<()> {
assert_eq!(
rudp_client_bound_host("rudp://10.77.0.1:17872", Some("127.0.0.1"))?,
"127.0.0.1"
);
assert_eq!(
rudp_client_bound_host("rudp://127.0.0.1:17872", Some("0.0.0.0"))?,
"0.0.0.0"
);
Ok(())
}
#[test]
fn cultmesh_rudp_server_binds_loopback_unless_told_otherwise() -> Result<()> {
let server = CultMesh::create_rudp_server(
"rust-cultmesh-bind-server",
0x2030_4061,
CultMeshRudpSocketOptions::default(),
)?;
assert_eq!(server.profile.transports[0].host.as_deref(), Some("127.0.0.1"));
let server = CultMesh::create_rudp_server(
"rust-cultmesh-bind-server",
0x2030_4062,
CultMeshRudpSocketOptions {
bind_host: Some("0.0.0.0".to_string()),
..CultMeshRudpSocketOptions::default()
},
)?;
assert_eq!(server.profile.transports[0].host.as_deref(), Some("0.0.0.0"));
Ok(())
}
#[test]
fn cultmesh_facade_creates_rudp_client_from_peer_endpoint() -> Result<()> {
let connection_id = 0x2030_4050;

View file

@ -106,6 +106,7 @@ export {
createRudpTransportProfile,
decodeRudpPacket,
encodeRudpPacket,
rudpClientBindHost,
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS,
CultNetRudpReconnectLoop,
CultNetRudpSession,

View file

@ -12,6 +12,7 @@ import {
CultNetRudpSocketTransportConnection,
decodeRudpPacket,
encodeRudpPacket,
rudpClientBindHost,
type CultNetRudpPacket,
} from "./rudp";
import { CultNetPeer } from "./peer";
@ -112,8 +113,9 @@ export async function invokeCultNetOperation(
options: CultNetOperationClientOptions,
): Promise<CultNetOperationResponseMessage> {
const target = parseRudpEndpoint(endpoint);
const socket = createSocket("udp4");
await bindSocket(socket, 0, "127.0.0.1");
const bindHost = await rudpClientBindHost(target.host);
const socket = createSocket(bindHost.includes(":") ? "udp6" : "udp4");
await bindSocket(socket, 0, bindHost);
const transport = new CultNetRudpSocketTransportConnection({
mode: "client",
runtimeId: options.runtimeId,

View file

@ -1,8 +1,22 @@
import { randomInt } from "node:crypto";
import { EventEmitter } from "node:events";
import { type RemoteInfo, type Socket } from "node:dgram";
import { lookup } from "node:dns/promises";
import type { CultNetTransportProfile } from "./contracts";
/**
* The address an RUDP client binds when its caller names none: loopback for a
* loopback endpoint, otherwise the unspecified address of the endpoint's family.
* A socket bound to loopback cannot send off the host on Windows.
*/
export async function rudpClientBindHost(endpointHost: string): Promise<string> {
const { address, family } = await lookup(endpointHost.replace(/^\[(.*)\]$/, "$1"));
if (family === 6) {
return address === "::1" ? "::1" : "::";
}
return address.startsWith("127.") ? "127.0.0.1" : "0.0.0.0";
}
import {
CultNetReconnectController,
createCultNetReconnectPolicy,

View file

@ -5,7 +5,7 @@ import { Duplex } from "node:stream";
import dgram, { type Socket } from "node:dgram";
import { rmSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { networkInterfaces, tmpdir } from "node:os";
import { z } from "zod";
import { decode as decodeMsgpack, encode } from "@msgpack/msgpack";
@ -47,6 +47,7 @@ import {
ghostlightAgentStateGeneratedContract,
parseCultNetMessage,
invokeCultNetOperation,
rudpClientBindHost,
startCultNetOperationServer,
validateGhostlightAgentStateGenerated,
validateGhostlightAgentState,
@ -2365,6 +2366,47 @@ test("operation service frees the slots of abandoned sessions once they idle out
}
});
test("an RUDP client binds loopback only for a loopback endpoint", async () => {
assert.equal(await rudpClientBindHost("10.77.0.1"), "0.0.0.0");
assert.equal(await rudpClientBindHost("2001:db8::1"), "::");
assert.equal(await rudpClientBindHost("[2001:db8::1]"), "::");
assert.equal(await rudpClientBindHost("127.0.0.1"), "127.0.0.1");
assert.equal(await rudpClientBindHost("127.0.0.2"), "127.0.0.1");
assert.equal(await rudpClientBindHost("::1"), "::1");
assert.equal(await rudpClientBindHost("[::1]"), "::1");
});
// A socket bound to loopback cannot send off the host on Windows. On Linux it
// reaches a local non-loopback address, but from 127.0.0.1, so the source
// address shows which one the client bound.
test("an operation client for a non-loopback endpoint does not send from loopback", async (t) => {
const address = Object.values(networkInterfaces())
.flat()
.find((entry) => entry && entry.family === "IPv4" && !entry.internal)?.address;
if (!address) {
t.skip("no non-loopback IPv4 interface on this host");
return;
}
const listener = dgram.createSocket("udp4");
await new Promise<void>((resolve) => listener.bind(0, "0.0.0.0", resolve));
const source = new Promise<string>((resolve) => listener.once("message", (_wire, remote) => resolve(remote.address)));
const request: CultNetOperationRequestMessage = {
schemaVersion: "cultnet.operation_request.v0",
messageId: "bind-host",
serviceId: "sai.vn",
operation: "describe",
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
payloadEncoding: "messagepack-base64",
payload: "gaZzY2hlbWE=",
};
try {
await assert.rejects(invokeCultNetOperation(`rudp://${address}:${udpPort(listener)}`, request, { runtimeId: "bind-test", timeoutMs: 300 }));
assert.equal(await source, address);
} finally {
listener.close();
}
});
test("server-mode transport replaces its peer when a Connect arrives from another endpoint", async () => {
const serverSocket = await bindUdpSocket();
const socketA = await bindUdpSocket();

View file

@ -537,8 +537,12 @@ namespace GameCult.Mesh
/// </summary>
public sealed class CultMeshRudpSocketOptions
{
/// <summary>Gets or sets the local bind host.</summary>
public string BindHost { get; set; } = "0.0.0.0";
/// <summary>
/// Gets or sets the local bind host. Unset, a server binds every IPv4 interface, and a
/// client binds loopback for a loopback endpoint and the unspecified address of the
/// endpoint's family otherwise, so it can reach a remote host.
/// </summary>
public string? BindHost { get; set; }
/// <summary>Gets or sets the local bind port.</summary>
public int BindPort { get; set; }
/// <summary>Gets or sets a caller-owned bound socket.</summary>
@ -2933,7 +2937,7 @@ namespace GameCult.Mesh
return new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = runtimeId,
Socket = options.Socket ?? BindRudpSocket(options.BindHost, options.BindPort),
Socket = options.Socket ?? BindRudpSocket(ResolveRudpAddress(options.BindHost ?? "0.0.0.0"), options.BindPort),
Mode = CultNetRudpSocketMode.Server,
ConnectionId = connectionId,
InitialSequence = options.InitialSequence,
@ -2957,12 +2961,16 @@ namespace GameCult.Mesh
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
options ??= new CultMeshRudpSocketOptions();
var remote = ResolveRudpAddress(endpoint.Host);
var bindAddress = options.BindHost == null
? RudpClientBindAddress(remote)
: ResolveRudpAddress(options.BindHost);
return new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
{
RuntimeId = runtimeId,
Socket = options.Socket ?? BindRudpSocket(options.BindHost, options.BindPort),
Socket = options.Socket ?? BindRudpSocket(bindAddress, options.BindPort),
Mode = CultNetRudpSocketMode.Client,
RemoteEndPoint = new IPEndPoint(ResolveRudpAddress(endpoint.Host), endpoint.Port),
RemoteEndPoint = new IPEndPoint(remote, endpoint.Port),
ConnectionId = connectionId,
InitialSequence = options.InitialSequence,
ResendDelayMs = options.ResendDelayMs,
@ -3234,9 +3242,21 @@ namespace GameCult.Mesh
return CultNetLocal.ConnectClient(host, port, security, configureClient);
}
private static Socket BindRudpSocket(string host, int port)
// A socket bound to loopback cannot send off the host on Windows, so a client keeps
// loopback only when its peer is on loopback.
private static IPAddress RudpClientBindAddress(IPAddress remote)
{
var ipv6 = remote.AddressFamily == AddressFamily.InterNetworkV6;
if (IPAddress.IsLoopback(remote))
{
return ipv6 ? IPAddress.IPv6Loopback : IPAddress.Loopback;
}
return ipv6 ? IPAddress.IPv6Any : IPAddress.Any;
}
private static Socket BindRudpSocket(IPAddress address, int port)
{
var address = ResolveRudpAddress(host);
var socket = new Socket(address.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(address, port));
socket.ReceiveTimeout = 20;

View file

@ -5428,10 +5428,49 @@ namespace GameCult.Networking.Tests
#pragma warning restore CS0618
}
[Test]
public void CultMeshRudpClient_DefaultBindAllowsRemoteRoutes()
private static string? RudpClientBoundHost(string endpoint, string? bindHost = null)
{
Assert.That(new CultMeshRudpSocketOptions().BindHost, Is.EqualTo("0.0.0.0"));
using var client = CultMesh.CreateRudpClient(
"csharp-cultmesh-bind-client",
0x10203060u,
endpoint,
new CultMeshRudpSocketOptions { BindHost = bindHost });
return client.Profile.Transports[0].Host;
}
// A client bound to loopback cannot send to another host on Windows, so a client for a
// remote endpoint binds the unspecified address of its family.
[Test]
public void CultMeshRudpClient_ForARemoteEndpoint_BindsTheUnspecifiedAddress()
{
Assert.That(RudpClientBoundHost("rudp://10.77.0.1:17872"), Is.EqualTo("0.0.0.0"));
Assert.That(RudpClientBoundHost("rudp://[2001:db8::1]:17872"), Is.EqualTo("::"));
}
[Test]
public void CultMeshRudpClient_ForALoopbackEndpoint_BindsLoopback()
{
Assert.That(RudpClientBoundHost("rudp://127.0.0.1:17872"), Is.EqualTo("127.0.0.1"));
Assert.That(RudpClientBoundHost("rudp://[::1]:17872"), Is.EqualTo("::1"));
}
[Test]
public void CultMeshRudpClient_BindsAnExplicitBindHostWhateverTheEndpoint()
{
Assert.That(RudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1"), Is.EqualTo("127.0.0.1"));
Assert.That(RudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0"), Is.EqualTo("0.0.0.0"));
}
[Test]
public void CultMeshRudpServer_BindsEveryIPv4InterfaceUnlessToldOtherwise()
{
using var anywhere = CultMesh.CreateRudpServer("csharp-cultmesh-bind-server", 0x10203061u);
using var loopback = CultMesh.CreateRudpServer(
"csharp-cultmesh-bind-server",
0x10203062u,
new CultMeshRudpSocketOptions { BindHost = "127.0.0.1" });
Assert.That(anywhere.Profile.Transports[0].Host, Is.EqualTo("0.0.0.0"));
Assert.That(loopback.Profile.Transports[0].Host, Is.EqualTo("127.0.0.1"));
}
[Test]