diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js index a55d727..eb43e8a 100644 --- a/server/model/links/links.model.js +++ b/server/model/links/links.model.js @@ -20,6 +20,7 @@ const core = require('../../core') const db = require('./links.db') const engagement = require('../../engagement/emit') const servers = require('../servers/servers.model') +const serversDb = require('../servers/servers.db') const sidecar = require('../../sidecarClient') const log = core.logger('links') @@ -213,7 +214,18 @@ async function redeem({ code, userId }) { const settled = asked.find((result) => result.ok || result.reason === 'taken') if (settled) return settled - const rest = await Promise.all(others.map((server) => confirmOne({ server, code, userId }))) + // **Not the ones this site already knows are down** (F6, option b of the step-2 + // walk). In parallel is not enough on its own: while any server is down, a code + // no issuer holds — every made-up one — still waited out that server's whole + // timeout, twelve seconds on both rigs. A server the poll has seen go down cannot + // answer, and it is not an issuer, so it cannot hold the code: it counts as + // `offline` without the wait. A server with no state yet is asked. + const down = await knownDown() + const reachable = others.filter((server) => !down.has(String(server.id))) + const rest = [ + ...(await Promise.all(reachable.map((server) => confirmOne({ server, code, userId })))), + ...others.filter((server) => down.has(String(server.id))).map(() => ({ ok: false, reason: 'offline' })), + ] const late = rest.find((result) => result.ok || result.reason === 'taken') if (late) return late @@ -224,6 +236,20 @@ async function redeem({ code, userId }) { return { ok: false, reason: 'rejected' } } +/** + * The servers the board poll last saw without a connected game, by id. A failed + * read is an empty set — everybody is asked, which is slow and never wrong. + */ +async function knownDown() { + try { + const states = await serversDb.listState() + return new Set(states.filter((state) => !Number(state.online)).map((state) => String(state.serverId))) + } catch (err) { + log.warn('could not read server state for the link fleet', { error: err.message }) + return new Set() + } +} + /** Remove a link the caller owns. False when they did not hold it. */ async function unlinkOwned(steamId, userId) { const removed = (await db.removeOwned(steamId, userId)) > 0 diff --git a/server/permSync.js b/server/permSync.js index 375fe84..b64270b 100644 --- a/server/permSync.js +++ b/server/permSync.js @@ -146,6 +146,13 @@ function reasonToSync({ desiredHash, sync, state, force }) { // the world is up, and the sync goes on the next tick. A human's "sync now" is // not held: they asked, and a failure then is theirs to read. if (state && state.worldReady === false) return null + // Nor while the game is not connected (F7, the step-2 walk). The stored hello is + // the LAST one: between a restart and the poll that reads the new boot's hello, + // it still says the old world is ready. On Carbon a due audit went out in that + // gap, 80 s before "Server startup complete". The poll marks the server offline + // the moment it goes away, so offline holds until a fresh hello says otherwise. + // Unknown (`online` absent) is not held — a state row always carries it. + if (state && state.online !== undefined && state.online !== null && !Number(state.online)) return null if (!sync) return 'first' if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) { return null diff --git a/server/test/links.test.js b/server/test/links.test.js index db28ef1..1eb427b 100644 --- a/server/test/links.test.js +++ b/server/test/links.test.js @@ -63,9 +63,13 @@ function withCore({ select = [], onInsert = null } = {}) { * `issuers` are the servers the site saw mint a link code in the last few * minutes (`account.link.requested`, F5) — none by default, which is also what a * code typed before its frame was ingested looks like. + * + * `down` are the servers the board poll last saw without a connected game + * (`online: 0`); every other server has no state row, which is asked like an up one. */ -function fleetOf(replies, issuers = []) { +function fleetOf(replies, issuers = [], down = []) { const servers = require('../model/servers/servers.model') + const serversDb = require('../model/servers/servers.db') const sidecar = require('../sidecarClient') const linksDb = require('../model/links/links.db') @@ -73,6 +77,7 @@ function fleetOf(replies, issuers = []) { const ids = Object.keys(replies) servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' })) + serversDb.listState = async () => down.map((serverId) => ({ serverId, online: 0 })) linksDb.recentLinkIssuers = async () => [...issuers] sidecar.confirmLink = async (server, code) => { @@ -375,3 +380,52 @@ test('the fleet is asked in parallel, not one dead server after another (F6)', a assert.equal(result.ok, true) assert.equal(most, 5, 'every server was being asked at once') }) + +test('a server the poll saw go down is not waited on for a code nobody minted (F6, option b)', async () => { + // The step-2 walk: with one rig stopped, a made-up code took twelve seconds on + // both frameworks — in parallel, but still one whole timeout on the dead server. + withCore() + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkRefused, b: unreachable, c: linkRefused }, [], ['b']) + + assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected') + assert.deepEqual(asked.map((x) => x.server).sort(), ['a', 'c'], 'the server known down was not asked') +}) + +test('an issuer is asked even when the poll saw it go down, so a good code stays "unsure"', async () => { + // Skipping is for the servers that cannot hold the code. A server that minted + // one in the window might — and a player whose code is on it must not be told + // the code is wrong. + withCore() + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: unreachable, b: linkRefused }, ['a'], ['a']) + + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'unsure') + assert.ok(asked.some((x) => x.server === 'a'), 'the down issuer was still asked') +}) + +test('a fleet the poll saw all go down is offline, without asking any of it', async () => { + withCore() + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: unreachable, b: unreachable }, [], ['a', 'b']) + + assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'offline') + assert.equal(asked.length, 0) +}) + +test('a state read that fails asks everybody, which is slow and never wrong', async () => { + withCore() + const links = require('../model/links/links.model') + const serversDb = require('../model/servers/servers.db') + + const asked = fleetOf({ a: linkRefused, b: linkRefused }) + serversDb.listState = async () => { + throw new Error('pool closed') + } + + assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected') + assert.equal(asked.length, 2) +}) diff --git a/server/test/planFixesStep2.test.js b/server/test/planFixesStep2.test.js index 0f1d232..3297459 100644 --- a/server/test/planFixesStep2.test.js +++ b/server/test/planFixesStep2.test.js @@ -6,7 +6,8 @@ // • F13/F14 — a zone that expired in the game is recorded `expired` by core, // through `ctx.events.expired` (D170, D183) // • F8 — a plugin that loads with permissions re-syncs within a tick (D184) -// • F7 — nothing is pushed to a game whose world is still loading +// • F7 — nothing is pushed to a game whose world is still loading, or +// that the poll saw go away (the step-2 walk) // • D182 — the ZoneManager helper's state reaches the servers page const test = require('node:test') @@ -97,6 +98,30 @@ test('no permission sync goes to a world that is still loading — unless a huma assert.equal(at({ worldReady: false }, true), 'requested') }) +test('no permission sync goes to a game the poll saw go away, however ready its last hello said it was', () => { + // The step-2 walk, on Carbon: the sidecar came back and a due audit went out in + // the same moment, 80 s before "Server startup complete". The stored hello was the + // OLD boot's (same boot id, hence "audit" and not "restart", worldReady true); + // only `online: 0` — written by the poll when the server went away — said the + // game was not there. + withCore() + const permSync = require('../permSync') + const stale = new Date(Date.now() - permSync.AUDIT_MS - 1000) + const sync = { state: 'ok', dirty: false, syncedHash: 'h1', bootId: 'boot-1', wipeId: 'w-1', lastAttemptAt: stale } + const at = (state, force = false) => permSync.reasonToSync({ desiredHash: 'h1', sync, state, force }) + + assert.equal(at({ online: 0, bootId: 'boot-1', wipeId: 'w-1', worldReady: true }), null) + assert.equal(at({ online: false, bootId: 'boot-1', wipeId: 'w-1', worldReady: true }), null) + // Back, with the new boot's hello still loading: held by worldReady, as before. + assert.equal(at({ online: 1, bootId: 'boot-2', wipeId: 'w-1', worldReady: false }), null) + // Loaded: the restart sync goes. + assert.equal(at({ online: 1, bootId: 'boot-2', wipeId: 'w-1', worldReady: true }), 'restart') + // The audit itself still runs for a server that is up. + assert.equal(at({ online: 1, bootId: 'boot-1', wipeId: 'w-1', worldReady: true }), 'audit') + // A human's "sync now" is not held. + assert.equal(at({ online: 0 }, true), 'requested') +}) + test('no title push goes to a world that is still loading', async () => { withCore() const titleSync = require('../titleSync')