fix(rust): skip servers known down for a link code, hold syncs while offline (F6, F7) #23

Merged
whitlocktech merged 1 commits from fix/protocol-13-step2-walk into edge 2026-09-27 08:26:45 +00:00
4 changed files with 115 additions and 3 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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')