diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95b11597..59fba610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: run: pnpm run build server: - name: Server (typecheck, lint, build) + name: Server (typecheck, test, lint, build) runs-on: ubuntu-latest steps: @@ -120,6 +120,10 @@ jobs: working-directory: server run: pnpm run lint + - name: Test + working-directory: server + run: pnpm test + - name: Typecheck working-directory: server run: pnpm exec tsc --noEmit diff --git a/frontend/components/game/session/useGameSession.ts b/frontend/components/game/session/useGameSession.ts index 6c54293d..e30cd419 100644 --- a/frontend/components/game/session/useGameSession.ts +++ b/frontend/components/game/session/useGameSession.ts @@ -487,7 +487,7 @@ export function useGameSession({ }); }; - socket.onclose = () => { + socket.onclose = (event) => { clearPing(); if ( activeSessionKeyRef.current === connection.sessionKey && @@ -495,10 +495,11 @@ export function useGameSession({ ) { setIsSceneReadyRef.current(false); const previousError = latestStatusRef.current.error; + const closeReason = event.reason.trim(); emitStatusRef.current({ connected: false, connecting: false, - error: previousError || "Conexion cerrada.", + error: closeReason || previousError || "Conexion cerrada.", }); } }; diff --git a/server/Dockerfile b/server/Dockerfile index 2ea03c85..3b35308c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -33,4 +33,4 @@ COPY --from=build /app/mapas_source ./mapas_source EXPOSE 7666 -CMD ["pnpm", "start"] +CMD ["node", "dist/server.js"] diff --git a/server/package.json b/server/package.json index a514ffb8..62336f37 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "clean": "rm -rf dist", "build": "pnpm run clean && tsc && node scripts/copy-assets.cjs", "dev": "NODE_ENV=development tsx watch src/server.ts", + "test": "tsx --test tests/**/*.test.ts", "compact-objs": "tsx src/scripts/compactObjsJson.ts", "compact-npcs": "tsx src/scripts/compactNpcsJson.ts", "export-editable-maps": "tsx src/scripts/exportEditableMaps.ts", diff --git a/server/src/gracefulShutdown.ts b/server/src/gracefulShutdown.ts new file mode 100644 index 00000000..f2974598 --- /dev/null +++ b/server/src/gracefulShutdown.ts @@ -0,0 +1,81 @@ +export type ShutdownSignal = "SIGINT" | "SIGTERM"; + +type GracefulShutdownOptions = { + timeoutMs: number; + stopAcceptingConnections: () => void; + notifyClients: (signal: ShutdownSignal) => void; + resetConnectedCharacters: () => Promise; + closeClients: () => void; + exit: (code: number) => void; + onInfo: (message: string) => void; + onError: (step: string, error: unknown) => void; +}; + +export function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timeoutId); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + +export function createGracefulShutdown(options: GracefulShutdownOptions) { + let started = false; + + async function run(signal: ShutdownSignal): Promise { + if (started) { + return; + } + + started = true; + options.onInfo(`[Servidor] Señal ${signal} recibida. Iniciando apagado ordenado...`); + + try { + options.stopAcceptingConnections(); + } catch (error) { + options.onError("detener nuevas conexiones", error); + } + + try { + options.notifyClients(signal); + } catch (error) { + options.onError("notificar a los clientes", error); + } + + try { + const updated = await withTimeout( + options.resetConnectedCharacters(), + options.timeoutMs, + `La API no respondió en ${options.timeoutMs}ms durante el apagado.`, + ); + + options.onInfo(`[Servidor] Personajes marcados como desconectados al apagar: ${updated}.`); + } catch (error) { + options.onError("desmarcar personajes conectados", error); + } + + try { + options.closeClients(); + } catch (error) { + options.onError("cerrar conexiones de clientes", error); + } finally { + options.exit(0); + } + } + + return { + hasStarted: () => started, + run, + }; +} diff --git a/server/src/server.ts b/server/src/server.ts index 69dc45af..c3800bcb 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -4,6 +4,7 @@ import type { NpcsApi } from "./npcs"; import type { PackageApi, PacketPayload } from "./package"; import type { ProtocolApi } from "./protocol"; import type { SocketApi } from "./socket"; +import { createGracefulShutdown } from "./gracefulShutdown"; import type { RuntimeCharacter, RuntimeCharacters, @@ -30,6 +31,8 @@ const FLOOR_ITEM_SWEEP_INTERVAL_MS = 60 * 60 * 1000; const FLOOR_ITEM_SWEEP_WARNING_MS = 60 * 1000; const FLOOR_ITEM_SWEEP_CHECK_MS = 5000; const DUPLICATE_IP_IDLE_TIMEOUT_MS = 60 * 1000; +const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5000; +const SHUTDOWN_CLIENT_MESSAGE = "El servidor se está reiniciando. Podrás volver a entrar en breve."; function broadcastNpcSnapshot(game: GameApi, handleProtocol: HandleProtocolApi, npc: RuntimeNpc | undefined): void { if (!npc) { @@ -77,6 +80,7 @@ function broadcastCharacterSnapshot( type WSServer = { on: (event: "connection", listener: (client: RuntimeClient, request: RuntimeConnectionRequest) => void) => void; + close: () => void; }; type ServerCharacter = RuntimeCharacter & { @@ -200,6 +204,63 @@ function handleHttpRequest(request: any, response: any) { response.end(JSON.stringify({ error: "Not found" })); } +const gracefulShutdown = createGracefulShutdown({ + timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS, + stopAcceptingConnections() { + vars.serverReady = false; + + if (httpServer.listening) { + httpServer.close(); + } + + wsServer?.close(); + }, + notifyClients(signal) { + handleProtocol.consoleToAll(`[Servidor] ${SHUTDOWN_CLIENT_MESSAGE} (${signal})`, "#E69500", 1, 0); + + for (const client of Object.values(vars.clients as Record)) { + socket.flushClient(client); + } + }, + async resetConnectedCharacters() { + const response = (await funct.fetchUrl("/internal/characters/reset-connected", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: vars.tokenAuth, + }, + })) as { updated?: number }; + + return Number(response.updated ?? 0); + }, + closeClients() { + for (const client of Object.values(vars.clients as Record)) { + if (client && client.readyState === client.OPEN) { + socket.flushClient(client); + client.close(1001, SHUTDOWN_CLIENT_MESSAGE); + } + } + }, + exit(code) { + process.exit(code); + }, + onInfo(message) { + console.log(message); + }, + onError(step, error) { + console.error(`[Servidor] Error al ${step} durante el apagado.`); + funct.dumpError(error); + }, +}); + +process.once("SIGINT", () => { + void gracefulShutdown.run("SIGINT"); +}); + +process.once("SIGTERM", () => { + void gracefulShutdown.run("SIGTERM"); +}); + const PACKET_TYPE_NAMES: Record = { [pkg.serverPacketID.changeHeading]: "heading", [pkg.serverPacketID.click]: "click", @@ -456,6 +517,10 @@ function trackClientActivity(ws: RuntimeClient, packageID: number) { LoadSmeltingRecipes.initialize(), ]); + if (gracefulShutdown.hasStarted()) { + return; + } + vars.serverReady = true; const endInitialize = Date.now() - startInitialize; const textInitializeServer = `[Servidor] Iniciado en ${endInitialize}ms.`; diff --git a/server/src/types/runtime.ts b/server/src/types/runtime.ts index a8388277..caef0124 100644 --- a/server/src/types/runtime.ts +++ b/server/src/types/runtime.ts @@ -501,7 +501,7 @@ export type RuntimeClient = { readyState: number; on: (event: string, listener: (...args: unknown[]) => void) => void; send: (data: unknown) => void; - close: () => void; + close: (code?: number, reason?: string | Buffer) => void; _socket?: { remoteAddress?: string; }; diff --git a/server/tests/gracefulShutdown.test.ts b/server/tests/gracefulShutdown.test.ts new file mode 100644 index 00000000..74f5f13b --- /dev/null +++ b/server/tests/gracefulShutdown.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createGracefulShutdown, type ShutdownSignal } from "../src/gracefulShutdown"; + +type TestOptions = Parameters[0]; + +function createOptions(overrides: Partial = {}): TestOptions { + return { + timeoutMs: 50, + stopAcceptingConnections() {}, + notifyClients(_signal: ShutdownSignal) {}, + async resetConnectedCharacters() { + return 0; + }, + closeClients() {}, + exit(_code: number) {}, + onInfo(_message: string) {}, + onError(_step: string, _error: unknown) {}, + ...overrides, + }; +} + +test("resets characters before closing clients and exiting", async () => { + const events: string[] = []; + const shutdown = createGracefulShutdown( + createOptions({ + stopAcceptingConnections() { + events.push("stop"); + }, + notifyClients(signal) { + events.push(`notify:${signal}`); + }, + async resetConnectedCharacters() { + events.push("reset"); + return 3; + }, + closeClients() { + events.push("close"); + }, + exit(code) { + events.push(`exit:${code}`); + }, + }), + ); + + await shutdown.run("SIGTERM"); + + assert.deepEqual(events, ["stop", "notify:SIGTERM", "reset", "close", "exit:0"]); +}); + +test("exits when the reset API does not respond", async () => { + const errors: string[] = []; + let closeCalls = 0; + let exitCalls = 0; + const shutdown = createGracefulShutdown( + createOptions({ + timeoutMs: 10, + resetConnectedCharacters() { + return new Promise(() => {}); + }, + closeClients() { + closeCalls += 1; + }, + exit() { + exitCalls += 1; + }, + onError(step) { + errors.push(step); + }, + }), + ); + + await shutdown.run("SIGINT"); + + assert.deepEqual(errors, ["desmarcar personajes conectados"]); + assert.equal(closeCalls, 1); + assert.equal(exitCalls, 1); +}); + +test("handles repeated signals only once", async () => { + let resolveReset!: (updated: number) => void; + const resetFinished = new Promise((resolve) => { + resolveReset = resolve; + }); + let resetCalls = 0; + let exitCalls = 0; + const shutdown = createGracefulShutdown( + createOptions({ + resetConnectedCharacters() { + resetCalls += 1; + return resetFinished; + }, + exit() { + exitCalls += 1; + }, + }), + ); + + const firstSignal = shutdown.run("SIGTERM"); + const secondSignal = shutdown.run("SIGINT"); + + resolveReset(1); + await Promise.all([firstSignal, secondSignal]); + + assert.equal(shutdown.hasStarted(), true); + assert.equal(resetCalls, 1); + assert.equal(exitCalls, 1); +});