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:
commit
93f1a50895
17 changed files with 471 additions and 43 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export {
|
|||
createRudpTransportProfile,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
rudpClientBindHost,
|
||||
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS,
|
||||
CultNetRudpReconnectLoop,
|
||||
CultNetRudpSession,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue