rook/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Fix a stale socket close evicting a live reconnect's seat

A page reload opens the new websocket before the old one's close event
lands. release() keyed only on session, so the old close would delete
whatever the session's current seat entry was (the new socket's) and,
finding nobody else holding the player, flip a connected human back to
a bot — command() then silently drops that player's input. attach/
release now carry a per-socket token so a stale close only releases the
socket it belongs to.

Added a regression test in server.test.ts that reproduces the race
directly against attach/release; confirmed it fails on the old code and
passes with the fix.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Pike committed 230b84c Browse files
3 files changed +29 −11
src/arena.ts +12 −6
@@ -31,7 +31,7 @@ import { isWeaponId, WEAPONS } from "./weapons.ts";
3131 export const TICK = 1 / 30;
3232 const BROADCAST_EVERY = 2;
3333
34 export type Seat = { playerId: string; send: (data: string) => void };
34 export type Seat = { playerId: string; token: string; send: (data: string) => void };
3535
3636 let state: MatchState = newMatch();
3737 const seats = new Map<string, Seat>();
@@ -86,15 +86,21 @@ export function takeSeat(session: string, name: string): Player | null {
8686 return seat;
8787 }
8888
89 export function attach(session: string, playerId: string, send: (data: string) => void): void {
90 seats.set(session, { playerId, send });
89 export function attach(session: string, playerId: string, token: string, send: (data: string) => void): void {
90 seats.set(session, { playerId, token, send });
9191 }
9292
93 /** A person leaving gives the seat back to a bot, mid-round and all. */
94 export function release(session: string): void {
93 /**
94 * A person leaving gives the seat back to a bot, mid-round and all — but only
95 * if this is still the socket the session is on. A reload opens the new
96 * socket before the old one's close event arrives; without the token, that
97 * late close would delete the reconnect's seat entry and hand a live human's
98 * seat to a bot mid-round.
99 */
100 export function release(session: string, token: string): void {
95101 const seat = seats.get(session);
102 if (!seat || seat.token !== token) return;
96103 seats.delete(session);
97 if (!seat) return;
98104 const player = findPlayer(seat.playerId);
99105 if (player && !stillHeld(seat.playerId)) {
100106 player.bot = true;
src/server.test.ts +11
@@ -207,6 +207,17 @@ test("the page carries the first-run card and the prompt line", async () => {
207207 expect(body).toContain('id="viewtoggle"');
208208 });
209209
210 test("a stale socket closing does not evict a live reconnect's seat", async () => {
211 // A reload opens the new socket before the old one's close event lands.
212 // The old close must not turn a player who is still connected into a bot.
213 const { attach, release } = await import("./arena.ts");
214 const player = takeSeat("reload-session", "Reload")!;
215 attach("reload-session", player.id, "first", () => {});
216 attach("reload-session", player.id, "second", () => {});
217 release("reload-session", "first");
218 expect(player.bot).toBe(false);
219 });
220
210221 test("two people who pick the same name do not end up with the same name", () => {
211222 // Everything that addresses a player — the kill feed, the round log, the
212223 // scoreboard — does it by name, so the match may never hold two of one.
src/server.ts +6 −5
@@ -145,7 +145,7 @@ const cleanName = (raw: unknown): string => {
145145 const setCookie = (name: string, value: string, httpOnly: boolean): string =>
146146 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
147147
148 type SocketData = { session: string; playerId: string };
148 type SocketData = { session: string; playerId: string; token: string };
149149
150150 // ---------------------------------------------------------------- routing
151151
@@ -180,7 +180,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
180180 const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`;
181181 const player = takeSeat(session, name);
182182 if (!player) return new Response("no seats", { status: 503, headers: SECURITY });
183 const upgraded = self.upgrade(request, { data: { session, playerId: player.id } });
183 const token = randomUUID();
184 const upgraded = self.upgrade(request, { data: { session, playerId: player.id, token } });
184185 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
185186 }
186187
@@ -264,8 +265,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
264265 websocket: {
265266 maxPayloadLength: 4 * 1024,
266267 open(socket) {
267 const { session, playerId } = socket.data;
268 attach(session, playerId, (data) => socket.send(data));
268 const { session, playerId, token } = socket.data;
269 attach(session, playerId, token, (data) => socket.send(data));
269270 socket.send(snapshot(playerId));
270271 },
271272 message(socket, raw) {
@@ -282,7 +283,7 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
282283 command(socket.data.playerId, parsed as Command);
283284 },
284285 close(socket) {
285 release(socket.data.session);
286 release(socket.data.session, socket.data.token);
286287 },
287288 },
288289
↑↓ navigate ↵ open esc close

Keyboard shortcuts