process-shutdown.spec.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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('exits once after graceful disposal resolves or rejects', async () => {
  21. const resolvedExit = vi.fn()
  22. const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
  23. await resolved.shutdown(0)
  24. expect(resolvedExit).toHaveBeenCalledOnce()
  25. expect(resolvedExit).toHaveBeenCalledWith(0)
  26. const rejectedExit = vi.fn()
  27. const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
  28. await rejected.shutdown(1)
  29. expect(rejectedExit).toHaveBeenCalledOnce()
  30. expect(rejectedExit).toHaveBeenCalledWith(1)
  31. })
  32. it('uses process.exit as the default process boundary', async () => {
  33. const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
  34. const shutdown = createProcessShutdown(() => Promise.resolve())
  35. await shutdown.shutdown(7)
  36. expect(exit).toHaveBeenCalledOnce()
  37. expect(exit).toHaveBeenCalledWith(7)
  38. })
  39. it('forces exit when graceful disposal reaches its bound', async () => {
  40. vi.useFakeTimers()
  41. const disposal = deferred()
  42. const exit = vi.fn()
  43. const shutdown = createProcessShutdown(() => disposal.promise, exit)
  44. const pending = shutdown.shutdown(0)
  45. await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
  46. expect(exit).not.toHaveBeenCalled()
  47. await vi.advanceTimersByTimeAsync(1)
  48. expect(exit).toHaveBeenCalledOnce()
  49. expect(exit).toHaveBeenCalledWith(0)
  50. disposal.resolve()
  51. await pending
  52. expect(exit).toHaveBeenCalledOnce()
  53. })
  54. it('honors a caller-supplied grace period', async () => {
  55. vi.useFakeTimers()
  56. const disposal = deferred()
  57. const exit = vi.fn()
  58. const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
  59. const pending = shutdown.shutdown(0)
  60. await vi.advanceTimersByTimeAsync(24)
  61. expect(exit).not.toHaveBeenCalled()
  62. await vi.advanceTimersByTimeAsync(1)
  63. expect(exit).toHaveBeenCalledOnce()
  64. disposal.resolve()
  65. await pending
  66. })
  67. it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
  68. const disposal = deferred()
  69. const exit = vi.fn()
  70. const shutdown = createProcessShutdown(() => disposal.promise, exit)
  71. const pending = shutdown.shutdown(0)
  72. shutdown.interrupt(130)
  73. expect(exit).toHaveBeenCalledOnce()
  74. expect(exit).toHaveBeenCalledWith(130)
  75. disposal.resolve()
  76. await pending
  77. expect(exit).toHaveBeenCalledOnce()
  78. })
  79. it('drains on the first signal and forces on the second signal', async () => {
  80. const disposal = deferred()
  81. const dispose = vi.fn(() => disposal.promise)
  82. const exit = vi.fn()
  83. const shutdown = createProcessShutdown(dispose, exit)
  84. shutdown.interrupt(143)
  85. await Promise.resolve()
  86. expect(dispose).toHaveBeenCalledOnce()
  87. expect(exit).not.toHaveBeenCalled()
  88. shutdown.interrupt(130)
  89. expect(exit).toHaveBeenCalledOnce()
  90. expect(exit).toHaveBeenCalledWith(130)
  91. disposal.resolve()
  92. await shutdown.shutdown(0)
  93. expect(exit).toHaveBeenCalledOnce()
  94. })
  95. it('coalesces normal shutdown calls without treating them as escalation', async () => {
  96. const disposal = deferred()
  97. const exit = vi.fn()
  98. const shutdown = createProcessShutdown(() => disposal.promise, exit)
  99. const first = shutdown.shutdown(0)
  100. const second = shutdown.shutdown(1)
  101. expect(second).toBe(first)
  102. expect(exit).not.toHaveBeenCalled()
  103. disposal.resolve()
  104. await first
  105. expect(exit).toHaveBeenCalledOnce()
  106. expect(exit).toHaveBeenCalledWith(0)
  107. })
  108. })