From 60d4d20d00ec720d3c8ff7c6ba501b158973fd22 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 10 May 2026 19:50:36 +0200 Subject: [PATCH] fix: free cap slot on disconnect, not just on room GC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admission used members.length (which only grows), so once a full room lost a client the cap was wedged until the room emptied entirely. Count room.active instead — disconnects free the spot, new joiners get the next index (which can exceed maxClients in churning rooms). Reclaim also checks the cap, since a freed spot may have been taken by the time the original clientId returns. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++--- server.test.ts | 43 ++++++++++++++++++++++++++++++++++++++----- server.ts | 13 ++++++++++++- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 175a653..7ce1a69 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ Minimal WebSocket relay server for party games. Clients share rooms and exchange - A client **creates** a room (server assigns a 6-char code) with a max client limit - Other clients **join** by room code -- Each member is assigned a numeric `index` (slot id). Indices are stable for the room's lifetime and never reassigned +- Each member is assigned a numeric `index` (slot id). Indices are stable for the room's lifetime and never reassigned. New joiners always get the next index, so in long-lived rooms with churn an index can exceed `maxClients` - Clients pick their own `clientId`; it stays server-side and acts as the bearer secret for their slot. Reconnecting with the same `clientId` replaces the old connection in the same slot - Messages can be **broadcast** to all peers or **sent** to a specific peer index -- `peer_left` is broadcast immediately on disconnect +- `peer_left` is broadcast immediately on disconnect, and the cap slot frees right away — a new joiner can take it. The original `clientId` can still reclaim its old index if no one took the spot in the meantime - Rooms are cleaned up when empty ## Run @@ -77,7 +77,7 @@ ws.onmessage = (event) => { Joining with the same `clientId` replaces the old connection in the same slot; no special reconnect message needed. The server closes the previous WebSocket with code `4000` and reason `"replaced"`. Treat that as terminal in your reconnect loop, otherwise the new connection will be torn down by the next replacement. -Since the `clientId` stays on the original client, another connection can't claim your slot; they'd just get a fresh index. Persist it locally if you want reconnect to survive a refresh. +Since the `clientId` stays on the original client, another connection can't *take over* your slot — but they can fill the cap spot it freed when you disconnected. Reconnect is best-effort: if the room re-filled while you were gone you'll get `Room is full`. Persist `clientId` locally if you want reconnect to survive a refresh. Hosts reconnect like guests: send `join` with the original `clientId` and room code, not another `create`. diff --git a/server.test.ts b/server.test.ts index cacdcae..913ed83 100644 --- a/server.test.ts +++ b/server.test.ts @@ -386,7 +386,10 @@ describe("reconnect", () => { expect(rooms.get(room)?.active).toBe(2); }); - test("new clientId cannot consume a disconnected owned slot", async () => { + test("disconnected slot frees the cap for a new joiner at the next index", async () => { + // Cap reflects live clients. When guest disconnects (battery, network, + // tab closed — not just intentional), a stranger can take the spot. + // Indices are still never reassigned: the stranger gets index 2, not 1. const ws1 = track(await connect()); sendMsg(ws1, { type: "create", clientId: "host", maxClients: 2 }); const { room } = await waitForType(ws1, "created"); @@ -401,11 +404,41 @@ describe("reconnect", () => { const ws3 = track(await connect()); sendMsg(ws3, { type: "join", clientId: "stranger", room }); - const msg = await waitForType(ws3, "error"); + const joinMsg = await waitForType(ws3, "joined"); + + expect(joinMsg.index).toBe(2); + expect(rooms.get(room)?.active).toBe(2); + expect(rooms.get(room)?.members).toHaveLength(3); + }); + + test("reclaim fails when room re-filled while disconnected", async () => { + // First-come-first-served: if a stranger took the freed spot before the + // original owner reconnected, the owner gets "Room is full" — the slot + // still exists in members[] but the cap is exhausted. + const ws1 = track(await connect()); + sendMsg(ws1, { type: "create", clientId: "host", maxClients: 2 }); + const { room } = await waitForType(ws1, "created"); - expect(msg.message).toBe("Room is full"); - expect(rooms.get(room)?.active).toBe(1); - expect(rooms.get(room)?.members).toHaveLength(2); + const ws2 = track(await connect()); + sendMsg(ws2, { type: "join", clientId: "guest", room }); + await waitForType(ws2, "joined"); + + const peerLeftPromise = waitForType(ws1, "peer_left"); + ws2.close(); + await peerLeftPromise; + + // Stranger fills the freed spot. + const ws3 = track(await connect()); + sendMsg(ws3, { type: "join", clientId: "stranger", room }); + await waitForType(ws3, "joined"); + + // Original guest tries to come back — no room. + const ws4 = track(await connect()); + sendMsg(ws4, { type: "join", clientId: "guest", room }); + const err = await waitForType(ws4, "error"); + + expect(err.message).toBe("Room is full"); + expect(rooms.get(room)?.active).toBe(2); }); test("attacker without the clientId cannot evict an existing peer", async () => { diff --git a/server.ts b/server.ts index d748524..48dcb7c 100644 --- a/server.ts +++ b/server.ts @@ -362,6 +362,13 @@ function handleJoin(ws: ServerWebSocket, msg: { clientId: string; ro existing.ws.data.index = undefined; existing.ws.close(4000, "replaced"); } else { + // Reclaim of a disconnected slot. Disconnects free the cap, so by the + // time the original owner returns the room may have re-filled with new + // joiners. First-come-first-served — no special priority for prior + // owners. + if (room.active >= room.maxClients) { + return send(ws, { type: "error", message: "Room is full" }); + } room.active++; } @@ -377,7 +384,11 @@ function handleJoin(ws: ServerWebSocket, msg: { clientId: string; ro return; } - if (room.members.length >= room.maxClients) { + // Cap counts live clients, not allocated slots. A disconnected slot + // (battery dead, network drop, tab closed) frees the spot. New joiners + // always push to the end, so indices grow monotonically and may exceed + // maxClients in long-lived rooms with churn. + if (room.active >= room.maxClients) { return send(ws, { type: "error", message: "Room is full" }); }