import { EventEmitter } from 'node:events'; import { createSocket, type Socket } from 'node:dgram'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { 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', () => ({ config: { rcon: { host: 'localhost', password: 'test', timeoutMs: 1_000 } }, rconConfigured: vi.fn() })); 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/backends/native.js', () => ({ nativeInstance: vi.fn(), nativeRequest: vi.fn() })); class FakeSocket extends EventEmitter { send = vi.fn(); close = vi.fn(); } let sockets: FakeSocket[]; let server: ServerRow; const packet = (body: string) => Buffer.concat([Buffer.from([255, 255, 255, 255]), Buffer.from(body)]); const reply = (index = 0) => sockets[index].emit('message', packet('statusResponse\n\\mapname\\mp_kino\n10 45 "joe"')); beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers(); sockets = []; server = { name: 'test-server', created_at: '2026-10-01T00:00:00Z', game_port: 28960, rcon_host: null } as ServerRow; vi.mocked(getServer).mockImplementation(() => ({ ...server })); vi.mocked(latestGameSubmission).mockReturnValue(null); vi.mocked(rconConfigured).mockReturnValue(true); vi.mocked(createSocket).mockImplementation(() => { const socket = new FakeSocket(); sockets.push(socket); return socket as unknown as Socket; }); }); afterEach(() => { expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); }); 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); const duplicate = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); expect(sockets).toHaveLength(1); expect(sockets[0].send.mock.calls[0][0].toString()).toContain('rcon test status\n'); reply(); expect(await health).toMatchObject({ ok: true, status: { players: 1, map: 'mp_kino' } }); expect(await occupancy).toBe(1); expect(await duplicate).toEqual(await health); expect(sockets[0].close).toHaveBeenCalledOnce(); const fresh = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); expect(sockets).toHaveLength(2); reply(1); expect(await fresh).toMatchObject({ ok: true }); }); it('recovers a dropped UDP reply after the first backoff', async () => { const request = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(1_000); expect(sockets).toHaveLength(1); expect(sockets[0].close).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(249); expect(sockets).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(sockets).toHaveLength(2); reply(1); expect(await request).toMatchObject({ ok: true }); }); it('bounds retries at three attempts with 250ms then 500ms backoffs', async () => { const request = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(1_250); expect(sockets).toHaveLength(2); await vi.advanceTimersByTimeAsync(1_499); expect(sockets).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); expect(sockets).toHaveLength(3); await vi.advanceTimersByTimeAsync(1_000); expect(await request).toMatchObject({ ok: false, reason: 'timeout' }); expect(sockets.every(socket => socket.close.mock.calls.length === 1)).toBe(true); const fresh = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); expect(sockets).toHaveLength(4); reply(3); expect(await fresh).toMatchObject({ ok: true }); }); it.each(['event', 'callback'])('retries transient send errors reported through the %s', async source => { const request = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); if (source === 'event') sockets[0].emit('error', new Error('UDP unavailable')); else sockets[0].send.mock.calls[0][5](new Error('UDP unavailable')); await vi.advanceTimersByTimeAsync(250); expect(sockets).toHaveLength(2); reply(1); expect(await request).toMatchObject({ ok: true }); }); it.each([ ['missing prefix', Buffer.from('wrong listener')], ['wrong password', packet('print\nBad rcon_password')], ['unknown dialect', packet('unexpected reply')] ])('does not retry a %s reply', async (_name, response) => { const request = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); sockets[0].emit('message', response); expect(await request).toMatchObject({ ok: false, reason: 'bad-reply' }); expect(sockets).toHaveLength(1); }); it('does not retry missing configuration or endpoint failures', async () => { vi.mocked(rconConfigured).mockReturnValue(false); expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-config' }); server.game_port = null; expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-port' }); vi.mocked(getServer).mockReturnValue(undefined); expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-port' }); expect(sockets).toHaveLength(0); }); it('cleans up a rejected in-flight probe so the next one can proceed', async () => { vi.mocked(createSocket).mockImplementationOnce(() => { throw new Error('Socket initialization failed'); }); await expect(rconStatusResult(server.name)).rejects.toThrow('Socket initialization failed'); const request = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); reply(); expect(await request).toMatchObject({ ok: true }); }); it.each(['restart', 'registration', 'host', 'port', 'server'])('does not share probes across a changed %s', async change => { const original = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); if (change === 'restart') vi.mocked(latestGameSubmission).mockReturnValue('2026-10-02T00:00:00Z'); if (change === 'registration') server.created_at = '2026-10-02T00:00:00Z'; if (change === 'host') server.rcon_host = 'new-host'; if (change === 'port') server.game_port = 28961; if (change === 'server') server.name = 'other-server'; const current = rconStatusResult(server.name); await vi.advanceTimersByTimeAsync(0); expect(sockets).toHaveLength(2); reply(); sockets[1].emit('message', packet('statusResponse\n\\mapname\\mp_ascension\n')); expect(await original).toMatchObject({ ok: true, status: { players: 1 } }); expect(await current).toMatchObject({ ok: true, status: { players: 0 } }); }); it('leaves other RCON commands on their existing single-attempt behavior', async () => { const request = rconPlutoniumVersion(server.name); await vi.advanceTimersByTimeAsync(1_000); expect(await request).toMatchObject({ revision: null, error: 'no reply within 1000ms' }); expect(sockets).toHaveLength(1); }); });