Merge remote-tracking branch 'origin/master' into merge/1829-master

This commit is contained in:
Huanqi Cao
2026-08-10 11:23:08 +08:00
86 changed files with 1026 additions and 370 deletions

View File

@@ -5,54 +5,73 @@ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
/** Process-exit controller shared by normal completion and Unix signal handlers. */
export interface ProcessShutdown {
/** Start or join graceful disposal before exiting with `code`. */
/** Start or join graceful disposal before allowing natural completion with `code`. */
shutdown(code: number): Promise<void>
/** Start graceful disposal, or force exit when a shutdown is already running. */
/** Start graceful disposal followed by exit, or force exit when shutdown is already running. */
interrupt(code: number): void
}
/**
* Create one process-exit controller around an application disposer.
* @param dispose - Whole-application teardown that resolves at quiescence.
* @param exit - Process exit boundary, replaceable by tests.
* @param forceExit - Forced process exit boundary, replaceable by tests.
* @param complete - Natural process completion boundary, replaceable by tests.
* @param timeoutMs - Grace before forced exit, replaceable by tests.
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
*/
export function createProcessShutdown(
dispose: () => Promise<void>,
exit: (code: number) => void = (code) => { process.exit(code) },
forceExit: (code: number) => void = (code) => { process.exit(code) },
complete: (code: number) => void = (code) => { process.exitCode = code },
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
): ProcessShutdown {
let pending: Promise<void> | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let exited = false
let completed = false
let forceExited = false
const exitOnce = (code: number): void => {
if (exited) return
exited = true
const clearExitTimeout = (): void => {
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
if (timeout !== undefined) clearTimeout(timeout)
exit(code)
}
const shutdown = (code: number): Promise<void> => {
const forceExitOnce = (code: number): void => {
if (forceExited) return
forceExited = true
clearExitTimeout()
forceExit(code)
}
const completeOnce = (code: number): void => {
if (completed || forceExited) return
completed = true
clearExitTimeout()
complete(code)
}
const start = (code: number, forceAfterDispose: boolean): Promise<void> => {
if (pending !== undefined) return pending
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
timeout = setTimeout(() => { forceExitOnce(code) }, timeoutMs)
pending = Promise.resolve().then(dispose).then(
() => { exitOnce(code) },
() => { exitOnce(code) },
() => {
if (forceAfterDispose) forceExitOnce(code)
else completeOnce(code)
},
() => { forceExitOnce(code) },
)
return pending
}
return {
shutdown,
shutdown(code) {
return start(code, false)
},
interrupt(code) {
if (pending !== undefined) {
exitOnce(code)
forceExitOnce(code)
return
}
void shutdown(code)
void start(code, true)
},
}
}

View File

@@ -20,35 +20,50 @@ afterEach(() => {
})
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
const resolvedComplete = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
expect(resolvedComplete).toHaveBeenCalledOnce()
expect(resolvedComplete).toHaveBeenCalledWith(0)
expect(resolvedExit).not.toHaveBeenCalled()
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
const rejectedComplete = vi.fn()
const rejected = createProcessShutdown(
() => Promise.reject(new Error('dispose failed')),
rejectedExit,
rejectedComplete,
)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
expect(rejectedComplete).not.toHaveBeenCalled()
})
it('uses process.exit as the default process boundary', async () => {
it('uses process.exitCode for default normal completion', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
const originalExitCode = process.exitCode
process.exitCode = undefined
const shutdown = createProcessShutdown(() => Promise.resolve())
await shutdown.shutdown(7)
try {
await shutdown.shutdown(7)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
expect(process.exitCode).toBe(7)
expect(exit).not.toHaveBeenCalled()
} finally {
process.exitCode = originalExitCode
}
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
@@ -60,13 +75,14 @@ describe('process shutdown', () => {
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
expect(complete).not.toHaveBeenCalled()
})
it('honors a caller-supplied grace period', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(24)
@@ -81,7 +97,8 @@ describe('process shutdown', () => {
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
@@ -91,13 +108,29 @@ describe('process shutdown', () => {
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
expect(complete).not.toHaveBeenCalled()
})
it('forces exit after disposal started by a signal', async () => {
const disposal = deferred()
const exit = vi.fn()
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
shutdown.interrupt(143)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(143)
expect(complete).not.toHaveBeenCalled()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
const shutdown = createProcessShutdown(dispose, exit, vi.fn())
shutdown.interrupt(143)
await Promise.resolve()
@@ -116,7 +149,8 @@ describe('process shutdown', () => {
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
@@ -125,7 +159,21 @@ describe('process shutdown', () => {
disposal.resolve()
await first
expect(complete).toHaveBeenCalledOnce()
expect(complete).toHaveBeenCalledWith(0)
expect(exit).not.toHaveBeenCalled()
})
it('lets a signal force exit while natural completion drains remaining handles', async () => {
const exit = vi.fn()
const complete = vi.fn()
const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
await shutdown.shutdown(0)
shutdown.interrupt(130)
expect(complete).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
expect(exit).toHaveBeenCalledWith(130)
})
})