CultLib/packages/cultnet-ts/test/rudp-send-failure.test.ts
Meta Vens 1f04d5eda3 cultnet-ts: pin the one-fragment size check with a large fragment too
With only 1 KiB fragments, a pre-check measuring a small multiple of a
fragment (Soul's min(payload, frag*2)) still passed a 70,000-byte send.
The fixture now also runs with 40,000-byte fragments. Any such multiple
of that is over the datagram limit, so the mutant dies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:19:36 +02:00

520 lines
21 KiB
TypeScript

// A datagram that cannot be sent to one peer is that peer's problem alone. Node throws
// for a send it refuses outright (a peer at UDP port 0, which any spoofer can claim)
// and reports a system failure later through the callback; neither may escape a
// timer or a receive handler, and only a datagram that can never be sent as built
// ends the session that owes it.
import assert from "node:assert/strict";
import dgram, { type RemoteInfo, type Socket } from "node:dgram";
import test from "node:test";
import { encode } from "@msgpack/msgpack";
import {
CultNetRudpSession,
CultNetRudpSocketTransportConnection,
decodeRudpPacket,
encodeCultNetMessageForWire,
encodeRudpPacket,
invokeCultNetOperation,
isPermanentSendError,
startCultNetOperationServer,
type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage,
} from "../src";
async function bindUdpSocket(): Promise<Socket> {
const socket = dgram.createSocket("udp4");
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
return socket;
}
function udpPort(socket: Socket): number {
const address = socket.address();
assert.notEqual(typeof address, "string");
return address.port;
}
async function waitFor(predicate: () => boolean, description: string): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > 2_000) {
throw new Error(`Timed out waiting for ${description}.`);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** A Connect as it arrives from UDP source port 0: Node refuses to send anything back. */
function connectFromPortZero(socket: Socket, connectionId: number): void {
const wire = Buffer.from(encodeRudpPacket(new CultNetRudpSession({ connectionId }).createConnect(Date.now())));
const remote: RemoteInfo = { address: "127.0.0.1", family: "IPv4", port: 0, size: wire.length };
socket.emit("message", wire, remote);
}
/** Records every exception that would otherwise end the process. */
function watchUncaught(): { errors: unknown[]; stop: () => void } {
const errors: unknown[] = [];
const listener = (error: unknown) => errors.push(error);
process.on("uncaughtException", listener);
return { errors, stop: () => process.off("uncaughtException", listener) };
}
test("only a datagram that can never be sent as built is a permanent send failure", () => {
const failure = (code: string) => Object.assign(new Error("send failed"), { code });
for (const code of ["EMSGSIZE", "EINVAL", "EAFNOSUPPORT", "ERR_SOCKET_BAD_PORT"]) {
assert.equal(isPermanentSendError(failure(code)), true, code);
}
for (const code of ["EHOSTUNREACH", "ENETUNREACH", "ECONNREFUSED", "ENOBUFS", "EAGAIN", "ERR_SOCKET_DGRAM_NOT_RUNNING"]) {
assert.equal(isPermanentSendError(failure(code)), false, code);
}
assert.equal(isPermanentSendError(new Error("no code")), false);
});
test("a server transport answering a Connect from port 0 ends that session and keeps serving", async () => {
const serverSocket = await bindUdpSocket();
const clientSocket = await bindUdpSocket();
const connectionId = 0x10203090;
const uncaught = watchUncaught();
const server = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-server",
socket: serverSocket,
mode: "server",
connectionId,
resendDelayMs: 10,
resendPollMs: 5,
});
const errors: Error[] = [];
const reasons: string[] = [];
server.on("error", (error: Error) => errors.push(error));
server.on("disconnect", ({ reason }: { reason: Uint8Array }) => reasons.push(Buffer.from(reason).toString("utf8")));
const client = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-client",
socket: clientSocket,
mode: "client",
remoteHost: "127.0.0.1",
remotePort: udpPort(serverSocket),
connectionId,
resendDelayMs: 10,
resendPollMs: 5,
});
try {
connectFromPortZero(serverSocket, connectionId);
// Many resend ticks: nothing the port-0 session owed is ever retried.
await sleep(100);
// The goodbye names the error by its code, never by a message quoting the address.
assert.deepEqual(reasons, ["packet could not be sent: ERR_SOCKET_BAD_PORT"]);
const frames: string[] = [];
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(Buffer.from(frame.payload).toString("utf8")));
client.connect();
await waitFor(() => client.connected && server.connected, "a real client's handshake");
client.send("schema", Buffer.from("after", "utf8"));
await waitFor(() => frames.length === 1, "the real client's frame");
assert.deepEqual(frames, ["after"]);
assert.deepEqual(errors, []);
assert.deepEqual(uncaught.errors, []);
} finally {
uncaught.stop();
client.close();
server.close();
}
});
test("an operation server answering a Connect from port 0 starts no session and keeps serving", async () => {
const connectionId = 0x43554c54;
const uncaught = watchUncaught();
// The server owns its socket; this captures it so a datagram can arrive from port 0.
const createSocket = dgram.createSocket;
let serverSocket: Socket | undefined;
Object.assign(dgram, {
createSocket: (...args: Parameters<typeof dgram.createSocket>) => {
serverSocket = createSocket(...args);
return serverSocket;
},
});
const server = await startCultNetOperationServer({
runtimeId: "operations",
handler: (request): CultNetOperationResponseMessage => ({
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: "ok",
payloadSchema: request.payloadSchema,
payloadEncoding: request.payloadEncoding,
payload: request.payload,
diagnostics: [],
sourceRuntimeId: "operations",
}),
}).finally(() => Object.assign(dgram, { createSocket }));
const raw = await bindUdpSocket();
const port = Number(new URL(server.endpoint).port);
const accepts: number[] = [];
raw.on("message", () => accepts.push(Date.now()));
try {
connectFromPortZero(serverSocket!, connectionId);
// The resend timer runs every 25 ms; a session that had started would owe its Accept.
await sleep(150);
assert.equal(server.packetsDropped, 1, "the Connect started no session");
assert.equal(server.sendFailures, 0);
// A real peer is still answered.
raw.send(encodeRudpPacket(new CultNetRudpSession({ connectionId }).createConnect(0)), port, "127.0.0.1");
await waitFor(() => accepts.length > 0, "the real peer's Accept");
assert.deepEqual(uncaught.errors, []);
} finally {
uncaught.stop();
raw.close();
await server.close();
}
});
test("a server transport whose resend can never be sent ends the session and sends nothing more of it", async () => {
const serverSocket = await bindUdpSocket();
const clientSocket = await bindUdpSocket();
const connectionId = 0x10203094;
const uncaught = watchUncaught();
const clientPort = udpPort(clientSocket);
// Sends to the client fail as the test says: later through the callback, or at once.
let mode: "healthy" | "lost" | "never" = "healthy";
const send = serverSocket.send.bind(serverSocket) as (...args: unknown[]) => void;
Object.assign(serverSocket, {
send: (message: Uint8Array, port: number, address: string, callback?: (error: Error | null) => void) => {
if (port !== clientPort || mode === "healthy") {
send(message, port, address, callback);
} else if (mode === "lost") {
process.nextTick(() => callback?.(Object.assign(new Error("lost"), { code: "EHOSTUNREACH" })));
} else {
throw Object.assign(new Error("never"), { code: "EMSGSIZE" });
}
},
});
const server = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-server",
socket: serverSocket,
mode: "server",
connectionId,
resendDelayMs: 10,
resendPollMs: 5,
});
const errors: string[] = [];
const reasons: string[] = [];
server.on("error", (error: NodeJS.ErrnoException) => errors.push(error.code ?? ""));
server.on("disconnect", ({ reason }: { reason: Uint8Array }) => reasons.push(Buffer.from(reason).toString("utf8")));
const client = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-client",
socket: clientSocket,
mode: "client",
remoteHost: "127.0.0.1",
remotePort: udpPort(serverSocket),
connectionId,
resendDelayMs: 10,
resendPollMs: 5,
});
try {
client.connect();
await waitFor(() => client.connected && server.connected, "the handshake");
mode = "lost";
server.send("schema", Buffer.from("one", "utf8"));
server.send("schema", Buffer.from("two", "utf8"));
await waitFor(() => server.stats.sendFailures >= 4, "both writes lost, and lost again when resent");
assert.deepEqual(errors, [], "a loss that may pass is counted, never emitted");
assert.deepEqual(reasons, []);
// Both writes are due again together; the first can never be sent.
mode = "never";
await waitFor(() => reasons.length > 0, "the session to end");
await sleep(50);
assert.deepEqual(reasons, ["packet could not be sent: EMSGSIZE"]);
assert.equal(server.connected, false);
assert.deepEqual(uncaught.errors, []);
} finally {
uncaught.stop();
client.close();
server.close();
}
});
// The kernel refuses a datagram to the limited broadcast address from a socket that has
// not asked for broadcast (EACCES): a real failure that may pass once the socket or the
// route changes, so it is a lost datagram. Node reports it only to the send's callback,
// never on the socket's "error" event, so nothing may turn it into an "error" either.
const UNREACHABLE = { host: "255.255.255.255", port: 9 };
test("a client transport with no error listener counts a send the network refuses and does not end the process", async () => {
const socket = await new Promise<Socket>((resolve) => {
const created = dgram.createSocket("udp4");
created.bind(0, "0.0.0.0", () => resolve(created));
});
const uncaught = watchUncaught();
const client = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-client",
socket,
mode: "client",
remoteHost: UNREACHABLE.host,
remotePort: UNREACHABLE.port,
connectionId: 0x10203095,
resendDelayMs: 10,
resendPollMs: 5,
});
try {
client.connect();
// The Connect and its resends, each refused by the kernel.
await waitFor(() => client.stats.sendFailures >= 5, "the refused Connect and its resends");
assert.equal(client.connected, false);
assert.deepEqual(uncaught.errors, []);
} finally {
uncaught.stop();
client.close();
}
});
test("an operation call the network refuses rejects with a timeout and does not end the process", async () => {
const uncaught = watchUncaught();
try {
await assert.rejects(
invokeCultNetOperation(
`rudp://${UNREACHABLE.host}:${UNREACHABLE.port}`,
{
schemaVersion: "cultnet.operation_request.v0",
messageId: "refused",
serviceId: "service",
operation: "operation",
payloadSchema: "payload",
payloadEncoding: "messagepack-base64",
payload: "gaZzY2hlbWE=",
sourceRuntimeId: "caller",
},
{ runtimeId: "caller", timeoutMs: 400 },
),
/CultNet operation connection timed out/,
);
assert.deepEqual(uncaught.errors, []);
} finally {
uncaught.stop();
}
});
// A send's failure can be reported after the generation that sent it ended: the client
// connected again from the same endpoint in the meantime. That failure is owed by nothing;
// it may not end the generation that replaced it or say goodbye to the endpoint.
/** Makes the next send to `port` fail as can never pass as built, reported `delayMs` later. */
function failNextSendLate(socket: Socket, port: number, delayMs: number): { arm: () => void } {
let armed = false;
const send = socket.send.bind(socket) as (...args: unknown[]) => void;
Object.assign(socket, {
send: (message: Uint8Array, toPort: number, address: string, callback?: (error: Error | null) => void) => {
if (toPort !== port || !armed) {
send(message, toPort, address, callback);
return;
}
armed = false;
setTimeout(() => callback?.(Object.assign(new Error("late"), { code: "EMSGSIZE" })), delayMs);
},
});
return { arm: () => { armed = true; } };
}
/** A client driven packet by packet from one socket, so it can connect again from the same endpoint. */
async function rawClient(serverPort: number, connectionId: number) {
const socket = await bindUdpSocket();
const received: ReturnType<typeof decodeRudpPacket>[] = [];
socket.on("message", (wire) => received.push(decodeRudpPacket(wire)));
const toServer = (packet: Parameters<typeof encodeRudpPacket>[0]) => socket.send(encodeRudpPacket(packet), serverPort, "127.0.0.1");
const connect = async (initialSequence: number) => {
const session = new CultNetRudpSession({ connectionId, initialSequence });
received.length = 0;
toServer(session.createConnect(Date.now()));
await waitFor(() => received.some((packet) => packet.packetType === "accept"), "the Accept");
const accept = received.find((packet) => packet.packetType === "accept")!;
session.receive(accept, Date.now());
toServer(session.createAckForReceived(accept.sequence));
return session;
};
return { socket, received, toServer, connect };
}
test("a transport ignores a send failure reported after the generation that sent it ended", async () => {
const serverSocket = await bindUdpSocket();
const connectionId = 0x10203096;
const uncaught = watchUncaught();
const server = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-server",
socket: serverSocket,
mode: "server",
connectionId,
resendPollMs: 1_000,
});
const reasons: string[] = [];
server.on("disconnect", ({ reason }: { reason: Uint8Array }) => reasons.push(Buffer.from(reason).toString("utf8")));
const client = await rawClient(udpPort(serverSocket), connectionId);
const late = failNextSendLate(serverSocket, udpPort(client.socket), 150);
try {
await client.connect(10);
late.arm();
server.send("schema", Buffer.from("old", "utf8"));
await client.connect(5_000);
await sleep(250);
assert.deepEqual(reasons, []);
assert.equal(server.connected, true);
assert.deepEqual(client.received.filter((packet) => packet.packetType === "disconnect"), []);
assert.deepEqual(uncaught.errors, []);
} finally {
uncaught.stop();
client.socket.close();
server.close();
}
});
test("an operation server ignores a send failure reported after the generation that sent it ended", async () => {
const connectionId = 0x43554c54;
const createSocket = dgram.createSocket;
let serverSocket: Socket | undefined;
Object.assign(dgram, {
createSocket: (...args: Parameters<typeof dgram.createSocket>) => {
serverSocket = createSocket(...args);
return serverSocket;
},
});
const server = await startCultNetOperationServer({
runtimeId: "operations",
handler: (request): CultNetOperationResponseMessage => ({
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: "ok",
payloadSchema: request.payloadSchema,
payloadEncoding: request.payloadEncoding,
payload: request.payload,
diagnostics: [],
sourceRuntimeId: "operations",
}),
}).finally(() => Object.assign(dgram, { createSocket }));
const client = await rawClient(Number(new URL(server.endpoint).port), connectionId);
const late = failNextSendLate(serverSocket!, udpPort(client.socket), 150);
const request = (session: CultNetRudpSession, messageId: string) => session.sendMany("schema", encode(encodeCultNetMessageForWire({
schemaVersion: "cultnet.operation_request.v0",
messageId,
serviceId: "service",
operation: "operation",
payloadSchema: "payload",
payloadEncoding: "messagepack-base64",
payload: "gaZzY2hlbWE=",
sourceRuntimeId: "caller",
} satisfies CultNetOperationRequestMessage, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() });
try {
const old = await client.connect(10);
// The response to this request is the next send, and it fails after the client reconnected.
late.arm();
for (const packet of request(old, "old")) client.toServer(packet);
const renewed = await client.connect(5_000);
await sleep(250);
assert.deepEqual(client.received.filter((packet) => packet.packetType === "disconnect"), []);
assert.equal(server.sendFailures, 0);
// The session that replaced it is still served.
let read = client.received.length;
let answered = 0;
for (const packet of request(renewed, "after")) client.toServer(packet);
await waitFor(() => {
for (; read < client.received.length; read += 1) {
const packet = client.received[read]!;
if (packet.packetType === "data") answered += renewed.receive(packet, Date.now()).delivered.length;
}
return answered > 0;
}, "the renewed session's response");
} finally {
client.socket.close();
await server.close();
}
});
test("a caller's send larger than a UDP datagram throws and takes nothing from the session", async () => {
const serverSocket = await bindUdpSocket();
const clientSocket = await bindUdpSocket();
const connectionId = 0x10203097;
const server = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-server",
socket: serverSocket,
mode: "server",
connectionId,
});
const client = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-client",
socket: clientSocket,
mode: "client",
remoteHost: "127.0.0.1",
remotePort: udpPort(serverSocket),
connectionId,
});
const frames: number[] = [];
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(frame.payload.byteLength));
// 36 header bytes and the six of "schema": the largest payload one IPv4 datagram carries.
const largest = 65_507 - 36 - "schema".length;
try {
client.connect();
await waitFor(() => client.connected && server.connected, "the handshake");
await waitFor(() => client.outstandingReliablePacketCount === 0, "the Connect acknowledged");
client.send("schema", new Uint8Array(largest));
assert.throws(() => client.send("schema", new Uint8Array(largest + 1)), { code: "EMSGSIZE" });
assert.equal(client.stats.framesSent, 1);
client.send("schema", Buffer.from("next", "utf8"));
// The refused send took no sequence: the next frame is not held behind a gap.
await waitFor(() => frames.length === 2, "both sent frames");
assert.deepEqual(frames, [largest, 4]);
assert.equal(client.connected, true);
await waitFor(() => client.outstandingReliablePacketCount === 0, "both frames acknowledged");
} finally {
client.close();
server.close();
}
});
// The pre-check measures one fragment, the largest datagram a fragmented send makes. Fragments
// of 1 KiB and of 40,000 bytes both leave a 70,000-byte send legal; the second size is large
// enough that any small multiple of it would not be.
for (const maxFragmentBytes of [1024, 40_000]) {
test(`a fragmented send larger than one UDP datagram is sent, because each datagram carries one fragment (${maxFragmentBytes}-byte fragments)`, async () => {
const serverSocket = await bindUdpSocket();
const clientSocket = await bindUdpSocket();
const connectionId = 0x10203095;
const server = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-server",
socket: serverSocket,
mode: "server",
connectionId,
});
const client = new CultNetRudpSocketTransportConnection({
runtimeId: "rudp-client",
socket: clientSocket,
mode: "client",
remoteHost: "127.0.0.1",
remotePort: udpPort(serverSocket),
connectionId,
maxFragmentBytes,
});
const frames: number[] = [];
server.on("frame", (frame: { payload: Uint8Array }) => frames.push(frame.payload.byteLength));
// Larger than any one datagram can carry, over IPv4 or IPv6.
const payloadBytes = 70_000;
try {
client.connect();
await waitFor(() => client.connected && server.connected, "the handshake");
assert.doesNotThrow(() => client.send("schema", new Uint8Array(payloadBytes)));
assert.equal(client.stats.framesSent, 1);
await waitFor(() => frames.length === 1, "the reassembled frame");
assert.deepEqual(frames, [payloadBytes]);
assert.equal(client.connected, true);
} finally {
client.close();
server.close();
}
});
}