// ── The lifecycle hooks ─────────────────────────────────────────────────── // // `register()` may not touch the database (MODULE_API.md §2.2). This file is // where everything it could not do goes. // // core schema → this module's schema fragment → onBoot(ctx) → the listener binds // // So by the time `onBoot` runs the tables exist, core's settings are seeded, and // nothing is serving traffic yet. // // **`onBoot` has no timeout.** Shutdown races the process being killed; boot does // not. A slow `onBoot` delays the listener, which is the promise above rather // than a problem to be timed out. // // **If `onBoot` throws, the module is `startup_failed` and the site still comes // up.** Its routes stay mounted but answer 503, because a module that failed to // warm up serving half-initialised data is worse than one that says it is down. // There is then NO `onShutdown` — being handed a half-built world to tear down is // worse than not closing cleanly. Which is why the poll below catches everything: // a sidecar that is not there yet is the ordinary state of a fresh install, and // letting that fail the boot would make installing the module before installing // the bridge impossible. // // ── Polling, in phase 1 ─────────────────────────────────────────────────── // // This is a poll, and the live feed it will become is a later phase's work. The // poll is not a placeholder for it: a sidecar's store-backed reads are exactly // what answers while a game server is off, and the module will keep reading them // on an interval to notice a server that went away without saying anything. // What the feed adds is latency, not coverage. const core = require('./core') const db = require('./model/servers/servers.db') const servers = require('./model/servers/servers.model') const sidecar = require('./sidecarClient') const log = core.logger('boot') let refreshTimer = null const REFRESH_MS = 30 * 1000 /** * Ask every configured sidecar how its server is doing, and store what it said. * * **Every server is polled independently and one failure never stops the * others.** `Promise.allSettled`, not `Promise.all`: six servers behind one * unreachable host would otherwise mean the whole fleet stops updating because * one of them does, and the site would report five healthy servers offline. */ async function refresh() { let rows try { rows = await servers.listForPolling() } catch (err) { log.warn('could not read the server list', { error: err.message }) return } await Promise.allSettled(rows.map(refreshOne)) } async function refreshOne(server) { try { const board = await sidecar.serverBoard(server) // Three outcomes, and collapsing any two of them loses something an operator // needs: // // • the sidecar answered with a frame → the server has connected at least once // • the sidecar answered 204 (`empty`) → the sidecar is up and the game never connected // • the sidecar did not answer → the bridge is unreachable // // The middle case is the one that is easy to lose. It is a fresh install // whose plugin is not loaded yet, and reporting it as unreachable sends the // operator to look at the network instead of at the game server. if (!board.ok) { await db.putState({ serverId: server.id, reachable: false, online: false }) return } const frame = board.data if (!frame) { await db.putState({ serverId: server.id, reachable: true, online: false }) return } await db.putState({ serverId: server.id, reachable: true, // A stored `server.hello` means the game connected; whether it is connected // NOW is a different question, and `/health` is what answers it. The board // alone cannot say, which is why `online` is not simply `true` here — it is // decided by freshness in the model, from `updated_at`. online: true, players: Number(frame.players) || 0, maxPlayers: Number(frame.maxPlayers) || 0, hostname: frame.hostname || null, level: frame.level || null, seed: frame.seed === undefined ? null : Number(frame.seed), worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize), bootId: frame.bootId || null, saveCreatedAt: frame.saveCreatedAt || null, protocol: frame.protocol === undefined ? null : Number(frame.protocol), raw: frame, }) } catch (err) { // A failure here is one server's, and it must not reach `Promise.allSettled` // as a rejection that hides which one. Log with the id and carry on. log.warn('could not refresh a server', { server: server.id, error: err.message }) } } /** * Runs once, after the schema and before the listener binds. * * Receives the same frozen `ctx` `register()` was given — not a second object * built to look like it — so a module that only needs core at boot time can skip * `core.init` entirely and use this argument. */ async function onBoot() { await refresh() refreshTimer = setInterval(refresh, REFRESH_MS) // Node keeps the process alive for a pending timer. Core's own intervals are // unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a // thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL. if (typeof refreshTimer.unref === 'function') refreshTimer.unref() log.info('booted', { refreshMs: REFRESH_MS }) } /** * Runs on SIGINT/SIGTERM, before core closes anything of its own. * * The database pool, the push dispatcher and the SSE fan-out are all still open, * because flushing through them is the only thing this hook is for. There is a * five-second budget per module, after which the hook is abandoned — abandoned * rather than cancelled, since nothing can stop a promise that is still running. */ async function onShutdown() { if (refreshTimer) clearInterval(refreshTimer) refreshTimer = null log.info('shut down') } module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }