cultnet-ts server: close() waits for the goodbyes to leave the socket; peer reaping is opt-in and TypeScript-only
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 01:51:34 +01:00
commit 94f20eee5e
3 changed files with 60 additions and 16 deletions

View file

@ -7,7 +7,7 @@ All notable changes to `cultnet-ts` are recorded here, following
### Added
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; idle peers are reaped after `peerTimeoutMs` (default 60 s) and Connects beyond `maxPeers` (default 256) are dropped. Matches the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`; the wire format is unchanged. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
### Breaking

View file

@ -34,10 +34,12 @@ export interface CultNetRudpSocketServerOptions {
/** A Connect from an endpoint beyond this many peers is dropped. Default 256. */
maxPeers?: number;
/**
* A peer that has sent nothing for this long is ended and told so. Default 60 000;
* `Infinity` keeps idle peers until they leave.
* A peer that has sent nothing for this long is ended and told so. TypeScript only: the
* Rust and C# servers never reap. Default `Infinity`, which keeps idle peers until they leave.
*/
peerTimeoutMs?: number;
/** How long `close()` waits for the goodbyes to leave the socket. Default 1000. */
closeGraceMs?: number;
}
/** One remote endpoint's session. A peer that reconnects from the same endpoint is a new object. */
@ -90,6 +92,7 @@ export class CultNetRudpSocketTransportServer extends EventEmitter {
readonly #maxPeers: number;
readonly #peerTimeoutMs: number;
#closed = false;
#closing = false;
readonly #stats: CultNetTransportStats = {
bytesReceived: 0,
bytesSent: 0,
@ -104,7 +107,7 @@ export class CultNetRudpSocketTransportServer extends EventEmitter {
this.#options = options;
this.#socket = options.socket;
this.#maxPeers = options.maxPeers ?? 256;
this.#peerTimeoutMs = options.peerTimeoutMs ?? 60_000;
this.#peerTimeoutMs = options.peerTimeoutMs ?? Infinity;
if (!Number.isInteger(this.#maxPeers) || this.#maxPeers < 1) {
throw new Error("RUDP server maxPeers must be a positive integer.");
}
@ -169,13 +172,23 @@ export class CultNetRudpSocketTransportServer extends EventEmitter {
this.#endPeer(entry, entry.session.createDisconnect(Uint8Array.from(reason)));
}
/** Ends every peer and releases the socket. */
close(): void {
/**
* Ends every peer and releases the socket once the goodbyes have left it (bounded by
* `closeGraceMs`, default 1000): closing the socket at once would drop them.
*/
async close(): Promise<void> {
if (this.#closed) return;
this.#closing = true;
clearInterval(this.#timer);
for (const entry of [...this.#entries.values()]) {
this.#endPeer(entry, entry.session.createDisconnect(utf8("server closed")));
}
const sent = [...this.#entries.values()].map((entry) =>
this.#endPeer(entry, entry.session.createDisconnect(utf8("server closed"))));
let bound: NodeJS.Timeout | undefined;
await Promise.race([
Promise.all(sent),
new Promise<void>((resolve) => { bound = setTimeout(resolve, this.#options.closeGraceMs ?? 1_000); }),
]);
clearTimeout(bound);
if (this.#closed) return;
this.#closed = true;
this.#socket.close();
}
@ -189,17 +202,24 @@ export class CultNetRudpSocketTransportServer extends EventEmitter {
}
/** Removes the peer, tells the caller, and says goodbye on the wire. Idempotent per entry. */
#endPeer(entry: PeerEntry, goodbye: CultNetRudpPacket): void {
if (this.#entries.get(entry.key) !== entry) return;
#endPeer(entry: PeerEntry, goodbye: CultNetRudpPacket): Promise<void> {
if (this.#entries.get(entry.key) !== entry) return Promise.resolve();
this.#entries.delete(entry.key);
this.emit("peerDisconnected", { peer: entry.peer, reason: goodbye.payload });
// Best-effort: the session is over whether or not the peer hears it.
const wire = encodeRudpPacket(goodbye);
this.#stats.bytesSent += wire.length;
sendRudpDatagram(this.#socket, wire, entry.peer.remotePort, entry.peer.remoteHost, () => {});
return new Promise<void>((resolve) => {
try {
this.#socket.send(wire, entry.peer.remotePort, entry.peer.remoteHost, () => resolve());
} catch {
resolve();
}
});
}
#receiveDatagram(wire: Buffer, remote: RemoteInfo): void {
if (this.#closing) return;
this.#stats.bytesReceived += wire.length;
// What a datagram carries is the sender's business: a malformed frame, another
// session's connection id and a sender no Connect admitted are dropped and counted.

View file

@ -97,7 +97,7 @@ test("two clients on different ports talk through one server socket and neither
assert.equal(a.closed() || b.closed(), false);
assert.equal(server.peers.length, 2);
} finally {
a.connection.close(); b.connection.close(); server.close();
a.connection.close(); b.connection.close(); await server.close();
}
});
@ -120,7 +120,7 @@ test("a client reconnecting from the same endpoint replaces only its own session
await waitFor(() => a.received.includes("re:after") && b.received.includes("re:still"), "replies after the reconnect");
assert.equal(b.closed(), false);
} finally {
a.connection.close(); b.connection.close(); server.close();
a.connection.close(); b.connection.close(); await server.close();
}
});
@ -147,7 +147,7 @@ test("a peer that leaves, or goes silent, is dropped and the others keep their s
steady.connection.send("schema", enc("alive"));
await waitFor(() => steady.received.includes("re:alive"), "the surviving peer's reply");
} finally {
silent.connection.close(); steady.connection.close(); server.close();
silent.connection.close(); steady.connection.close(); await server.close();
}
});
@ -167,6 +167,30 @@ test("Connects beyond maxPeers and datagrams from unadmitted endpoints are dropp
first.connection.send("schema", enc("ok"));
await waitFor(() => first.received.includes("re:ok"), "the admitted peer's reply");
} finally {
stranger.close(); first.connection.close(); server.close();
stranger.close(); first.connection.close(); await server.close();
}
});
test("close() delivers a Disconnect to every peer before the socket is released", async () => {
const { server, port } = await startServer();
const peers = await Promise.all([0, 1, 2].map(async () => {
const socket = await bind();
const session = new CultNetRudpSession({ connectionId });
const types: string[] = [];
socket.on("message", (wire) => {
const packet = decodeRudpPacket(wire);
types.push(packet.packetType);
session.receive(packet, Date.now());
});
socket.send(encodeRudpPacket(session.createConnect(Date.now())), port, "127.0.0.1");
await waitFor(() => session.connected, "raw handshake");
return { socket, types };
}));
try {
assert.equal(server.peers.length, 3);
await server.close();
await waitFor(() => peers.every((peer) => peer.types.includes("disconnect")), "every peer to observe the Disconnect");
} finally {
peers.forEach((peer) => peer.socket.close());
}
});