process-shutdown.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import {
  3. createProcessShutdown,
  4. PROCESS_SHUTDOWN_TIMEOUT_MS,
  5. } from '../src/process-shutdown.ts'
  6. function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
  7. let resolve!: () => void
  8. let reject!: (error: Error) => void
  9. const promise = new Promise<void>((accept, fail) => {
  10. resolve = accept
  11. reject = fail
  12. })
  13. return { promise, resolve, reject }
  14. }
  15. afterEach(() => {
  16. vi.useRealTimers()
  17. vi.restoreAllMocks()
  18. })
  19. describe('process shutdown', () => {
  20. it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
  21. const resolvedExit = vi.fn()
  22. const resolvedComplete = vi.fn()
  23. const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
  24. await resolved.shutdown(0)
  25. expect(resolvedComplete).toHaveBeenCalledOnce()
  26. expect(resolvedComplete).toHaveBeenCalledWith(0)
  27. expect(resolvedExit).not.toHaveBeenCalled()
  28. const rejectedExit = vi.fn()
  29. const rejectedComplete = vi.fn()
  30. const rejected = createProcessShutdown(
  31. () => Promise.reject(new Error('dispose failed')),
  32. rejectedExit,
  33. rejectedComplete,
  34. )
  35. await rejected.shutdown(1)
  36. expect(rejectedExit).toHaveBeenCalledOnce()
  37. expect(rejectedExit).toHaveBeenCalledWith(1)
  38. expect(rejectedComplete).not.toHaveBeenCalled()
  39. })
  40. it('uses process.exitCode for default normal completion', async () => {
  41. const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
  42. const originalExitCode = process.exitCode
  43. process.exitCode = undefined
  44. const shutdown = createProcessShutdown(() => Promise.resolve())
  45. try {
  46. await shutdown.shutdown(7)
  47. expect(process.exitCode).toBe(7)
  48. expect(exit).not.toHaveBeenCalled()
  49. } finally {
  50. process.exitCode = originalExitCode
  51. }
  52. })
  53. it('forces exit when graceful disposal reaches its bound', async () => {
  54. vi.useFakeTimers()
  55. const disposal = deferred()
  56. const exit = vi.fn()
  57. const complete = vi.fn()
  58. const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
  59. const pending = shutdown.shutdown(0)
  60. await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
  61. expect(exit).not.toHaveBeenCalled()
  62. await vi.advanceTimersByTimeAsync(1)
  63. expect(exit).toHaveBeenCalledOnce()
  64. expect(exit).toHaveBeenCalledWith(0)
  65. disposal.resolve()
  66. await pending
  67. expect(exit).toHaveBeenCalledOnce()
  68. expect(complete).not.toHaveBeenCalled()
  69. })
  70. it('honors a caller-supplied grace period', async () => {
  71. vi.useFakeTimers()
  72. const disposal = deferred()
  73. const exit = vi.fn()
  74. const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
  75. const pending = shutdown.shutdown(0)
  76. await vi.advanceTimersByTimeAsync(24)
  77. expect(exit).not.toHaveBeenCalled()
  78. await vi.advanceTimersByTimeAsync(1)
  79. expect(exit).toHaveBeenCalledOnce()
  80. disposal.resolve()
  81. await pending
  82. })
  83. it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
  84. const disposal = deferred()
  85. const exit = vi.fn()
  86. const complete = vi.fn()
  87. const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
  88. const pending = shutdown.shutdown(0)
  89. shutdown.interrupt(130)
  90. expect(exit).toHaveBeenCalledOnce()
  91. expect(exit).toHaveBeenCalledWith(130)
  92. disposal.resolve()
  93. await pending
  94. expect(exit).toHaveBeenCalledOnce()
  95. expect(complete).not.toHaveBeenCalled()
  96. })
  97. it('forces exit after disposal started by a signal', async () => {
  98. const disposal = deferred()
  99. const exit = vi.fn()
  100. const complete = vi.fn()
  101. const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
  102. shutdown.interrupt(143)
  103. disposal.resolve()
  104. await shutdown.shutdown(0)
  105. expect(exit).toHaveBeenCalledOnce()
  106. expect(exit).toHaveBeenCalledWith(143)
  107. expect(complete).not.toHaveBeenCalled()
  108. })
  109. it('drains on the first signal and forces on the second signal', async () => {
  110. const disposal = deferred()
  111. const dispose = vi.fn(() => disposal.promise)
  112. const exit = vi.fn()
  113. const shutdown = createProcessShutdown(dispose, exit, vi.fn())
  114. shutdown.interrupt(143)
  115. await Promise.resolve()
  116. expect(dispose).toHaveBeenCalledOnce()
  117. expect(exit).not.toHaveBeenCalled()
  118. shutdown.interrupt(130)
  119. expect(exit).toHaveBeenCalledOnce()
  120. expect(exit).toHaveBeenCalledWith(130)
  121. disposal.resolve()
  122. await shutdown.shutdown(0)
  123. expect(exit).toHaveBeenCalledOnce()
  124. })
  125. it('coalesces normal shutdown calls without treating them as escalation', async () => {
  126. const disposal = deferred()
  127. const exit = vi.fn()
  128. const complete = vi.fn()
  129. const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
  130. const first = shutdown.shutdown(0)
  131. const second = shutdown.shutdown(1)
  132. expect(second).toBe(first)
  133. expect(exit).not.toHaveBeenCalled()
  134. disposal.resolve()
  135. await first
  136. expect(complete).toHaveBeenCalledOnce()
  137. expect(complete).toHaveBeenCalledWith(0)
  138. expect(exit).not.toHaveBeenCalled()
  139. })
  140. it('lets a signal force exit while natural completion drains remaining handles', async () => {
  141. const exit = vi.fn()
  142. const complete = vi.fn()
  143. const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
  144. await shutdown.shutdown(0)
  145. shutdown.interrupt(130)
  146. expect(complete).toHaveBeenCalledOnce()
  147. expect(exit).toHaveBeenCalledOnce()
  148. expect(exit).toHaveBeenCalledWith(130)
  149. })
  150. })