From 263be1df458731c8b27c6a526946cb0a8770b8b3 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 27 Sep 2026 03:14:10 -0500 Subject: [PATCH] fix(rust): skip servers known down for a link code, hold syncs while offline (F6, F7) Two findings of the step-2 player walk (2026-09-27, both rigs). F6, option (b) of the org lead (D186): a code no recent issuer holds - every made-up one - was still asked of every other enabled server, and while any of them was down the redeem waited out its whole timeout (12 s on both rigs). The second pass now skips the servers the board poll last saw without a connected game; they count as offline without the wait. Issuers are still asked whatever their state, so a good code on a down server stays "unsure". Live on the walk core: 338 ms with five servers down, 360 ms with a rig stopped as well. F7 (D187): on Carbon a due audit sync went out the moment the sidecar reconnected, 80 s before "Server startup complete". The worldReady hold reads the stored hello, which is the OLD boot's until the poll reads the new one. reasonToSync now also holds while the stored state says the game is not connected (online 0), which the poll writes the moment the server goes away. titleSync already held on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/model/links/links.model.js | 28 ++++++++++++++- server/permSync.js | 7 ++++ server/test/links.test.js | 56 +++++++++++++++++++++++++++++- server/test/planFixesStep2.test.js | 27 +++++++++++++- 4 files changed, 115 insertions(+), 3 deletions(-) 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')