import { describe, expect, it, vi } from 'vitest' import { createLogger } from '../src/log/logger.js' import { createBackoff } from '../src/transport/backoff.js' import { STABLE_RUN_MS, superviseFrpc, type FrpcChild, type SpawnFrpc, } from '../src/transport/frpSupervise.js' const silentLogger = createLogger('error', () => {}) /** * A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies, * firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock). * exit/kill fire the handler at most once. */ function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } { let onExit: ((code: number | null) => void) | null = null let alive = true const state = { killed: false } const fire = (code: number | null): void => { if (!alive) return alive = false onExit?.(code) } return { child: { onExit: (cb) => { onExit = cb }, isAlive: () => alive, kill: () => { state.killed = true fire(null) }, }, exit: (code) => fire(code), get killed() { return state.killed }, } } describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => { it('spawns the frpc binary with -c via the child seam', async () => { const spawn: SpawnFrpc = vi.fn(() => makeChild().child) superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', { spawn, sleep: async () => {}, logger: silentLogger, }) await Promise.resolve() expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml') }) it('restarts the child on exit, backing off 1s → 2s → 4s', async () => { const children = [makeChild(), makeChild(), makeChild(), makeChild()] let n = 0 const spawn: SpawnFrpc = () => children[n++]!.child const sleeps: number[] = [] // now() fixed so no run counts as "stable" ⇒ backoff monotonically increases. const handle = superviseFrpc('/frpc', '/toml', { spawn, backoff: createBackoff(), sleep: async (ms) => { sleeps.push(ms) }, logger: silentLogger, now: () => 1000, }) // Crash three times; each crash schedules the next spawn after the growing backoff. for (let i = 0; i < 3; i += 1) { children[i]!.exit(1) await Promise.resolve() await Promise.resolve() } expect(sleeps).toEqual([1000, 2000, 4000]) await handle.stop() }) it('resets the backoff after a run that stayed up past the stability window', async () => { const children = [makeChild(), makeChild(), makeChild()] let n = 0 const spawn: SpawnFrpc = () => children[n++]!.child const sleeps: number[] = [] let clock = 0 const handle = superviseFrpc('/frpc', '/toml', { spawn, backoff: createBackoff(), sleep: async (ms) => { sleeps.push(ms) }, logger: silentLogger, now: () => clock, }) // First run crashes instantly ⇒ backoff 1s. children[0]!.exit(1) await Promise.resolve() await Promise.resolve() // Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s). clock += STABLE_RUN_MS + 1 children[1]!.exit(1) await Promise.resolve() await Promise.resolve() expect(sleeps).toEqual([1000, 1000]) await handle.stop() }) it('stop() halts the loop and kills the live child; done resolves 0', async () => { const c = makeChild() const spawn: SpawnFrpc = () => c.child const handle = superviseFrpc('/frpc', '/toml', { spawn, sleep: async () => {}, logger: silentLogger, }) await Promise.resolve() expect(handle.isChildAlive()).toBe(true) // stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks. const stopping = handle.stop() c.exit(null) await expect(stopping).resolves.toBeUndefined() await expect(handle.done).resolves.toBe(0) expect(c.killed).toBe(true) }) it('does not restart after stop (no spawn past shutdown)', async () => { const first = makeChild() let n = 0 const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child)) const handle = superviseFrpc('/frpc', '/toml', { spawn, sleep: async () => {}, logger: silentLogger, }) await Promise.resolve() const stopping = handle.stop() first.exit(0) await stopping expect(spawn).toHaveBeenCalledTimes(1) }) })