Recover presence connections after temporary database failures
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
export const PRESENCE_AUTH_REFRESH_MS = 60000;
|
export const PRESENCE_AUTH_REFRESH_MS = 60000;
|
||||||
|
export const PRESENCE_RETRY_MS = 5000;
|
||||||
|
|||||||
+11
-3
@@ -1,21 +1,29 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import type { GameSessionAuth } from '../shared/p2p-types.js';
|
import type { GameSessionAuth } from '../shared/p2p-types.js';
|
||||||
|
import { PRESENCE_RETRY_MS } from '../../constants/presence.js';
|
||||||
|
|
||||||
export function createPresence(counter: HTMLElement) {
|
export function createPresence(counter: HTMLElement) {
|
||||||
let socket: Socket | undefined, sessionId = '';
|
let socket: Socket | undefined, sessionId = '';
|
||||||
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
const unknown = () => { counter.textContent = '접속 확인 중'; };
|
const unknown = () => { counter.textContent = '접속 확인 중'; };
|
||||||
window.addEventListener('pagehide', () => { socket?.disconnect(); unknown(); });
|
window.addEventListener('pagehide', () => { clearTimeout(retry); socket?.disconnect(); unknown(); });
|
||||||
window.addEventListener('pageshow', () => { socket?.connect(); });
|
window.addEventListener('pageshow', () => { socket?.connect(); });
|
||||||
return {
|
return {
|
||||||
connect(auth: GameSessionAuth) {
|
connect(auth: GameSessionAuth) {
|
||||||
if (socket && sessionId === auth.gameSessionId) { socket.connect(); return; }
|
if (socket && sessionId === auth.gameSessionId) { socket.connect(); return; }
|
||||||
socket?.removeAllListeners(); socket?.disconnect(); unknown();
|
clearTimeout(retry); socket?.removeAllListeners(); socket?.disconnect(); unknown();
|
||||||
sessionId = auth.gameSessionId;
|
sessionId = auth.gameSessionId;
|
||||||
socket = io('/presence', { auth: { gameSessionId: auth.gameSessionId, token: auth.token }, forceNew: true, autoConnect: false });
|
socket = io('/presence', { auth: { gameSessionId: auth.gameSessionId, token: auth.token }, forceNew: true, autoConnect: false });
|
||||||
socket.on('presence:count', (count: unknown) => {
|
socket.on('presence:count', (count: unknown) => {
|
||||||
if (Number.isSafeInteger(count) && (count as number) >= 0) counter.textContent = `접속 ${count}명`;
|
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();
|
socket.connect();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Server } from 'socket.io';
|
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';
|
import { PRESENCE_AUTH_REFRESH_MS } from '../../constants/presence.js';
|
||||||
|
|
||||||
// The application is deployed as one process, as is the ranked match service.
|
// 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 => {
|
void sessions.get({ gameSessionId, token }).then(session => {
|
||||||
socket.data.playerId = session.playerId ?? session.gameSessionId;
|
socket.data.playerId = session.playerId ?? session.gameSessionId;
|
||||||
next();
|
next();
|
||||||
}, () => next(new Error('INVALID_SESSION')));
|
}, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE')));
|
||||||
});
|
});
|
||||||
namespace.on('connection', socket => {
|
namespace.on('connection', socket => {
|
||||||
const before = count; publish();
|
const before = count; publish();
|
||||||
@@ -25,7 +25,10 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
|
|||||||
if (checking) return;
|
if (checking) return;
|
||||||
checking = true;
|
checking = true;
|
||||||
const { gameSessionId, token } = socket.handshake.auth;
|
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);
|
}, PRESENCE_AUTH_REFRESH_MS);
|
||||||
timer.unref();
|
timer.unref();
|
||||||
socket.on('disconnect', () => { clearInterval(timer); publish(); });
|
socket.on('disconnect', () => { clearInterval(timer); publish(); });
|
||||||
|
|||||||
+32
-2
@@ -4,12 +4,13 @@ import { createServer } from 'node:http';
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import { io as client, type Socket } from 'socket.io-client';
|
import { io as client, type Socket } from 'socket.io-client';
|
||||||
import { registerPresence } from '../src/server/presence.js';
|
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 () => {
|
test('presence counts authenticated players once across tabs and removes disconnected players', async () => {
|
||||||
const http = createServer(), io = new Server(http);
|
const http = createServer(), io = new Server(http);
|
||||||
registerPresence(io, { async get(auth: { token: string; gameSessionId: string }) {
|
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' };
|
return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' };
|
||||||
} } as unknown as GameSessionStore);
|
} } as unknown as GameSessionStore);
|
||||||
await new Promise<void>(resolve => http.listen(0, '127.0.0.1', resolve));
|
await new Promise<void>(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);
|
const lastA = next(secondTab); a.disconnect(); b.disconnect(); assert.equal(await lastA, 1);
|
||||||
} finally { for (const socket of sockets) socket.disconnect(); await new Promise<void>(resolve => io.close(() => resolve())); }
|
} finally { for (const socket of sockets) socket.disconnect(); await new Promise<void>(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<void>(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<void>(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<string>(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<void>(resolve => io.close(() => resolve())); }
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user