Files
hermes-desktop/tests/server/gateway-respawn.test.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EventEmitter } from 'events'
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
class FakeChild extends EventEmitter {
pid: number
killSignals: string[] = []
constructor(pid: number) {
super()
this.pid = pid
}
unref() { /* no-op */ }
kill(signal?: string) {
this.killSignals.push(signal || 'SIGTERM')
return true
}
}
let fakeChildren: FakeChild[] = []
vi.mock('../../packages/server/src/services/hermes/hermes-process', () => ({
resolveHermesInvocation: (bin: string) => ({ command: bin, argsPrefix: [] }),
execHermesWithBin: vi.fn(),
execHermes: vi.fn(),
spawnHermesWithBin: vi.fn(() => {
const pid = 10000 + fakeChildren.length
const child = new FakeChild(pid)
fakeChildren.push(child)
return child
}),
spawnHermes: vi.fn(),
resolveHermesBin: () => 'hermes',
}))
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.resetModules()
process.env = { ...originalEnv }
fakeChildren = []
})
describe('gateway-runner supervision', () => {
it('respawns the gateway when the spawned child dies unexpectedly', async () => {
vi.useFakeTimers()
vi.resetModules()
const { startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
const first = startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/fake-a' })
expect(first.pid).toBe(10000)
expect(fakeChildren).toHaveLength(1)
// Simulate unexpected exit
fakeChildren[0].emit('exit', 1, null)
// No respawn yet — timer is pending
expect(fakeChildren).toHaveLength(1)
await vi.advanceTimersByTimeAsync(2500)
// A new spawn should have been issued
expect(fakeChildren.length).toBeGreaterThanOrEqual(2)
const newPid = fakeChildren[fakeChildren.length - 1].pid
expect(newPid).not.toBe(10000)
})
it('stops respawning after three consecutive quick failures', async () => {
vi.useFakeTimers()
vi.resetModules()
const { startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/flapping' })
expect(fakeChildren).toHaveLength(1)
for (let i = 0; i < 3; i += 1) {
fakeChildren[fakeChildren.length - 1].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
}
expect(fakeChildren).toHaveLength(4)
fakeChildren[fakeChildren.length - 1].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(5000)
expect(fakeChildren).toHaveLength(4)
})
it('resets the respawn limit after a stable run', async () => {
vi.useFakeTimers()
vi.resetModules()
const { startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/recovered' })
fakeChildren[0].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
fakeChildren[1].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
expect(fakeChildren).toHaveLength(3)
await vi.advanceTimersByTimeAsync(31000)
fakeChildren[2].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
expect(fakeChildren).toHaveLength(4)
fakeChildren[3].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
fakeChildren[4].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
expect(fakeChildren).toHaveLength(6)
fakeChildren[5].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(5000)
expect(fakeChildren).toHaveLength(6)
})
it('cancels a pending respawn when a fresh start is issued for the same profile (the /restart case)', async () => {
vi.useFakeTimers()
vi.resetModules()
const { startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
// First start: a gateway is running
const first = startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/fake-b' })
expect(first.pid).toBe(10000)
// Simulate `/restart`: old gateway dies, then a new start happens before
// the respawn timer fires.
fakeChildren[0].emit('exit', 1, null)
expect(fakeChildren).toHaveLength(1) // respawn not yet fired
// New start (the `/restart` second phase) for the same profile. The
// pending respawn timer should be cancelled.
const second = startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/fake-b' })
expect(second.pid).toBe(10001)
expect(fakeChildren).toHaveLength(2)
// Now advance timers past the original respawn delay. The cancelled
// respawn must NOT fire — otherwise we'd have a third gateway racing
// with the second.
await vi.advanceTimersByTimeAsync(5000)
expect(fakeChildren).toHaveLength(2)
})
it('tracks respawns independently per profile', async () => {
vi.useFakeTimers()
vi.resetModules()
const { startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/profile-x' })
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/profile-y' })
expect(fakeChildren).toHaveLength(2)
// profile-x's gateway dies — only its respawn timer should fire
fakeChildren[0].emit('exit', 1, null)
await vi.advanceTimersByTimeAsync(2500)
// profile-x was respawned (3rd child), profile-y was untouched
expect(fakeChildren).toHaveLength(3)
expect(fakeChildren[2].pid).toBe(10002)
})
it('does not respawn if a new start for the same profile has already replaced the dead child', async () => {
vi.useFakeTimers()
vi.resetModules()
const { startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
const first = startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/fake-c' })
fakeChildren[0].emit('exit', 0, 'SIGTERM')
// Issue a fresh start BEFORE the respawn timer fires — this clears the
// pending respawn. The old child's exit handler then becomes a no-op
// because `state.current?.pid !== oldPid` after the new start.
const second = startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/fake-c' })
expect(second.pid).not.toBe(first.pid)
// Now the original (now-orphaned) exit listener fires after the timer
// window. It should detect the mismatch and not schedule another spawn.
await vi.advanceTimersByTimeAsync(5000)
// Exactly two children total: the original, and the replacement
expect(fakeChildren).toHaveLength(2)
})
it('retires one managed profile gateway without scheduling a respawn', async () => {
vi.useFakeTimers()
vi.resetModules()
const { retireManagedGatewayForProfile, startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/delete-me' })
expect(fakeChildren).toHaveLength(1)
// 固定 platform:停止网关的手段随平台不同(POSIX 走 child.kill('SIGTERM'),
// Windows 走 taskkill 杀进程树),不固定的话这个用例会随运行平台变形。
// Windows 分支由同文件末尾的 "kills the managed gateway process tree on
// Windows shutdown" 单独覆盖。
const retired = retireManagedGatewayForProfile('/tmp/delete-me', { timeoutMs: 5000, platform: 'linux' })
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
fakeChildren[0].emit('exit', 0, 'SIGTERM')
await expect(retired).resolves.toEqual({ signaled: 1, forced: 0, errors: 0 })
await vi.advanceTimersByTimeAsync(6000)
expect(fakeChildren).toHaveLength(1)
})
it('stops managed gateways on shutdown without respawning them', async () => {
vi.useFakeTimers()
vi.resetModules()
const { shutdownManagedGateways, startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-a' })
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-b' })
expect(fakeChildren).toHaveLength(2)
const shutdown = shutdownManagedGateways({ timeoutMs: 5000, platform: 'linux' })
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
expect(fakeChildren[1].killSignals).toEqual(['SIGTERM'])
fakeChildren[0].emit('exit', 0, 'SIGTERM')
fakeChildren[1].emit('exit', 0, 'SIGTERM')
await expect(shutdown).resolves.toEqual({ signaled: 2, forced: 0, errors: 0 })
await vi.advanceTimersByTimeAsync(6000)
expect(fakeChildren).toHaveLength(2)
})
it('cancels pending respawn timers during managed gateway shutdown', async () => {
vi.useFakeTimers()
vi.resetModules()
const { shutdownManagedGateways, startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-c' })
fakeChildren[0].emit('exit', 1, null)
const result = await shutdownManagedGateways({ timeoutMs: 5000 })
expect(result).toEqual({ signaled: 0, forced: 0, errors: 0 })
await vi.advanceTimersByTimeAsync(6000)
expect(fakeChildren).toHaveLength(1)
})
it('forces managed gateway shutdown when children do not exit', async () => {
vi.useFakeTimers()
vi.resetModules()
const { shutdownManagedGateways, startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-d' })
const shutdown = shutdownManagedGateways({ timeoutMs: 1000, platform: 'linux' })
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
await vi.advanceTimersByTimeAsync(1000)
await expect(shutdown).resolves.toEqual({ signaled: 1, forced: 1, errors: 0 })
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM', 'SIGKILL'])
})
it('kills the managed gateway process tree on Windows shutdown', async () => {
vi.resetModules()
const killWindowsProcessTree = vi.fn().mockResolvedValue(undefined)
const { shutdownManagedGateways, startGatewayRunManaged } = await import(
'../../packages/server/src/services/hermes/gateway-runner'
)
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-win' })
await expect(shutdownManagedGateways({
platform: 'win32',
killWindowsProcessTree,
})).resolves.toEqual({ signaled: 1, forced: 1, errors: 0 })
expect(killWindowsProcessTree).toHaveBeenCalledWith(10000)
expect(fakeChildren[0].killSignals).toEqual([])
})
})