From 88d2d72405831edcf5c29fc8fc9a00bb809a7b37 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:37:21 +0200 Subject: [PATCH 1/6] cultnet-rs: an RUDP client for a remote endpoint binds the unspecified address CultMeshRudpSocketOptions defaulted bind_host to 127.0.0.1 for servers and clients alike. On Windows a socket bound to loopback cannot send off the host (os error 10051), so a default client could never reach a remote endpoint such as rudp://10.77.0.1:17872. bind_host is now Option. Unset, a server still binds 127.0.0.1; a client binds loopback for a loopback endpoint and the unspecified address of the endpoint's family (0.0.0.0 or ::) otherwise. An explicit bind_host wins for both. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-rs/src/cultmesh.rs | 37 +++++++++++++--- packages/cultnet-rs/tests/cultnet.rs | 65 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/packages/cultnet-rs/src/cultmesh.rs b/packages/cultnet-rs/src/cultmesh.rs index 900f4744..920a9f1f 100644 --- a/packages/cultnet-rs/src/cultmesh.rs +++ b/packages/cultnet-rs/src/cultmesh.rs @@ -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, pub bind_port: u16, pub read_timeout: Option, pub initial_sequence: Option, @@ -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 { - 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 { - 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 { Ok(CultNetRudpEndpoint { host, port }) } -fn bind_rudp_socket(options: &CultMeshRudpSocketOptions) -> Result { +/// 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 { let socket = UdpSocket::bind(format!( "{}:{}", - socket_host(&options.bind_host), + socket_host(bind_host), options.bind_port ))?; socket.set_read_timeout(options.read_timeout)?; diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 46e6b2e2..2f4572d9 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -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 { + 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; From b68ddfe3572871d3af8d4d61140c773a16c51a63 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:41:44 +0200 Subject: [PATCH 2/6] cultnet-ts, cultmesh-ts: an RUDP client for a remote endpoint binds the unspecified address CultMesh.createRudpClient defaulted bindHost to 127.0.0.1 on a udp4 socket, and invokeCultNetOperation bound 127.0.0.1 unconditionally. On Windows a socket bound to loopback cannot send off the host (os error 10051), so neither could reach a remote endpoint. cultnet-ts now owns the rule in rudpClientBindHost: loopback for a loopback endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0 or ::). The operation client and the CultMesh client both use it, and the CultMesh socket family follows the bind host. An explicit bindHost still wins; a CultMesh server still binds 127.0.0.1 unless told otherwise. Co-Authored-By: Claude Opus 5.5 --- packages/cultmesh-ts/src/index.ts | 18 +++++--- packages/cultmesh-ts/test/cultmesh.test.ts | 38 +++++++++++++++++ packages/cultnet-ts/src/index.ts | 1 + packages/cultnet-ts/src/operation-service.ts | 6 ++- packages/cultnet-ts/src/rudp.ts | 14 +++++++ packages/cultnet-ts/test/cultnet.test.ts | 44 +++++++++++++++++++- 6 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index b89cd823..e9813e18 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -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 { 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 { - const socket = createSocket("udp4"); - const host = options.bindHost ?? "127.0.0.1"; +async function bindRudpSocket(host: string, options: CultMeshRudpSocketOptions): Promise { + const socket = createSocket(host.includes(":") ? "udp6" : "udp4"); const port = options.bindPort ?? 0; await new Promise((resolve, reject) => { socket.once("error", reject); diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 3954c697..7d0c43c4 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -2547,6 +2547,44 @@ test("CultMesh TS branded facade exposes schema and shard catalogs", () => { ); }); +async function rudpClientBoundHost(endpoint: string, bindHost?: string): Promise { + 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( diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 8950f638..9309b05b 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -106,6 +106,7 @@ export { createRudpTransportProfile, decodeRudpPacket, encodeRudpPacket, + rudpClientBindHost, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS, CultNetRudpReconnectLoop, CultNetRudpSession, diff --git a/packages/cultnet-ts/src/operation-service.ts b/packages/cultnet-ts/src/operation-service.ts index 606cd418..fa116c07 100644 --- a/packages/cultnet-ts/src/operation-service.ts +++ b/packages/cultnet-ts/src/operation-service.ts @@ -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 { 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, diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index e32bb2d7..23852a3c 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -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 { + 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, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 4a0c3bbc..5f21fe72 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -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, @@ -2224,6 +2225,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((resolve) => listener.bind(0, "0.0.0.0", resolve)); + const source = new Promise((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(); From 99546522662a57f8640b3a8e36093ba5f9cecfa8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:44:06 +0200 Subject: [PATCH 3/6] cultnet-py, cultmesh-py: an RUDP client for a remote endpoint binds the unspecified address create_rudp_schema_transport and CultMesh.create_rudp_client (and create_client and connect_client above them) defaulted bind_host to 127.0.0.1 on an AF_INET socket. On Windows a socket bound to loopback cannot send off the host (os error 10051), so a default client could not reach a remote endpoint. cultnet-py now owns the rule in rudp_client_bind_host: loopback for a loopback endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0 or ::). bind_host defaults to None on the client paths, which use the rule; the socket family follows the bind host. An explicit bind_host still wins; a CultMesh server still binds 127.0.0.1 unless told otherwise. Co-Authored-By: Claude Opus 5.5 --- .../cultmesh-py/src/cultmesh_py/facade.py | 17 +++-- packages/cultmesh-py/tests/test_cultmesh.py | 31 +++++++++ .../cultnet-py/src/cultnet_py/__init__.py | 2 + packages/cultnet-py/src/cultnet_py/client.py | 25 ++++++- packages/cultnet-py/tests/test_cultnet.py | 69 +++++++++++++++++++ 5 files changed, 137 insertions(+), 7 deletions(-) diff --git a/packages/cultmesh-py/src/cultmesh_py/facade.py b/packages/cultmesh-py/src/cultmesh_py/facade.py index 626b1dc7..fea60610 100644 --- a/packages/cultmesh-py/src/cultmesh_py/facade.py +++ b/packages/cultmesh-py/src/cultmesh_py/facade.py @@ -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 diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index 9a697370..2b4f5bec 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -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( diff --git a/packages/cultnet-py/src/cultnet_py/__init__.py b/packages/cultnet-py/src/cultnet_py/__init__.py index b1734f25..1e509bda 100644 --- a/packages/cultnet-py/src/cultnet_py/__init__.py +++ b/packages/cultnet-py/src/cultnet_py/__init__.py @@ -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", diff --git a/packages/cultnet-py/src/cultnet_py/client.py b/packages/cultnet-py/src/cultnet_py/client.py index 05bc5fa6..28060753 100644 --- a/packages/cultnet-py/src/cultnet_py/client.py +++ b/packages/cultnet-py/src/cultnet_py/client.py @@ -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( diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index fb2eff54..0d707730 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -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, @@ -2399,6 +2401,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)) From cf624616433c9d1ccca03214a5341b674750453d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:49:31 +0200 Subject: [PATCH 4/6] cultmesh-kotlin: an RUDP client for a remote endpoint binds the unspecified address The CultMesh RUDP client entry points defaulted bindHost to 127.0.0.1. On Windows a socket bound to loopback cannot send off the host (os error 10051), so a default client could not reach a remote endpoint. cultNetRudpClientBindAddress owns the rule: loopback for a loopback endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0 or ::). bindHost defaults to null on every client entry point, which uses the rule. An explicit bindHost still wins; a CultMesh server still binds 127.0.0.1 unless told otherwise. The self-test pins all three. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/org/gamecult/cultmesh/CultMesh.kt | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 2117c2d8..3197bb24 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -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) { rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() rudpSessionAdvancesLargeFragmentSetsThroughBoundedReliableWindow() rudpSocketTransportErgonomicFactoriesCarrySchemaFrames() + rudpClientBindsLoopbackOnlyForALoopbackEndpoint() rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() return @@ -5378,6 +5394,25 @@ 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 of its family. +private fun rudpClientBindsLoopbackOnlyForALoopbackEndpoint() { + check(rudpClientBoundHost("rudp://10.77.0.1:17872") == InetAddress.getByName("0.0.0.0")) + check(rudpClientBoundHost("rudp://[2001:db8::1]:17872") == InetAddress.getByName("::")) + 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") == InetAddress.getByName("0.0.0.0")) + 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( From e6ddcdd6e1948cfedbe69d9126c51c010522360b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:50:58 +0200 Subject: [PATCH 5/6] GameCult.Mesh: an RUDP client binds by its endpoint, as the other runtimes do CultMeshRudpSocketOptions.BindHost defaulted to 0.0.0.0 for servers and clients alike (a58daa54, so clients could reach remote routes). That bound an IPv4 socket for every client, so a client for an IPv6 endpoint could not send, and a client for a loopback endpoint listened on every interface. BindHost is now nullable and unset by default. Unset, a client binds loopback for a loopback endpoint and the unspecified address of the endpoint's family (0.0.0.0 or ::) otherwise, matching cultnet-rs, cultnet-ts, cultnet-py and cultmesh-kotlin. A server keeps binding 0.0.0.0. An explicit BindHost wins for both. The test that pinned the old default's value is replaced by tests of what each endpoint binds. Co-Authored-By: Claude Opus 5.5 --- src/GameCult.Mesh/CultMesh.cs | 34 +++++++++++--- .../NetworkingTests.cs | 45 +++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 76656699..0d7ebd16 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -537,8 +537,12 @@ namespace GameCult.Mesh /// public sealed class CultMeshRudpSocketOptions { - /// Gets or sets the local bind host. - public string BindHost { get; set; } = "0.0.0.0"; + /// + /// 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. + /// + public string? BindHost { get; set; } /// Gets or sets the local bind port. public int BindPort { get; set; } /// Gets or sets a caller-owned bound socket. @@ -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; diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 8bf59746..8d01a7c3 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5262,10 +5262,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] From e079ba1ad3172efc7a8c5a32e8a3c9160e4a09c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 13:52:26 +0200 Subject: [PATCH 6/6] cultmesh-kotlin: the bind self-test observes a wildcard, not its family A JVM wildcard socket is dual-stack, so a client bound to 0.0.0.0 reports the IPv6 wildcard as its local address and the self-test's first check failed on Linux. Assert that a client for a remote endpoint binds a wildcard, and keep the exact loopback checks. Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 3197bb24..368b5338 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -5400,14 +5400,16 @@ private fun rudpClientBoundHost(endpoint: String, bindHost: String? = null): Ine } // 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. +// 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") == InetAddress.getByName("0.0.0.0")) - check(rudpClientBoundHost("rudp://[2001:db8::1]:17872") == InetAddress.getByName("::")) + 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") == InetAddress.getByName("0.0.0.0")) + 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")) }