Fix transient health checks and avoid unnecessary recovery

This commit is contained in:
2026-10-02 13:06:18 -04:00
parent 221c4c10cf
commit 65d3a99846
16 changed files with 842 additions and 110 deletions
+29 -8
View File
@@ -2,6 +2,7 @@ import { createSocket } from 'node:dgram';
import type { Socket } from 'node:dgram';
import { config, komodoConfigured, rconConfigured } from '../config.js';
import { getServer, setServerField } from '../fleet/servers.js';
import { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
import { komodoResolveGamePort } from './komodo.js';
import { parsePlutoniumRevision } from '../../plutonium.js';
@@ -300,13 +301,8 @@ export function parsePlayerRoster(text: string): RconPlayer[] {
}
export async function rconGetPlayerCount(server: string): Promise<number | null> {
const res = await endpointFor(server);
if (!res.endpoint) return null;
const send = await sendRcon(res.endpoint.host, res.endpoint.port, 'status');
if (!send.ok) return null;
const text = stripPrefix(send.buf);
if (!text) return null;
return parsePlayerCount(text);
const result = await rconStatusResult(server);
return result.ok ? result.status.players : null;
}
export interface RconStatus {
@@ -338,6 +334,18 @@ export type RconStatusResult =
};
const RAW_REPLY_LIMIT = 512;
const statusInFlight = new Map<string, Promise<RconStatusResult>>();
/** Retry only this read-only query: commands with effects must never be replayed. */
async function sendStatus(endpoint: RconEndpoint): Promise<SendResult> {
for (let attempt = 0; ; attempt++) {
const result = await sendRcon(endpoint.host, endpoint.port, 'status');
if (result.ok || (result.reason !== 'timeout' && result.reason !== 'send-error') || attempt === 2) {
return result;
}
await new Promise<void>(resolve => setTimeout(resolve, 250 * 2 ** attempt));
}
}
/**
* Render a Buffer as a short hex + printable-ASCII snippet — same layout as
@@ -365,6 +373,10 @@ export function formatRawReply(buf: Buffer): string {
* rather than just "unreachable".
*/
export async function rconStatusResult(server: string): Promise<RconStatusResult> {
// Snapshot before any network work. A probe begun after a restart or a
// replacement registration must not join one observing the old process.
const createdAt = getServer(server)?.created_at;
const epoch = createdAt ? latestGameSubmission(server) : null;
const res = await endpointFor(server);
if (!res.endpoint) {
if (res.reason === 'unknown-server') {
@@ -387,13 +399,22 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
}
const ep = res.endpoint;
const key = JSON.stringify([server, createdAt, epoch, ep.host, ep.port]);
const pending = statusInFlight.get(key);
if (pending) return pending;
const request = probeStatus(ep).finally(() => { statusInFlight.delete(key); });
statusInFlight.set(key, request);
return request;
}
async function probeStatus(ep: RconEndpoint): Promise<RconStatusResult> {
// Plutonium T5 doesn't recognize `getstatus` as an rcon subcommand
// ("Unknown command"), so we use `status` — a print-body response with
// a header table plus one row per player. Q3-derived engines that
// *do* answer statusResponse (via anonymous `getstatus` query) would
// give richer info-line dvars, but every engine we care about supports
// `status` under the rcon wrapper.
const send = await sendRcon(ep.host, ep.port, 'status');
const send = await sendStatus(ep);
if (!send.ok) {
return { ok: false, endpoint: ep, reason: send.reason, detail: send.detail };
}