managed-spawn.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { spawn } from 'node:child_process'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  4. import type { BoundProcessOwner } from '../src/managed-owner.ts'
  5. import { observeChildClose, waitWithAbort } from '../src/managed-owner.ts'
  6. import { bindManagedProcess } from '../src/spawn.ts'
  7. function spec(graceMs = 30): SubprocessSpawnSpec {
  8. return {
  9. argv: [process.execPath],
  10. cwd: process.cwd(),
  11. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
  12. graceMs,
  13. }
  14. }
  15. describe('managed process binding', () => {
  16. it('closes the abort race after installing the wait listener', async () => {
  17. let reads = 0
  18. const removeEventListener = vi.fn()
  19. const signal = {
  20. get aborted() {
  21. reads += 1
  22. return reads > 1
  23. },
  24. addEventListener: vi.fn(),
  25. removeEventListener,
  26. } as unknown as AbortSignal
  27. await expect(waitWithAbort(new Promise<void>(() => {}), signal)).resolves.toBe(false)
  28. expect(removeEventListener).toHaveBeenCalledOnce()
  29. })
  30. it('keeps direct outcome separate from managed-range quiescence', async () => {
  31. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  32. stdio: ['ignore', 'pipe', 'pipe'],
  33. })
  34. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  35. const stopped = Promise.withResolvers<undefined>()
  36. let ownerStopped = false
  37. const signals: NodeJS.Signals[] = []
  38. const owner: BoundProcessOwner = {
  39. signal(signal) {
  40. if (ownerStopped) return
  41. signals.push(signal)
  42. if (signal === 'SIGKILL') {
  43. ownerStopped = true
  44. wrapper.kill('SIGKILL')
  45. stopped.resolve(undefined)
  46. }
  47. },
  48. async waitForExit(signal) {
  49. if (ownerStopped) return true
  50. if (signal?.aborted) return false
  51. if (signal === undefined) {
  52. await stopped.promise
  53. return true
  54. }
  55. const aborted = Promise.withResolvers<boolean>()
  56. signal.addEventListener('abort', () => { aborted.resolve(false) }, { once: true })
  57. return Promise.race([stopped.promise.then(() => true), aborted.promise])
  58. },
  59. }
  60. const handle = bindManagedProcess(spec(), {
  61. child: wrapper,
  62. pid: 4242,
  63. direct: direct.promise,
  64. closed: observeChildClose(wrapper),
  65. owner,
  66. })
  67. direct.resolve({ exitCode: 42, signal: null })
  68. await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null })
  69. const bound = AbortSignal.timeout(10)
  70. await expect(handle.waitForExit(bound)).resolves.toBe(false)
  71. handle.terminate()
  72. expect(signals).toEqual(['SIGTERM'])
  73. await new Promise(resolve => setTimeout(resolve, 50))
  74. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  75. await expect(handle.waitForExit()).resolves.toBe(true)
  76. handle.terminateForHostExit()
  77. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  78. })
  79. it('routes synchronous host-exit finalization directly to the owner', () => {
  80. const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] })
  81. const signal = vi.fn()
  82. const handle = bindManagedProcess(spec(), {
  83. child: wrapper,
  84. pid: 4242,
  85. direct: Promise.resolve({ exitCode: 0, signal: null }),
  86. closed: observeChildClose(wrapper),
  87. owner: { signal, waitForExit: async () => true },
  88. })
  89. handle.terminateForHostExit()
  90. expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL')
  91. })
  92. it('settles when the wrapper closes before the direct outcome arrives', async () => {
  93. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  94. stdio: ['ignore', 'pipe', 'pipe'],
  95. })
  96. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  97. const handle = bindManagedProcess(spec(), {
  98. child: wrapper,
  99. pid: wrapper.pid as number,
  100. direct: direct.promise,
  101. closed: Promise.resolve(),
  102. owner: { signal: vi.fn(), waitForExit: async () => true },
  103. })
  104. try {
  105. await new Promise(resolve => setImmediate(resolve))
  106. direct.resolve({ exitCode: 23, signal: null })
  107. await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null })
  108. } finally {
  109. wrapper.kill('SIGKILL')
  110. }
  111. })
  112. it('publishes direct outcome immediately when no collected stream needs draining', async () => {
  113. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  114. stdio: ['ignore', 'ignore', 'ignore'],
  115. })
  116. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  117. const handle = bindManagedProcess({
  118. ...spec(1_000),
  119. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
  120. }, {
  121. child: wrapper,
  122. pid: wrapper.pid as number,
  123. direct: direct.promise,
  124. closed: new Promise<void>(() => {}),
  125. owner: { signal: vi.fn(), waitForExit: async () => true },
  126. })
  127. try {
  128. direct.resolve({ exitCode: 23, signal: null })
  129. const outcome = await Promise.race([
  130. handle.done,
  131. new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 50)),
  132. ])
  133. expect(outcome).toEqual({ exitCode: 23, signal: null })
  134. } finally {
  135. wrapper.kill('SIGKILL')
  136. }
  137. })
  138. it('contains background range-observation rejection until waitForExit observes it', async () => {
  139. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  140. stdio: ['ignore', 'pipe', 'pipe'],
  141. })
  142. const failure = new Error('range observation failed')
  143. const handle = bindManagedProcess(spec(), {
  144. child: wrapper,
  145. pid: wrapper.pid as number,
  146. direct: new Promise(() => {}),
  147. closed: new Promise<void>(() => {}),
  148. owner: { signal: vi.fn(), waitForExit: async () => { throw failure } },
  149. })
  150. try {
  151. handle.terminate()
  152. await new Promise(resolve => setImmediate(resolve))
  153. await expect(handle.waitForExit()).rejects.toBe(failure)
  154. } finally {
  155. wrapper.kill('SIGKILL')
  156. }
  157. })
  158. it('normalizes a non-Error direct rejection', async () => {
  159. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  160. stdio: ['ignore', 'pipe', 'pipe'],
  161. })
  162. const rejection: unknown = 'runner failed'
  163. const direct = Promise.resolve().then(() => { throw rejection })
  164. const signal = vi.fn()
  165. const handle = bindManagedProcess(spec(), {
  166. child: wrapper,
  167. pid: wrapper.pid as number,
  168. direct,
  169. closed: new Promise<void>(() => {}),
  170. owner: { signal, waitForExit: async () => true },
  171. })
  172. try {
  173. await expect(handle.done).rejects.toThrow('runner failed')
  174. expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  175. } finally {
  176. wrapper.kill('SIGKILL')
  177. }
  178. })
  179. it('keeps abort ownership after direct exit until the managed range is empty', async () => {
  180. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  181. stdio: ['ignore', 'pipe', 'pipe'],
  182. })
  183. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  184. const stopped = Promise.withResolvers<undefined>()
  185. const signal = vi.fn((requested: NodeJS.Signals) => {
  186. if (requested !== 'SIGTERM') return
  187. wrapper.kill('SIGTERM')
  188. stopped.resolve(undefined)
  189. })
  190. const controller = new AbortController()
  191. const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, {
  192. child: wrapper,
  193. pid: wrapper.pid as number,
  194. direct: direct.promise,
  195. closed: Promise.resolve(),
  196. owner: {
  197. signal,
  198. waitForExit: async () => { await stopped.promise; return true },
  199. },
  200. })
  201. direct.resolve({ exitCode: 0, signal: null })
  202. await handle.done
  203. controller.abort()
  204. await expect(handle.waitForExit()).resolves.toBe(true)
  205. expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  206. })
  207. })