refactor(subprocess): share terminal lifecycle

This commit is contained in:
Tianyi Cui
2026-07-29 03:17:40 +08:00
parent 32bce339da
commit 65264f6c80
10 changed files with 248 additions and 44 deletions

View File

@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess'
describe('SubprocessTerminalLifecycle', () => {
it('waits for top-level settlement and the provider cleanup transaction', async () => {
const done = Promise.withResolvers<undefined>()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanup = vi.fn(() => cleanupGate.promise)
const lifecycle = new SubprocessTerminalLifecycle({ done: done.promise, cleanup })
const waiting = lifecycle.waitForExit()
expect(cleanup).not.toHaveBeenCalled()
done.resolve(undefined)
await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
const observed = vi.fn()
void waiting.then(observed)
await Promise.resolve()
expect(observed).not.toHaveBeenCalled()
cleanupGate.resolve(undefined)
await expect(waiting).resolves.toBe(true)
lifecycle.terminate()
await expect(lifecycle.waitForExit()).resolves.toBe(true)
expect(cleanup).toHaveBeenCalledOnce()
})
it('normalizes cleanup failures, permits retries, and retains lifetime cancellation until success', async () => {
const done = Promise.withResolvers<undefined>()
const lifetime = new AbortController()
const removeListener = vi.spyOn(lifetime.signal, 'removeEventListener')
const synchronousFailure = new Error('first cleanup failed')
let attempt = 0
const cleanup = vi.fn((): Promise<void> => {
attempt += 1
if (attempt === 1) throw synchronousFailure
if (attempt === 2) {
return Promise.resolve().then(() => {
const nonErrorRejection: unknown = 'cleanup transport gone'
throw nonErrorRejection
})
}
return Promise.resolve()
})
const lifecycle = new SubprocessTerminalLifecycle({
done: done.promise,
cleanup,
signal: lifetime.signal,
})
lifecycle.terminate()
await expect(lifecycle.waitForExit()).rejects.toBe(synchronousFailure)
lifecycle.terminate()
await expect(lifecycle.waitForExit()).rejects.toThrow('cleanup transport gone')
lifetime.abort()
await expect(lifecycle.waitForExit()).resolves.toBe(true)
expect(cleanup).toHaveBeenCalledTimes(3)
expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
done.reject(new Error('top-level transport failed'))
await Promise.resolve()
expect(cleanup).toHaveBeenCalledTimes(3)
})
it('starts cleanup for a pre-aborted lifetime and bounds a wait that is already aborted', async () => {
const cleanupGate = Promise.withResolvers<undefined>()
const cleanup = vi.fn(() => cleanupGate.promise)
const lifecycle = new SubprocessTerminalLifecycle({
done: new Promise(() => {}),
cleanup,
signal: AbortSignal.abort(new Error('lifetime cancelled')),
})
expect(cleanup).toHaveBeenCalledOnce()
await expect(lifecycle.waitForExit(AbortSignal.abort())).resolves.toBe(false)
cleanupGate.resolve(undefined)
await expect(lifecycle.waitForExit()).resolves.toBe(true)
})
it('contains cleanup settlement after an observer aborts between signal checks', async () => {
const firstCleanup = Promise.withResolvers<undefined>()
const cleanup = vi.fn()
.mockImplementationOnce(() => firstCleanup.promise)
.mockResolvedValueOnce(undefined)
const lifecycle = new SubprocessTerminalLifecycle({ done: Promise.resolve(), cleanup })
const observer = new AbortController().signal
vi.spyOn(observer, 'aborted', 'get')
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
await expect(lifecycle.waitForExit(observer)).resolves.toBe(false)
firstCleanup.reject(new Error('late cleanup failure'))
await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
await Promise.resolve()
lifecycle.terminate()
await expect(lifecycle.waitForExit()).resolves.toBe(true)
expect(cleanup).toHaveBeenCalledTimes(2)
})
it('reports bounded cleanup success and failure', async () => {
const successful = new SubprocessTerminalLifecycle({
done: Promise.resolve(),
cleanup: async () => {},
})
await expect(successful.waitForExit(new AbortController().signal)).resolves.toBe(true)
const failure = new Error('quiescence failed')
const failed = new SubprocessTerminalLifecycle({
done: Promise.resolve(),
cleanup: () => Promise.reject(failure),
})
await expect(failed.waitForExit(new AbortController().signal)).rejects.toBe(failure)
const failedDone = Promise.withResolvers<undefined>()
const afterTransportFailure = new SubprocessTerminalLifecycle({
done: failedDone.promise,
cleanup: async () => {},
})
const waiting = afterTransportFailure.waitForExit()
failedDone.reject(new Error('transport failed'))
await expect(waiting).resolves.toBe(true)
})
})