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
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:
parent
ae0b9e234f
commit
94f20eee5e
3 changed files with 60 additions and 16 deletions
|
|
@ -7,7 +7,7 @@ All notable changes to `cultnet-ts` are recorded here, following
|
||||||
|
|
||||||
### Added
|
### 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
|
### Breaking
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ export interface CultNetRudpSocketServerOptions {
|
||||||
/** A Connect from an endpoint beyond this many peers is dropped. Default 256. */
|
/** A Connect from an endpoint beyond this many peers is dropped. Default 256. */
|
||||||
maxPeers?: number;
|
maxPeers?: number;
|
||||||
/**
|
/**
|
||||||
* A peer that has sent nothing for this long is ended and told so. Default 60 000;
|
* A peer that has sent nothing for this long is ended and told so. TypeScript only: the
|
||||||
* `Infinity` keeps idle peers until they leave.
|
* Rust and C# servers never reap. Default `Infinity`, which keeps idle peers until they leave.
|
||||||
*/
|
*/
|
||||||
peerTimeoutMs?: number;
|
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. */
|
/** 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 #maxPeers: number;
|
||||||
readonly #peerTimeoutMs: number;
|
readonly #peerTimeoutMs: number;
|
||||||
#closed = false;
|
#closed = false;
|
||||||
|
#closing = false;
|
||||||
readonly #stats: CultNetTransportStats = {
|
readonly #stats: CultNetTransportStats = {
|
||||||
bytesReceived: 0,
|
bytesReceived: 0,
|
||||||
bytesSent: 0,
|
bytesSent: 0,
|
||||||
|
|
@ -104,7 +107,7 @@ export class CultNetRudpSocketTransportServer extends EventEmitter {
|
||||||
this.#options = options;
|
this.#options = options;
|
||||||
this.#socket = options.socket;
|
this.#socket = options.socket;
|
||||||
this.#maxPeers = options.maxPeers ?? 256;
|
this.#maxPeers = options.maxPeers ?? 256;
|
||||||
this.#peerTimeoutMs = options.peerTimeoutMs ?? 60_000;
|
this.#peerTimeoutMs = options.peerTimeoutMs ?? Infinity;
|
||||||
if (!Number.isInteger(this.#maxPeers) || this.#maxPeers < 1) {
|
if (!Number.isInteger(this.#maxPeers) || this.#maxPeers < 1) {
|
||||||
throw new Error("RUDP server maxPeers must be a positive integer.");
|
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)));
|
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;
|
if (this.#closed) return;
|
||||||
|
this.#closing = true;
|
||||||
clearInterval(this.#timer);
|
clearInterval(this.#timer);
|
||||||
for (const entry of [...this.#entries.values()]) {
|
const sent = [...this.#entries.values()].map((entry) =>
|
||||||
this.#endPeer(entry, entry.session.createDisconnect(utf8("server closed")));
|
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.#closed = true;
|
||||||
this.#socket.close();
|
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. */
|
/** Removes the peer, tells the caller, and says goodbye on the wire. Idempotent per entry. */
|
||||||
#endPeer(entry: PeerEntry, goodbye: CultNetRudpPacket): void {
|
#endPeer(entry: PeerEntry, goodbye: CultNetRudpPacket): Promise<void> {
|
||||||
if (this.#entries.get(entry.key) !== entry) return;
|
if (this.#entries.get(entry.key) !== entry) return Promise.resolve();
|
||||||
this.#entries.delete(entry.key);
|
this.#entries.delete(entry.key);
|
||||||
this.emit("peerDisconnected", { peer: entry.peer, reason: goodbye.payload });
|
this.emit("peerDisconnected", { peer: entry.peer, reason: goodbye.payload });
|
||||||
// Best-effort: the session is over whether or not the peer hears it.
|
// Best-effort: the session is over whether or not the peer hears it.
|
||||||
const wire = encodeRudpPacket(goodbye);
|
const wire = encodeRudpPacket(goodbye);
|
||||||
this.#stats.bytesSent += wire.length;
|
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 {
|
#receiveDatagram(wire: Buffer, remote: RemoteInfo): void {
|
||||||
|
if (this.#closing) return;
|
||||||
this.#stats.bytesReceived += wire.length;
|
this.#stats.bytesReceived += wire.length;
|
||||||
// What a datagram carries is the sender's business: a malformed frame, another
|
// 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.
|
// session's connection id and a sender no Connect admitted are dropped and counted.
|
||||||
|
|
|
||||||
|
|
@ -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(a.closed() || b.closed(), false);
|
||||||
assert.equal(server.peers.length, 2);
|
assert.equal(server.peers.length, 2);
|
||||||
} finally {
|
} 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");
|
await waitFor(() => a.received.includes("re:after") && b.received.includes("re:still"), "replies after the reconnect");
|
||||||
assert.equal(b.closed(), false);
|
assert.equal(b.closed(), false);
|
||||||
} finally {
|
} 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"));
|
steady.connection.send("schema", enc("alive"));
|
||||||
await waitFor(() => steady.received.includes("re:alive"), "the surviving peer's reply");
|
await waitFor(() => steady.received.includes("re:alive"), "the surviving peer's reply");
|
||||||
} finally {
|
} 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"));
|
first.connection.send("schema", enc("ok"));
|
||||||
await waitFor(() => first.received.includes("re:ok"), "the admitted peer's reply");
|
await waitFor(() => first.received.includes("re:ok"), "the admitted peer's reply");
|
||||||
} finally {
|
} 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());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue