dispose.spec.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. /**
  2. * Deterministic ladder coverage against a scriptable fake child: each
  3. * escalation tier's timing is driven exactly (the client suite exercises the
  4. * same ladder against real subprocesses end to end).
  5. */
  6. import { EventEmitter } from 'node:events'
  7. import type { ChildProcess } from 'node:child_process'
  8. import { describe, expect, it, vi } from 'vitest'
  9. import { disposeRuntimeProcess } from '../src/dispose.ts'
  10. /** What fells a scripted {@link FakeChild}. */
  11. type LethalTrigger = 'eof' | NodeJS.Signals
  12. /** Per-scenario script for a {@link FakeChild}. */
  13. interface FakeChildScript {
  14. /**
  15. * The one trigger that makes the child exit (SIGKILL always does,
  16. * uncatchable, like a real process). Omitted: only SIGKILL fells it.
  17. */
  18. diesOn?: LethalTrigger
  19. /** Delay (ms) between the lethal trigger and the exit event. */
  20. delayMs?: number
  21. /** Complete the scripted exit inside the triggering call. */
  22. synchronousExit?: boolean
  23. /** `false` models a child spawned without a stdin pipe. */
  24. stdin?: boolean
  25. }
  26. /**
  27. * A scriptable stand-in for a ChildProcess carrying exactly the API the
  28. * ladder reads: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
  29. * `exit` event.
  30. */
  31. class FakeChild extends EventEmitter {
  32. exitCode: number | null = null
  33. signalCode: NodeJS.Signals | null = null
  34. readonly kills: NodeJS.Signals[] = []
  35. stdinEnded = false
  36. readonly stdin: { end: () => void } | null
  37. constructor(private readonly script: FakeChildScript = {}) {
  38. super()
  39. this.stdin = script.stdin === false
  40. ? null
  41. : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
  42. }
  43. kill(signal: NodeJS.Signals): boolean {
  44. this.kills.push(signal)
  45. this.maybeDie(signal)
  46. return true
  47. }
  48. private maybeDie(trigger: LethalTrigger): void {
  49. // SIGKILL is uncatchable — it always fells the child; any other trigger
  50. // only when the scenario scripts it as the lethal one.
  51. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
  52. const exit = (): void => {
  53. if (trigger === 'eof') this.exitCode = 0
  54. else this.signalCode = trigger
  55. this.emit('exit', this.exitCode, this.signalCode)
  56. }
  57. if (this.script.synchronousExit === true) exit()
  58. else setTimeout(exit, this.script.delayMs ?? 0)
  59. }
  60. }
  61. /** The ladder takes a real ChildProcess; the fake carries the read surface. */
  62. function asChild(fake: FakeChild): ChildProcess {
  63. return fake as unknown as ChildProcess
  64. }
  65. describe('disposeRuntimeProcess', () => {
  66. it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
  67. const fake = new FakeChild()
  68. fake.exitCode = 0
  69. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  70. expect(fake.stdinEnded).toBe(false)
  71. expect(fake.kills).toEqual([])
  72. })
  73. it('returns immediately for a child already dead by signal', async () => {
  74. const fake = new FakeChild()
  75. fake.signalCode = 'SIGKILL'
  76. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  77. expect(fake.stdinEnded).toBe(false)
  78. expect(fake.kills).toEqual([])
  79. })
  80. it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
  81. const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
  82. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  83. expect(fake.stdinEnded).toBe(true)
  84. expect(fake.kills).toEqual([])
  85. expect(fake.exitCode).toBe(0)
  86. })
  87. it('recognizes a child that exits synchronously on stdin EOF', async () => {
  88. const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
  89. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  90. expect(fake.exitCode).toBe(0)
  91. expect(fake.listenerCount('exit')).toBe(0)
  92. })
  93. it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
  94. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  95. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  96. expect(fake.stdinEnded).toBe(true)
  97. expect(fake.kills).toEqual(['SIGTERM'])
  98. expect(fake.signalCode).toBe('SIGTERM')
  99. expect(fake.listenerCount('exit')).toBe(0)
  100. })
  101. it('recognizes a child that exits synchronously on SIGTERM', async () => {
  102. const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
  103. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  104. expect(fake.kills).toEqual(['SIGTERM'])
  105. expect(fake.signalCode).toBe('SIGTERM')
  106. expect(fake.listenerCount('exit')).toBe(0)
  107. })
  108. it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
  109. const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
  110. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
  111. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  112. // Quiescence, not a request: at resolution the child has ACTUALLY exited
  113. // (the exit event landed, despite the scripted post-SIGKILL delay).
  114. expect(fake.signalCode).toBe('SIGKILL')
  115. })
  116. it('recognizes a child already gone when the final exit wait begins', async () => {
  117. const fake = new FakeChild({ synchronousExit: true })
  118. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
  119. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  120. expect(fake.signalCode).toBe('SIGKILL')
  121. })
  122. it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
  123. const fake = new FakeChild()
  124. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  125. fake.kills.push(signal)
  126. queueMicrotask(() => {
  127. if (marker === 'exitCode') fake.exitCode = 0
  128. else fake.signalCode = 'SIGTERM'
  129. })
  130. return true
  131. })
  132. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
  133. expect(fake.kills).toEqual(['SIGTERM'])
  134. })
  135. it('walks the ladder for a child spawned without a stdin pipe', async () => {
  136. const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
  137. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  138. expect(fake.kills).toEqual(['SIGTERM'])
  139. })
  140. it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
  141. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  142. await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
  143. expect(fake.kills).toEqual(['SIGKILL'])
  144. expect(fake.signalCode).toBe('SIGKILL')
  145. })
  146. it('propagates a forced-termination error without waiting for the grace', async () => {
  147. const fake = new FakeChild()
  148. const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
  149. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  150. fake.kills.push(signal)
  151. fake.emit('error', failure)
  152. return false
  153. })
  154. await expect(disposeRuntimeProcess(
  155. asChild(fake),
  156. { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
  157. 'win32',
  158. )).rejects.toBe(failure)
  159. expect(fake.kills).toEqual(['SIGKILL'])
  160. expect(fake.listenerCount('error')).toBe(0)
  161. expect(fake.listenerCount('exit')).toBe(0)
  162. })
  163. it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
  164. const fake = new FakeChild()
  165. const failure = new Error('invalid signal state')
  166. vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
  167. await expect(disposeRuntimeProcess(
  168. asChild(fake),
  169. { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
  170. 'win32',
  171. )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
  172. expect(fake.listenerCount('error')).toBe(0)
  173. expect(fake.listenerCount('exit')).toBe(0)
  174. })
  175. it('bounds a refused forced termination that produces no error or exit', async () => {
  176. const fake = new FakeChild()
  177. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  178. fake.kills.push(signal)
  179. return false
  180. })
  181. await expect(disposeRuntimeProcess(
  182. asChild(fake),
  183. { disposeEofGraceMs: 1, disposeGraceMs: 10 },
  184. 'win32',
  185. )).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was refused')
  186. expect(fake.listenerCount('error')).toBe(0)
  187. expect(fake.listenerCount('exit')).toBe(0)
  188. })
  189. it('bounds an accepted forced termination that never reports exit', async () => {
  190. const fake = new FakeChild()
  191. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  192. fake.kills.push(signal)
  193. return true
  194. })
  195. await expect(disposeRuntimeProcess(
  196. asChild(fake),
  197. { disposeEofGraceMs: 1, disposeGraceMs: 10 },
  198. 'win32',
  199. )).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was accepted')
  200. expect(fake.listenerCount('error')).toBe(0)
  201. expect(fake.listenerCount('exit')).toBe(0)
  202. })
  203. })