Fix native health polling and isolate file operation traffic

This commit is contained in:
2026-10-04 21:50:29 -04:00
parent 8a31de92fa
commit 81f088739c
22 changed files with 446 additions and 44 deletions
+18
View File
@@ -15,6 +15,24 @@ async function enrolled() {
}
describe('native host enrollment and scoped authentication', () => {
it('reserves health delivery while a file backlog is pending and bounds background batches', async () => {
const api = await enrolled();
api.heartbeat(api.host.id, { ...inventory, capabilities: ['operations.health-lane'] });
const files = Array.from({ length: 30 }, (_, i) => api.queueHostOperation(api.host.id, 'files.read', { path: `file-${i}` }));
const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' });
const inspect = api.queueHostOperation(api.host.id, 'instance.inspect', {});
const mutation = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' });
expect(api.pendingHostOperations(api.host.id, 'health').map(op => op.id)).toEqual([status.id]);
api.reportHostOperation(api.host.id, status.id, { state: 'completed', result: {} });
expect(api.pendingHostOperations(api.host.id, 'health').map(op => op.id)).toEqual([inspect.id]);
expect(api.pendingHostOperations(api.host.id, 'read').map(op => op.id)).toEqual(files.slice(0, 4).map(op => op.id));
expect(api.pendingHostOperations(api.host.id, 'write').map(op => op.id)).toEqual([mutation.id]);
// Rolling upgrades: older agents still receive health through read.
api.heartbeat(api.host.id, inventory);
files.forEach(file => api.reportHostOperation(api.host.id, file.id, { state: 'completed', result: {} }));
expect(api.pendingHostOperations(api.host.id, 'read').map(op => op.id)).toEqual([inspect.id]);
});
it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => {
const api = await enrolled();
const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {});
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
async function setup() {
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Observation host' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1 });
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO servers(name,komodo_name) VALUES('test','')").run();
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES('instance','test',?,'library','image',28960,?)`).run(host.id, new Date().toISOString());
const native = await import('../../src/lib/server/backends/native.js');
const instance = native.nativeInstance('test')!;
return { ...hosts, ...native, db: db(), host, instance };
}
describe('overlapping native observations', () => {
it.each(['files.read', 'files.list', 'files.stat', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => {
const api = await setup();
const requests = Array.from({ length: 4 }, () => api.nativeRequest(api.instance, kind, { path: 'same' }));
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(1);
api.reportHostOperation(api.host.id, operations[0].id, { state: 'completed', result: { value: 'first' } });
expect(await Promise.all(requests)).toEqual(Array(4).fill({ value: 'first' }));
const fresh = api.nativeRequest(api.instance, kind, { path: 'same' });
const next = api.listHostOperations(api.host.id)[0];
expect(next.id).not.toBe(operations[0].id);
api.reportHostOperation(api.host.id, next.id, { state: 'completed', result: { value: 'changed' } });
expect(await fresh).toEqual({ value: 'changed' });
});
it('does not merge different paths, mutations, or reads across lifecycle submissions', async () => {
const api = await setup();
const requests = [api.nativeRequest(api.instance, 'files.read', { path: 'one' }),
api.nativeRequest(api.instance, 'files.read', { path: 'two' }),
api.nativeRequest(api.instance, 'rcon.execute', { command: 'map_restart' }),
api.nativeRequest(api.instance, 'rcon.execute', { command: 'map_restart' })];
api.db.prepare("INSERT INTO restart_rollout(server,requested_at) VALUES('test',?)").run(new Date().toISOString());
requests.push(api.nativeRequest(api.instance, 'files.read', { path: 'one' }));
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(5);
for (const operation of operations) api.reportHostOperation(api.host.id, operation.id, { state: 'completed', result: {} });
await Promise.all(requests);
});
it('releases failed observations so the next request can recover', async () => {
const api = await setup();
const first = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
const rejected = expect(first).rejects.toThrow('unavailable');
api.reportHostOperation(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'failed', error: 'unavailable' });
await rejected;
const next = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
expect(api.listHostOperations(api.host.id)).toHaveLength(2);
api.reportHostOperation(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'completed', result: {} });
await next;
});
it('keeps replacement generations separate and rejects the old result', async () => {
const api = await setup();
const old = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
const rejected = expect(old).rejects.toThrow('Instance was replaced');
api.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run();
const current = api.nativeRequest(api.nativeInstance('test')!, 'files.read', { path: 'one' });
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(2);
for (const operation of operations) api.reportHostOperation(api.host.id, operation.id, {
state: 'completed', result: { generation: operation.generation }
});
await rejected;
expect(await current).toEqual({ generation: 2 });
});
});
+24
View File
@@ -5,6 +5,7 @@ import { komodoConfigured, rconConfigured } from '../../src/lib/server/config.js
import { getServer, type ServerRow } from '../../src/lib/server/fleet/servers.js';
import { latestGameSubmission } from '../../src/lib/server/fleet/lifecycle-evidence.js';
import { rconGetPlayerCount, rconPlutoniumVersion, rconStatusResult } from '../../src/lib/server/integrations/rcon.js';
import { nativeInstance, nativeRequest } from '../../src/lib/server/backends/native.js';
vi.mock('node:dgram', () => ({ createSocket: vi.fn() }));
vi.mock('../../src/lib/server/config.js', () => ({
@@ -14,6 +15,7 @@ vi.mock('../../src/lib/server/config.js', () => ({
vi.mock('../../src/lib/server/fleet/servers.js', () => ({ getServer: vi.fn(), setServerField: vi.fn() }));
vi.mock('../../src/lib/server/fleet/lifecycle-evidence.js', () => ({ latestGameSubmission: vi.fn() }));
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoResolveGamePort: vi.fn() }));
vi.mock('../../src/lib/server/backends/native.js', () => ({ nativeInstance: vi.fn(), nativeRequest: vi.fn() }));
class FakeSocket extends EventEmitter {
send = vi.fn();
@@ -47,6 +49,28 @@ afterEach(() => {
});
describe('RCON status transport', () => {
it.each([
['timeout', 'timeout', 'Agent received no reply'],
['unreachable', 'send-error', 'Agent could not reach'],
['unconfigured', 'no-config', 'Agent RCON password'],
['authentication', 'bad-reply', 'wrong RCON password'],
['invalid-reply', 'bad-reply', 'Agent RCON reply is missing']
])('preserves the native agent %s diagnosis', async (agentReason, reason, detail) => {
server.native_instance_id = 'instance';
vi.mocked(nativeInstance).mockReturnValue({ id: 'instance', generation: 1, host_id: 'host', port: 28960 } as never);
vi.mocked(nativeRequest).mockResolvedValue({ ok: false, reason: agentReason, text: '' });
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason, detail: expect.stringContaining(detail) });
expect(nativeRequest).toHaveBeenCalledOnce();
expect(sockets).toHaveLength(0);
});
it('uses the successful native reply after agent-side recovery', async () => {
server.native_instance_id = 'instance';
vi.mocked(nativeInstance).mockReturnValue({ id: 'instance', generation: 1, host_id: 'host', port: 28960 } as never);
vi.mocked(nativeRequest).mockResolvedValue({ ok: true, text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n' });
expect(await rconStatusResult(server.name)).toMatchObject({ ok: true, status: { players: 0, map: 'mp_kino' } });
});
it('shares overlapping health and occupancy probes and probes again after completion', async () => {
const health = rconStatusResult(server.name);
const occupancy = rconGetPlayerCount(server.name);
+10 -1
View File
@@ -30,13 +30,22 @@ describe('shared server observation', () => {
expect(fallback).toMatchObject({ status_source: 'rcon', health: 'online', last_players: 4, last_seen_at: at(40_000) });
});
it.each(['starting', 'restarting', 'unhealthy', 'exited', 'unknown'])('telemetry cannot override container state %s', state => {
it.each(['starting', 'restarting', 'unhealthy', 'exited'])('telemetry cannot override container state %s', state => {
const selected = selectServerObservation(server({ health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline', container_state: state }), report(), now);
expect(selected.health).not.toBe('online');
expect(selected.last_players).toBeNull();
expect(selected.roster).toBeNull();
});
it('uses fresh RCON with unknown container state, but does not substitute telemetry or extend freshness', () => {
const raw = server({ container_state: 'unknown', container_checked_at: at(-60_000) });
expect(selectServerObservation(raw, report(), now)).toMatchObject({ health: 'online', last_players: 4, status_source: 'rcon' });
expect(selectServerObservation(raw, report(46_000), now + 46_000).health).toBe('stale');
expect(selectServerObservation(raw, report(121_000), now + 121_000).health).toBe('offline');
expect(selectServerObservation({ ...raw, telemetry_after: at() }, report(), now).health).toBe('offline');
expect(selectServerObservation({ ...raw, last_seen_at: null }, report(), now).health).toBe('offline');
});
it('requires fresh container evidence and reports after the latest lifecycle submission', () => {
expect(selectServerObservation(server({ container_checked_at: at(-46_000) }), report(), now).health).not.toBe('online');
expect(selectServerObservation(server({ health: 'provisioning' }), report(), now).health).toBe('provisioning');
+25 -1
View File
@@ -379,7 +379,7 @@ describe('database fleet and container readiness', () => {
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_error: null });
});
it.each(['unhealthy', 'exited', undefined])('does not poll RCON with container state %s', async (state) => {
it.each(['unhealthy', 'exited', 'stopped', 'missing'])('does not poll RCON with container state %s', async (state) => {
const { inspect, rcon, fanoutStatus } = await setup();
inspect.mockResolvedValue({ ok: false, state, reason: 'not ready' });
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
@@ -389,6 +389,30 @@ describe('database fleet and container readiness', () => {
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'offline', last_players: null });
});
it.each(['unknown', undefined, 'throw'])('automatically recovers through RCON when inspection returns %s', async state => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
if (state === 'throw') inspect.mockRejectedValue(new Error('Inspection timed out'));
else inspect.mockResolvedValue({ ok: false, state, reason: 'Inspection timed out' });
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
const events: unknown[] = [];
const unsubscribe = subscribe(event => events.push(event));
try { await fanoutStatus(); } finally { unsubscribe(); }
expect(rcon).toHaveBeenCalledOnce();
expect(getServer('managed')?.container_state).toBe('unknown');
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_players: 2, status_source: 'rcon' });
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
health: 'online', players: 2, containerState: 'unknown'
})] }));
rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'timeout', detail: 'no reply' });
await fanoutStatus();
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'stale', last_error: 'timeout:no reply' });
});
it('waits for an EC restart request to settle before trusting the old healthy container', async () => {
const { db, inspect, rcon, fanoutStatus, getServer } = await setup();
db.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('managed', ?)").run(new Date().toISOString());
+35
View File
@@ -68,6 +68,41 @@ async function sustainedFailure() {
}
describe('Watchdog detection and ownership', () => {
it('scans files every five minutes while retaining minute health checks and immediate manual scans', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const scan = vi.spyOn(drift, 'prepareDriftCampaign');
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(1);
for (let minute = 1; minute < 5; minute++) {
advance(watchdog.WATCHDOG_SCAN_MS);
await watchdog.watchdogTick();
}
expect(scan).toHaveBeenCalledTimes(1);
expect(komodo.komodoInspectHealthy).toHaveBeenCalledTimes(5);
advance(watchdog.WATCHDOG_SCAN_MS);
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(2);
await watchdog.watchdogTick(true);
expect(scan).toHaveBeenCalledTimes(3);
advance(watchdog.WATCHDOG_SCAN_MS);
database.db().prepare('UPDATE servers SET created_at=? WHERE name=?').run(new Date().toISOString(), server);
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(4);
scan.mockRestore();
});
it('retries failed file scans on the next health scan instead of retaining successful freshness', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const scan = vi.spyOn(drift, 'prepareDriftCampaign').mockRejectedValueOnce(new Error('Agent unavailable'));
await watchdog.watchdogTick();
expect(watchdog.watchdogOverview().scanError).toContain('Agent unavailable');
advance(watchdog.WATCHDOG_SCAN_MS);
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(2);
expect(watchdog.watchdogOverview().scanError).toBeNull();
scan.mockRestore();
});
it('defaults to automatic recovery and debounces manual scans without accelerating the failure window', async () => {
expect(watchdog.watchdogOverview().enabled).toBe(true);
down();