diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index da431cd9..e68293d3 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -30,6 +30,10 @@ import { const RUDP_MAGIC = [0x43, 0x4e, 0x52, 0x30] as const; // CNR0 const RUDP_VERSION = 0; const RUDP_FIXED_HEADER_BYTES = 36; +/// The largest UDP payload a datagram can carry: 65,535 less the UDP header and, +/// over IPv4, the minimal IP header. IPv6's header is outside its payload length. +const MAX_UDP4_DATAGRAM_BYTES = 65_507; +const MAX_UDP6_DATAGRAM_BYTES = 65_527; const MAX_CHANNEL_ID_BYTES = 255; export const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS = 32; const RUDP_RECEIVED_SEQUENCE_WINDOW = 4_096; @@ -1037,6 +1041,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement readonly #mode: "client" | "server"; readonly #resendTimer: NodeJS.Timeout; readonly #maxFragmentBytes: number | undefined; + readonly #maxDatagramBytes: number; #remoteHost: string | undefined; #remotePort: number | undefined; #closed = false; @@ -1068,6 +1073,8 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement const address = this.#socket.address(); const localPort = typeof address === "string" ? undefined : address.port; const localHost = typeof address === "string" ? undefined : address.address; + this.#maxDatagramBytes = + typeof address !== "string" && String(address.family).endsWith("6") ? MAX_UDP6_DATAGRAM_BYTES : MAX_UDP4_DATAGRAM_BYTES; this.profile = createRudpTransportProfile(options.runtimeId, { transportId: options.transportId, host: localHost, @@ -1109,6 +1116,15 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement } send(channelId: string, payload: Uint8Array): void { + // A datagram larger than UDP can carry can never be sent, and Node reports that + // only later, to the send's callback. Checked here it is the caller's error, as + // in the other runtimes: the send throws, and nothing of it is queued or takes a + // sequence. Every datagram of a send is at most one fragment of its payload. + const largestPayload = + this.#maxFragmentBytes === undefined ? payload.byteLength : Math.min(payload.byteLength, this.#maxFragmentBytes); + if (RUDP_FIXED_HEADER_BYTES + Buffer.byteLength(channelId, "utf8") + largestPayload > this.#maxDatagramBytes) { + throw Object.assign(new Error("RUDP datagram is larger than UDP can carry."), { code: "EMSGSIZE" }); + } const packets = this.#session.sendMany(channelId, payload, { ...channelOptions(channelId), nowMs: Date.now(), diff --git a/packages/cultnet-ts/test/rudp-send-failure.test.ts b/packages/cultnet-ts/test/rudp-send-failure.test.ts index cb62d199..803b1e15 100644 --- a/packages/cultnet-ts/test/rudp-send-failure.test.ts +++ b/packages/cultnet-ts/test/rudp-send-failure.test.ts @@ -433,3 +433,46 @@ test("an operation server ignores a send failure reported after the generation t 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(); + } +});