refactor(subprocess): share terminal lifecycle
This commit is contained in:
125
packages/subprocess/subprocess/tests/terminal-lifecycle.spec.ts
Normal file
125
packages/subprocess/subprocess/tests/terminal-lifecycle.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user