From a533d6e22134f1eddf7f2929a0a9af231906cc93 Mon Sep 17 00:00:00 2001 From: Horoli Date: Thu, 15 Oct 2026 00:14:17 +0900 Subject: [PATCH] Recover presence connections after temporary database failures --- constants/presence.ts | 1 + src/client/presence.ts | 14 +++++++++++--- src/server/presence.ts | 9 ++++++--- tests/presence.test.ts | 34 ++++++++++++++++++++++++++++++++-- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/constants/presence.ts b/constants/presence.ts index 0f2aa5c..bead9f5 100644 --- a/constants/presence.ts +++ b/constants/presence.ts @@ -1 +1,2 @@ export const PRESENCE_AUTH_REFRESH_MS = 60000; +export const PRESENCE_RETRY_MS = 5000; diff --git a/src/client/presence.ts b/src/client/presence.ts index e8d62e2..a5104fb 100644 --- a/src/client/presence.ts +++ b/src/client/presence.ts @@ -1,21 +1,29 @@ import { io, type Socket } from 'socket.io-client'; import type { GameSessionAuth } from '../shared/p2p-types.js'; +import { PRESENCE_RETRY_MS } from '../../constants/presence.js'; export function createPresence(counter: HTMLElement) { let socket: Socket | undefined, sessionId = ''; + let retry: ReturnType | undefined; const unknown = () => { counter.textContent = '접속 확인 중'; }; - window.addEventListener('pagehide', () => { socket?.disconnect(); unknown(); }); + window.addEventListener('pagehide', () => { clearTimeout(retry); socket?.disconnect(); unknown(); }); window.addEventListener('pageshow', () => { socket?.connect(); }); return { connect(auth: GameSessionAuth) { if (socket && sessionId === auth.gameSessionId) { socket.connect(); return; } - socket?.removeAllListeners(); socket?.disconnect(); unknown(); + clearTimeout(retry); socket?.removeAllListeners(); socket?.disconnect(); unknown(); sessionId = auth.gameSessionId; socket = io('/presence', { auth: { gameSessionId: auth.gameSessionId, token: auth.token }, forceNew: true, autoConnect: false }); socket.on('presence:count', (count: unknown) => { if (Number.isSafeInteger(count) && (count as number) >= 0) counter.textContent = `접속 ${count}명`; }); - socket.on('disconnect', unknown); socket.on('connect_error', unknown); + socket.on('connect', () => clearTimeout(retry)); + socket.on('disconnect', unknown); + socket.on('connect_error', error => { + unknown(); clearTimeout(retry); + // Middleware errors do not trigger Socket.IO's transport reconnection loop. + if (error.message !== 'INVALID_SESSION') retry = setTimeout(() => socket?.connect(), PRESENCE_RETRY_MS); + }); socket.connect(); }, }; diff --git a/src/server/presence.ts b/src/server/presence.ts index 60d7a89..9a92ffe 100644 --- a/src/server/presence.ts +++ b/src/server/presence.ts @@ -1,5 +1,5 @@ import type { Server } from 'socket.io'; -import type { GameSessionStore } from './game-session-store.js'; +import { ApiError, type GameSessionStore } from './game-session-store.js'; import { PRESENCE_AUTH_REFRESH_MS } from '../../constants/presence.js'; // The application is deployed as one process, as is the ranked match service. @@ -15,7 +15,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) { void sessions.get({ gameSessionId, token }).then(session => { socket.data.playerId = session.playerId ?? session.gameSessionId; next(); - }, () => next(new Error('INVALID_SESSION'))); + }, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE'))); }); namespace.on('connection', socket => { const before = count; publish(); @@ -25,7 +25,10 @@ export function registerPresence(io: Server, sessions: GameSessionStore) { if (checking) return; checking = true; const { gameSessionId, token } = socket.handshake.auth; - void sessions.get({ gameSessionId, token }).catch(() => socket.disconnect(true)).finally(() => { checking = false; }); + void sessions.get({ gameSessionId, token }).catch(error => { + if (error instanceof ApiError && error.statusCode === 401) socket.disconnect(true); + else socket.conn.close(); // Transport closure permits Socket.IO to reconnect after a transient outage. + }).finally(() => { checking = false; }); }, PRESENCE_AUTH_REFRESH_MS); timer.unref(); socket.on('disconnect', () => { clearInterval(timer); publish(); }); diff --git a/tests/presence.test.ts b/tests/presence.test.ts index 5702538..6713cd5 100644 --- a/tests/presence.test.ts +++ b/tests/presence.test.ts @@ -4,12 +4,13 @@ import { createServer } from 'node:http'; import { Server } from 'socket.io'; import { io as client, type Socket } from 'socket.io-client'; import { registerPresence } from '../src/server/presence.js'; -import type { GameSessionStore } from '../src/server/game-session-store.js'; +import { ApiError, type GameSessionStore } from '../src/server/game-session-store.js'; +import { PRESENCE_AUTH_REFRESH_MS } from '../constants/presence.js'; test('presence counts authenticated players once across tabs and removes disconnected players', async () => { const http = createServer(), io = new Server(http); registerPresence(io, { async get(auth: { token: string; gameSessionId: string }) { - if (auth.token !== 'valid') throw new Error('INVALID_SESSION'); + if (auth.token !== 'valid') throw new ApiError(401, 'INVALID_SESSION'); return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' }; } } as unknown as GameSessionStore); await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); @@ -33,3 +34,32 @@ test('presence counts authenticated players once across tabs and removes disconn const lastA = next(secondTab); a.disconnect(); b.disconnect(); assert.equal(await lastA, 1); } finally { for (const socket of sockets) socket.disconnect(); await new Promise(resolve => io.close(() => resolve())); } }); + +test('temporary revalidation errors allow reconnect, invalid sessions remain disconnected', async t => { + let revalidate!: () => void; + const interval = globalThis.setInterval; + t.mock.method(globalThis, 'setInterval', ((fn: () => void, ms: number) => { + if (ms !== PRESENCE_AUTH_REFRESH_MS) return interval(fn, ms); + revalidate = fn; + return { unref() {} }; + }) as typeof setInterval); + let failure: 'temporary' | 'invalid' | undefined; + const http = createServer(), io = new Server(http); + registerPresence(io, { async get() { + if (failure === 'invalid') throw new ApiError(401, 'INVALID_SESSION'); + if (failure === 'temporary') { failure = undefined; throw new Error('DB_UNAVAILABLE'); } + return { playerId: 'one' }; + } } as unknown as GameSessionStore); + await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); + const socket = client(`http://127.0.0.1:${(http.address() as { port: number }).port}/presence`, { autoConnect: false, reconnectionDelay: 10 }); + const connected = () => new Promise(resolve => socket.once('connect', () => resolve())); + try { + const first = connected(); socket.connect(); await first; + const next = connected(); failure = 'temporary'; revalidate(); await next; + assert.equal(socket.connected, true); + const disconnected = new Promise(resolve => socket.once('disconnect', resolve)); + failure = 'invalid'; revalidate(); + assert.equal(await disconnected, 'io server disconnect'); + assert.equal(socket.active, false); + } finally { socket.disconnect(); await new Promise(resolve => io.close(() => resolve())); } +});