fix(subprocess): clean managed processes on host exit

This commit is contained in:
pku-xht
2026-08-11 15:59:43 +08:00
parent b6cd817aca
commit ebe932e24c
18 changed files with 728 additions and 38 deletions
@@ -21,6 +21,77 @@ function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): Su
}
describe('LocalSubprocessService', () => {
it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => {
const before = new Set(process.listeners('exit'))
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const listener = process.listeners('exit').find(candidate => !before.has(candidate))
expect(listener).toBeTypeOf('function')
let finishExit!: () => void
const exited = new Promise<void>((resolve) => { finishExit = resolve })
const terminate = vi.fn()
const terminateForHostExit = vi.fn()
const live = (ctx.subprocess as unknown as {
live: Set<{
done: Promise<{ exitCode: number; signal: null }>
terminate(): void
terminateForHostExit(): void
waitForExit(): Promise<boolean>
}>
}).live
live.add({
done: Promise.resolve({ exitCode: 0, signal: null }),
terminate,
terminateForHostExit,
waitForExit: async () => { await exited; return true },
})
let disposed = false
const disposing = fiber.dispose().then(() => { disposed = true })
await new Promise(resolve => setImmediate(resolve))
expect(disposed).toBe(false)
expect(live.size).toBe(1)
listener?.(0)
expect(terminate).toHaveBeenCalledOnce()
expect(terminateForHostExit).toHaveBeenCalledOnce()
finishExit()
await disposing
expect(live.size).toBe(0)
expect(process.listeners('exit')).not.toContain(listener)
})
it('contains each host-exit termination failure and continues with the other targets', async () => {
const before = new Set(process.listeners('exit'))
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const listener = process.listeners('exit').find(candidate => !before.has(candidate))
expect(listener).toBeTypeOf('function')
const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') })
const ordinarySuccess = vi.fn()
const terminalFailure = vi.fn(() => { throw new Error('terminal failed') })
const terminalSuccess = vi.fn()
const service = ctx.subprocess as unknown as {
live: Set<{ terminateForHostExit(): void }>
terminals: Set<{ terminateForHostExit(): void }>
}
service.live.add({ terminateForHostExit: ordinaryFailure })
service.live.add({ terminateForHostExit: ordinarySuccess })
service.terminals.add({ terminateForHostExit: terminalFailure })
service.terminals.add({ terminateForHostExit: terminalSuccess })
expect(() => { listener?.(0) }).not.toThrow()
expect(ordinaryFailure).toHaveBeenCalledOnce()
expect(ordinarySuccess).toHaveBeenCalledOnce()
expect(terminalFailure).toHaveBeenCalledOnce()
expect(terminalSuccess).toHaveBeenCalledOnce()
service.live.clear()
service.terminals.clear()
await fiber.dispose()
})
it('resolves absolute and PATH executables and honors lookup cancellation', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
@@ -177,6 +248,30 @@ describe('LocalSubprocessService', () => {
expect(disposalErrors).toEqual([failure])
})
it('force-terminates remaining targets before releasing a failed disposal', async () => {
const before = new Set(process.listeners('exit'))
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const listener = process.listeners('exit').find(candidate => !before.has(candidate))
expect(listener).toBeTypeOf('function')
const failure = new Error('cleanup failed')
const terminateForHostExit = vi.fn(() => {
expect(process.listeners('exit')).toContain(listener)
})
const terminal = {
terminate: vi.fn(async () => { throw failure }),
terminateForHostExit,
}
const terminals = (ctx.subprocess as unknown as { terminals: Set<typeof terminal> }).terminals
terminals.add(terminal)
await fiber.dispose()
expect(terminateForHostExit).toHaveBeenCalledOnce()
expect(terminals.size).toBe(0)
expect(process.listeners('exit')).not.toContain(listener)
})
it('releases a terminal after top-level exit reaches quiescence', async () => {
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
const inspector = {