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
+9 -4
View File
@@ -320,7 +320,7 @@ const HEALTHY_STATES = new Set(['running', 'healthy', 'ok', 'started', 'up']);
async function fetchJsonWithSnippet(
path: string,
body: Record<string, string>
): Promise<{ ok: true; json: unknown; snippet: string } | { ok: false; reason: string; snippet?: string }> {
): Promise<{ ok: true; json: unknown; snippet: string } | { ok: false; reason: string; snippet?: string; unsupportedEndpoint?: boolean }> {
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
let res: Response;
try {
@@ -330,7 +330,11 @@ async function fetchJsonWithSnippet(
}
const text = await res.text().catch(() => '');
if (!res.ok) {
return { ok: false, reason: `HTTP ${res.status}`, snippet: truncate(text) };
const unsupportedEndpoint = res.status === 404 || (
[400, 405, 422, 500, 501].includes(res.status)
&& /\b(?:unknown|unsupported|unrecognized)\s+(?:method|endpoint|variant)\b|\bmethod not found\b/i.test(text)
);
return { ok: false, reason: `HTTP ${res.status}`, snippet: truncate(text), unsupportedEndpoint };
}
let json: unknown;
try {
@@ -382,9 +386,10 @@ export async function komodoInspectHealthy(serverName: string): Promise<KomodoHe
for (const { path, body } of attempts) {
const r = await fetchJsonWithSnippet(path, body);
if (!r.ok) {
// Alternate endpoints handle API-version differences. Retrying an
// outage against every endpoint delays all other server probes.
if (!r.unsupportedEndpoint) return { ok: false, reason: r.reason, snippet: r.snippet };
lastSnippet = r.snippet ?? lastSnippet;
// Try the next endpoint on 404s or unknown-method-shaped errors; a
// real error (auth, timeout) will fail every attempt anyway.
continue;
}
const state = extractContainerState(r.json);
+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 };
}