cultnet-ts: a caller's send larger than a UDP datagram throws EMSGSIZE and takes nothing
Node reports an oversized datagram only to the send's callback, so the caller's send used to succeed and the session ended later. The transport now checks the largest datagram of the send against the socket family's UDP limit (65,507 bytes for IPv4, 65,527 for IPv6) before the session builds anything: the send throws, nothing is queued, and no sequence is taken, as the Rust and C# listeners withdraw it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2d76b8d1b6
commit
73fbb264d6
2 changed files with 59 additions and 0 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue