Tell clients on an old game revision how to update

After a revision upgrade, a lite runner from an old checkout fails login
with an opaque "Client out of date" and its scripts only report "No game
client is attached".

- Engine: on a revision mismatch, read the username from the login block
  (laid out the same across revisions) and record it for 30 minutes; a
  correct-revision login clears it. The management port serves it at
  /outdated-client.
- Gateway: when an SDK connects to a bot with no live client, ask the
  engine and answer sdk_error with the update steps. Old SDK checkouts
  print gateway errors, so this reaches people who haven't pulled yet.
- Public /api/version. The lite runner checks it before loading the
  cache and fails with OutdatedClientError (a LoginError, code 6) naming
  the fix; the runner and swarm exit 3 instead of retrying.
- CLAUDE.md troubleshooting entry for the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-09 12:53:12 -04:00
1 parent 3b2be2bc2d
commit 93cd70b347
10 files changed
+171 -4

No files matched your search

+2
View File
@@ -343,6 +343,8 @@ wiki/
**"No game client is attached" / "No state received"** - No client is publishing state for the bot. Start the lite client (`cd server/webclient && bun src/lite/runner.ts {username}`) or open the bot's browser URL, then rerun.
**"Client out of date" / "the server now runs revision N" / lite runner exits with code 3** - The game server was upgraded to a new game revision. Update your checkout (`git pull`, then `cd server/webclient && bun install`) and restart the lite runner. Browser clients only need a page reload.
**Script stalls** - Check for open dialogs (`state.dialog.isOpen`). Level-ups block everything.
**"Can't reach"** - Path is blocked. Try walking closer first, or find a different target.
+12
View File
@@ -53,6 +53,18 @@ survival) is described in the project memory; this file is the human-readable ch
overload instead of sprinting through the whole backlog at max speed.
Verify: `grep -n "start - this.tickRate \* 2" src/engine/World.ts`
### Old-client guidance (rs-sdk)
- [ ] **Outdated-client record** — `World.ts` login: on a revision mismatch, `noteOutdatedClient()`
decodes the rest of the login block (identical across revisions) for the username and keeps
`{revision, at}` in `World.outdatedClients` (30 min TTL); a correct-revision login clears it.
Management port `/outdated-client?username=` exposes it (port 8898, not published by fly).
Gateway `sdk_connect`: when the bot has no live client, it asks that endpoint and answers
`sdk_error` naming the fix — old SDK checkouts print gateway errors, so this is the one hint
that reaches people who haven't pulled. Public `/api/version` (`web/pages/api.ts`) returns
`{revision}`; the lite runner (`session.ts`) checks it first and throws `OutdatedClientError`
(a `LoginError` code 6) — runner/swarm exit **3** so supervisors stop restarting.
Verify: `curl localhost:8888/api/version` and `curl 'localhost:8898/outdated-client?username=x'`.
### Database
- [ ] **Bun sqlite dialect** — `src/db/dialect/BunSqliteDialect*.ts` (3 files) + runtime chooser
in `src/db/query.ts` (`typeof Bun !== 'undefined'` → bun:sqlite, else upstream's
+29
View File
@@ -201,6 +201,9 @@ class World {
loginAddressAttempts: TTLCache<string, number> = new TTLCache({ ttl: 60000 });
loginDeviceAttempts: TTLCache<string, number> = new TTLCache({ ttl: 15000 });
// rs-sdk: logins refused for an old client revision, by safe username. The gateway reads this
// (management /outdated-client) to tell that bot's SDK scripts to update their checkout.
outdatedClients: TTLCache<string, { revision: number; at: number }> = new TTLCache({ ttl: 30 * 60_000, max: 10_000 });
constructor() {
this.loginThread.on('message', msg => {
@@ -2100,6 +2103,30 @@ class World {
});
}
// rs-sdk: an old client's login block is laid out like ours (274 and 289 differ only in the
// revision), so read its username: the gateway can then tell that bot's scripts to update.
private noteOutdatedClient(revision: number): void {
try {
World.loginBuf.pos += 1 + 9 * 4; // info, archive crcs
World.loginBuf.rsadec(priv);
if (World.loginBuf.g1() !== 10) {
return;
}
World.loginBuf.pos += 4 * 4 + 4; // isaac seed, uid
const username = World.loginBuf.gjstr();
if (username.length < 1 || username.length > 12) {
return;
}
const name = toSafeName(username);
if (!this.outdatedClients.has(name)) {
printInfo(`Refused login for ${name}: client revision ${revision}, server ${Environment.engine.revision}`);
}
this.outdatedClients.set(name, { revision, at: Date.now() });
} catch {
// unreadable login block: nothing to record
}
}
rebootTimer(duration: number): void {
this.shutdownTick = this.currentTick + duration;
@@ -2462,6 +2489,7 @@ class World {
rev = World.loginBuf.g2();
}
if (rev !== Environment.engine.revision) {
this.noteOutdatedClient(rev);
client.send(Uint8Array.from([6]));
client.close();
return;
@@ -2503,6 +2531,7 @@ class World {
const uid = World.loginBuf.g4s();
const username = World.loginBuf.gjstr();
const password = World.loginBuf.gjstr();
this.outdatedClients.delete(toSafeName(username));
if (Environment.node.production && Environment.node.rateLimitDeviceLogin > 0) {
const last = this.loginDeviceAttempts.get(`${uid}@${client.remoteAddress}`);
+12 -1
View File
@@ -2,12 +2,13 @@ import { handleMarket } from './pages/market.js';
import { register } from 'prom-client';
import Environment from '#/util/Environment.js';
import World from '#/engine/World.js';
import { toSafeName } from '#/util/JString.js';
import { handleClientPage, handleCacheEndpoints } from './pages/client.js';
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresRunitePage } from './pages/hiscores.js';
import { handleViewerAssets } from './hiscoresServer.js';
import { handleSpriteRequest } from './sprites/SpriteRenderer.js';
import { handleScreenshotsListPage, handleScreenshotFilePage } from './pages/screenshots.js';
import { handleScreenshotUpload, handleExportCollisionApi } from './pages/api.js';
import { handleScreenshotUpload, handleExportCollisionApi, handleVersionApi } from './pages/api.js';
import { handleBugReport } from './pages/bug-report.js';
import { handleDisclaimerPage, handleMapviewPage, handlePublicFiles } from './pages/static.js';
import { WebSocketData, handleWebSocketUpgrade, handleGatewayEndpointGet, websocketHandlers } from './websocket.js';
@@ -294,6 +295,9 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis
const exportCollisionResponse = handleExportCollisionApi(req, url);
if (exportCollisionResponse) return exportCollisionResponse;
const versionResponse = handleVersionApi(url);
if (versionResponse) return versionResponse;
// Hiscores
const hiscoresResponse = await handleHiscoresPage(url);
if (hiscoresResponse) return hiscoresResponse;
@@ -348,6 +352,13 @@ export async function startManagementWeb() {
}),
// rolling per-phase cycle timings over the last ~300 ticks
'/tickstats': () => Response.json({ ...World.getTickStats(), web: getWebStats() }),
// rs-sdk: the gateway asks whether a bot's recent login was refused for an old client
// revision, so it can tell that bot's SDK scripts to update (management port only)
'/outdated-client': (req: Request) => {
const username = new URL(req.url).searchParams.get('username') ?? '';
const outdated = username ? (World.outdatedClients.get(toSafeName(username)) ?? null) : null;
return Response.json({ serverRevision: Environment.engine.revision, outdated });
},
// sample the main thread with JSC's sampling profiler for ?ms= (default 3000, max 15000)
// at ?interval= microseconds (default 250). Covers everything on the event loop:
// world cycles, websocket I/O callbacks, timers.
+9
View File
@@ -8,6 +8,7 @@ import * as rsmod from '#/engine/routefinder/index.js';
import { CollisionFlag, LocLayer } from '#/engine/routefinder/index.js';
import LocType from '#/cache/config/LocType.js';
import Packet from '#/io/Packet.js';
import Environment from '#/util/Environment.js';
export async function handleScreenshotUpload(req: Request, url: URL): Promise<Response | null> {
if (url.pathname !== '/api/screenshot' || req.method !== 'POST') {
@@ -38,6 +39,14 @@ export async function handleScreenshotUpload(req: Request, url: URL): Promise<Re
// naturally invalidates the cache.
let collisionExportCache: string | null = null;
// rs-sdk: lets clients outside the browser (the lite runner) check they speak this server's revision
export function handleVersionApi(url: URL): Response | null {
if (url.pathname !== '/api/version') {
return null;
}
return Response.json({ revision: Environment.engine.revision });
}
export function handleExportCollisionApi(req: Request, url: URL): Response | null {
if (url.pathname !== '/api/exportCollision') {
return null;
+43
View File
@@ -133,6 +133,33 @@ async function authenticateSDK(username: string, password: string): Promise<{ su
});
}
// ============ Outdated Client Check ============
// The engine records logins it refused for an old client revision, on its management port (not
// published). When an SDK connects to a bot with no client attached, ask whether that's why, so
// scripts on an old checkout are told to update instead of waiting on a client that can't log in.
const ENGINE_MANAGEMENT_URL = process.env.ENGINE_MANAGEMENT_URL || `http://127.0.0.1:${process.env.WEB_MANAGEMENT_PORT || 8898}`;
type OutdatedClient = { revision: number; serverRevision: number };
async function lookupOutdatedClient(username: string): Promise<OutdatedClient | null> {
try {
const res = await fetch(`${ENGINE_MANAGEMENT_URL}/outdated-client?username=${encodeURIComponent(username)}`, { signal: AbortSignal.timeout(1000) });
if (!res.ok) return null;
const body = await res.json() as { serverRevision?: number; outdated?: { revision: number } | null };
if (!body.outdated || typeof body.serverRevision !== 'number') return null;
return { revision: body.outdated.revision, serverRevision: body.serverRevision };
} catch {
return null; // no engine on this host (private fleet gateway), or it's busy: skip the hint
}
}
function outdatedClientMessage(username: string, outdated: OutdatedClient): string {
return `Bot "${username}" can't log in: its headless client is revision ${outdated.revision}, but the server now runs revision ${outdated.serverRevision}. ` +
`Update your rs-sdk checkout (git pull, then cd server/webclient && bun install) and restart the client: ` +
`cd server/webclient && bun src/lite/runner.ts ${username}. Browser bots (the /bot page) update by reloading.`;
}
// ============ Types ============
/**
@@ -501,8 +528,14 @@ const SyncModule = {
// Authenticate via login server (if enabled)
sdkConnecting.add(ws);
let authResult: { success: boolean; error?: string };
let outdated: OutdatedClient | null = null;
try {
authResult = await authenticateSDK(targetUsername, message.password || '');
// A bot with no client attached may be stuck on an old client revision
const bot = botSessions.get(targetUsername);
if (authResult.success && (!bot || getSessionStatus(bot) === 'dead')) {
outdated = await lookupOutdatedClient(targetUsername);
}
} finally {
sdkConnecting.delete(ws);
}
@@ -516,6 +549,16 @@ const SyncModule = {
return;
}
if (outdated) {
console.log(`[Gateway] SDK connect refused: ${targetUsername}'s client is revision ${outdated.revision} (server ${outdated.serverRevision})`);
ws.send(JSON.stringify({
type: 'sdk_error',
error: outdatedClientMessage(targetUsername, outdated)
}));
ws.close();
return;
}
const sessionId = newSDKSessionId();
const session: SDKSession = { ws, sessionId, sdkClientId, targetUsername, mode };
sdkSessions.set(sessionId, session);
@@ -10,7 +10,7 @@ import Isaac from '#/io/Isaac.js';
import JString from '#/datastruct/JString.js';
import Packet from '#/io/Packet.js';
const CLIENT_VERSION = 289;
export const CLIENT_VERSION = 289;
/** Defaults match webclient/bundle.ts, which bakes these in at build time. */
const DEFAULT_RSA_MODULUS =
@@ -61,6 +61,25 @@ export class LoginError extends Error {
}
}
/**
* The server runs a different game revision than this checkout: retrying can't help. A login
* response 6 ("client out of date") with the fix spelled out, so existing LoginError handling
* still applies.
*/
export class OutdatedClientError extends LoginError {
constructor(
readonly origin: string,
readonly serverRevision: number | null
) {
super(
6,
`This lite client is revision ${CLIENT_VERSION}, but ${origin} runs ${serverRevision === null ? 'a different revision' : `revision ${serverRevision}`}. ` +
'The game server was upgraded: update this checkout (git pull, then cd server/webclient && bun install) and start the client again.'
);
this.name = 'OutdatedClientError';
}
}
/**
* A logged-in game socket: the two ISAAC-keyed packet buffers plus the raw
* stream. Packet framing is the caller's job (see PacketReader) because the
+8
View File
@@ -12,6 +12,7 @@
import { fileURLToPath } from 'node:url';
import { OutdatedClientError } from './net/GameConnection.js';
import { startSession, type LiteSession } from './session.js';
import { type ActionResult } from '#/bot/ActionExecutor.js';
import { BotActionQueue, type QueuedBotAction } from '#/bot/ActionQueue.js';
@@ -404,6 +405,13 @@ const session = await startSession({
password: env.PASSWORD!,
profanityFilter: ['false', '0', 'off', 'no'].includes(profanityRaw) ? false : undefined,
quiet: true
}).catch((err: unknown) => {
// A revision mismatch won't fix itself: exit 3 so a supervisor can stop restarting us
if (err instanceof OutdatedClientError) {
console.error(`[lite-runner] ${err.message}`);
process.exit(3);
}
throw err;
});
console.log(`[lite-runner] '${env.BOT_USERNAME}' logged into ${host}`);
+25 -2
View File
@@ -15,7 +15,7 @@
import './dom-shim.js';
import { invalidateCache, loadCache } from './cache.js';
import { GameConnection, LoginError } from './net/GameConnection.js';
import { CLIENT_VERSION, GameConnection, LoginError, OutdatedClientError } from './net/GameConnection.js';
import { LiteClient } from './LiteClient.js';
import { LocIndex } from './world/LocIndex.js';
import { ClientProt } from '#/io/ClientProt.js';
@@ -83,6 +83,20 @@ export interface LiteSession {
stopped: Promise<SessionEnd>;
}
/** The server's game revision from /api/version, or null if it doesn't say (older server, network). */
async function fetchServerRevision(origin: string): Promise<number | null> {
try {
const res = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return null;
}
const body = (await res.json()) as { revision?: unknown };
return typeof body.revision === 'number' ? body.revision : null;
} catch {
return null;
}
}
export async function startSession(opts: SessionOptions): Promise<LiteSession> {
if (opts.profanityFilter !== undefined) {
WordFilter.enabled = opts.profanityFilter;
@@ -91,6 +105,13 @@ export async function startSession(opts: SessionOptions): Promise<LiteSession> {
const origin = `${secured ? 'https' : 'http'}://${opts.host}`;
const cacheDir = opts.cacheDir ?? `${process.env.HOME}/.cache/rs-sdk-lite`;
// Fail with the fix, not a decode error or an opaque login response 6, when the server
// has moved to another revision. Servers without /api/version skip the check.
const serverRevision = await fetchServerRevision(origin);
if (serverRevision !== null && serverRevision !== CLIENT_VERSION) {
throw new OutdatedClientError(origin, serverRevision);
}
let { checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet });
let locIndex = await LocIndex.load({ origin, cacheDir, versionlistCrc: checksums[5], quiet: opts.quiet });
@@ -118,7 +139,9 @@ export async function startSession(opts: SessionOptions): Promise<LiteSession> {
invalidateCache();
({ checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet }));
if (checksums.every((crc, i) => crc === stale[i])) {
throw err;
// still refused with fresh CRCs: the revision is the likely cause unless the
// server confirmed it matches
throw serverRevision === null ? new OutdatedClientError(origin, null) : err;
}
console.error(`[lite] ${opts.username}: stale archive CRCs after server redeploy, re-fetched /crc and retrying login`);
locIndex = await LocIndex.load({ origin, cacheDir, versionlistCrc: checksums[5], quiet: opts.quiet });
+11
View File
@@ -28,6 +28,7 @@
import { fileURLToPath } from 'node:url';
import { OutdatedClientError } from './net/GameConnection.js';
import { startSession, type LiteSession, type SessionEnd } from './session.js';
import { BotStateCollector } from '#/bot/StateCollector.js';
import { ActionExecutor } from '#/bot/ActionExecutor.js';
@@ -386,6 +387,14 @@ function onSessionEnd(bot: PickpocketBot, end: SessionEnd): void {
void relogin(bot);
}
/** Every bot would hit the same revision mismatch, and retrying can't fix it. */
function exitIfOutdated(e: unknown): void {
if (e instanceof OutdatedClientError) {
console.error(`[swarm] ${e.message}`);
process.exit(3);
}
}
/** Retry forever: the world may be restarting, and a bot is no use logged out. */
async function relogin(bot: PickpocketBot): Promise<void> {
let delay = RELOGIN_MS;
@@ -400,6 +409,7 @@ async function relogin(bot: PickpocketBot): Promise<void> {
console.log(`[swarm] ${bot.name} back online (${bot.stats.relogins} re-logins so far)`);
return;
} catch (e) {
exitIfOutdated(e);
console.error(`[swarm] ${bot.name} re-login failed (${e}); retrying in ${Math.round(delay / 1000)}s`);
delay = Math.min(delay * 2, RELOGIN_MAX_MS);
}
@@ -414,6 +424,7 @@ for (const name of names) {
await login(bot);
console.log(`[swarm] ${name} logged in${bot.isCollector ? ' (COLLECTOR)' : ''}`);
} catch (e) {
exitIfOutdated(e);
console.error(`[swarm] ${name} failed to log in: ${e}`);
void relogin(bot); // keep trying in the background rather than losing the bot
}