fix: clean production launcher startup listeners (#2885)

This commit is contained in:
Neil 2026-05-26 20:24:42 -07:00 committed by GitHub
parent 9d396e115c
commit fe7042b948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 99 additions and 16 deletions

View File

@ -7,6 +7,15 @@ import { startDaemon, type DaemonHandle } from './daemon-main'
import { DaemonClient } from './client'
import type { SubprocessHandle } from './session'
const { forkMock } = vi.hoisted(() => ({
forkMock: vi.fn()
}))
vi.mock('child_process', async () => {
const actual = await vi.importActual<Record<string, unknown>>('child_process')
return { ...actual, fork: forkMock }
})
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'prod-launcher-test-'))
}
@ -41,6 +50,7 @@ describe('createProductionLauncher', () => {
for (const h of handles) {
await h.shutdown().catch(() => {})
}
forkMock.mockReset()
rmSync(dir, { recursive: true, force: true })
})
@ -75,4 +85,51 @@ describe('createProductionLauncher', () => {
await handle.shutdown()
handles.pop()
})
it('removes startup child listeners after readiness', async () => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
error: [],
exit: []
}
const child = {
pid: 12345,
killed: false,
on: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event]?.push(cb)
return child
}),
off: vi.fn((event: string, cb: (arg?: unknown) => void) => {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return child
}),
kill: vi.fn(),
disconnect: vi.fn(),
unref: vi.fn()
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
handlers.message[0]?.({ type: 'ready' })
const handle = await launch
expect(handle.shutdown).toEqual(expect.any(Function))
expect(handlers.message).toHaveLength(0)
expect(handlers.error).toHaveLength(0)
expect(handlers.exit).toHaveLength(0)
expect(child.disconnect).toHaveBeenCalled()
expect(child.unref).toHaveBeenCalled()
})
})
function socketPathFor(dir: string): string {
return join(dir, 'test.sock')
}
function tokenPathFor(dir: string): string {
return join(dir, 'test.token')
}

View File

@ -32,27 +32,53 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
function waitForReady(child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill('SIGTERM')
reject(new Error('Daemon failed to signal readiness within timeout'))
}, READY_TIMEOUT_MS)
child.on('message', (msg: unknown) => {
if (msg && typeof msg === 'object' && (msg as Record<string, unknown>).type === 'ready') {
let timeout: ReturnType<typeof setTimeout> | undefined
let settled = false
function cleanupStartupListeners(): void {
if (timeout) {
clearTimeout(timeout)
}
child.off('message', onMessage)
child.off('error', onError)
child.off('exit', onExit)
}
function fail(error: Error, killChild = false): void {
if (settled) {
return
}
settled = true
cleanupStartupListeners()
if (killChild) {
child.kill('SIGTERM')
}
reject(error)
}
function onMessage(msg: unknown): void {
if (msg && typeof msg === 'object' && (msg as Record<string, unknown>).type === 'ready') {
if (settled) {
return
}
settled = true
// Why: the daemon is detached after readiness, so startup listeners
// must not keep the child process closure alive for the daemon lifetime.
cleanupStartupListeners()
resolve()
}
})
}
function onError(err: Error): void {
fail(new Error(`Daemon process error: ${err.message}`))
}
function onExit(code: number | null): void {
fail(new Error(`Daemon process exited prematurely with code ${code}`))
}
child.on('error', (err) => {
clearTimeout(timeout)
reject(new Error(`Daemon process error: ${err.message}`))
})
timeout = setTimeout(() => {
fail(new Error('Daemon failed to signal readiness within timeout'), true)
}, READY_TIMEOUT_MS)
child.on('exit', (code) => {
clearTimeout(timeout)
reject(new Error(`Daemon process exited prematurely with code ${code}`))
})
child.on('message', onMessage)
child.on('error', onError)
child.on('exit', onExit)
})
}