managed-spawn.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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. stdin: wrapper.stdin,
  62. stdout: wrapper.stdout,
  63. stderr: wrapper.stderr,
  64. pid: 4242,
  65. direct: direct.promise,
  66. closed: observeChildClose(wrapper),
  67. owner,
  68. })
  69. direct.resolve({ exitCode: 42, signal: null })
  70. await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null })
  71. const bound = AbortSignal.timeout(10)
  72. await expect(handle.waitForExit(bound)).resolves.toBe(false)
  73. handle.terminate()
  74. expect(signals).toEqual(['SIGTERM'])
  75. await new Promise(resolve => setTimeout(resolve, 50))
  76. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  77. await expect(handle.waitForExit()).resolves.toBe(true)
  78. handle.terminateForHostExit()
  79. expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
  80. })
  81. it('routes synchronous host-exit finalization directly to the owner', () => {
  82. const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] })
  83. const signal = vi.fn()
  84. const handle = bindManagedProcess(spec(), {
  85. stdin: wrapper.stdin,
  86. stdout: wrapper.stdout,
  87. stderr: wrapper.stderr,
  88. pid: 4242,
  89. direct: Promise.resolve({ exitCode: 0, signal: null }),
  90. closed: observeChildClose(wrapper),
  91. owner: { signal, waitForExit: async () => true },
  92. })
  93. handle.terminateForHostExit()
  94. expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL')
  95. })
  96. it('settles when the wrapper closes before the direct outcome arrives', async () => {
  97. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  98. stdio: ['ignore', 'pipe', 'pipe'],
  99. })
  100. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  101. const handle = bindManagedProcess(spec(), {
  102. stdin: wrapper.stdin,
  103. stdout: wrapper.stdout,
  104. stderr: wrapper.stderr,
  105. pid: wrapper.pid as number,
  106. direct: direct.promise,
  107. closed: Promise.resolve(),
  108. owner: { signal: vi.fn(), waitForExit: async () => true },
  109. })
  110. try {
  111. await new Promise(resolve => setImmediate(resolve))
  112. direct.resolve({ exitCode: 23, signal: null })
  113. await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null })
  114. } finally {
  115. wrapper.kill('SIGKILL')
  116. }
  117. })
  118. it('publishes direct outcome immediately when no collected stream needs draining', async () => {
  119. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  120. stdio: ['ignore', 'ignore', 'ignore'],
  121. })
  122. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  123. const handle = bindManagedProcess({
  124. ...spec(1_000),
  125. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
  126. }, {
  127. stdin: wrapper.stdin,
  128. stdout: wrapper.stdout,
  129. stderr: wrapper.stderr,
  130. pid: wrapper.pid as number,
  131. direct: direct.promise,
  132. closed: new Promise<void>(() => {}),
  133. owner: { signal: vi.fn(), waitForExit: async () => true },
  134. })
  135. try {
  136. direct.resolve({ exitCode: 23, signal: null })
  137. const outcome = await Promise.race([
  138. handle.done,
  139. new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 50)),
  140. ])
  141. expect(outcome).toEqual({ exitCode: 23, signal: null })
  142. } finally {
  143. wrapper.kill('SIGKILL')
  144. }
  145. })
  146. it('contains background range-observation rejection until waitForExit observes it', async () => {
  147. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  148. stdio: ['ignore', 'pipe', 'pipe'],
  149. })
  150. const failure = new Error('range observation failed')
  151. const handle = bindManagedProcess(spec(), {
  152. stdin: wrapper.stdin,
  153. stdout: wrapper.stdout,
  154. stderr: wrapper.stderr,
  155. pid: wrapper.pid as number,
  156. direct: new Promise(() => {}),
  157. closed: new Promise<void>(() => {}),
  158. owner: { signal: vi.fn(), waitForExit: async () => { throw failure } },
  159. })
  160. try {
  161. handle.terminate()
  162. await new Promise(resolve => setImmediate(resolve))
  163. await expect(handle.waitForExit()).rejects.toBe(failure)
  164. } finally {
  165. wrapper.kill('SIGKILL')
  166. }
  167. })
  168. it('normalizes a non-Error direct rejection', async () => {
  169. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  170. stdio: ['ignore', 'pipe', 'pipe'],
  171. })
  172. const rejection: unknown = 'runner failed'
  173. const direct = Promise.resolve().then(() => { throw rejection })
  174. const signal = vi.fn()
  175. const handle = bindManagedProcess(spec(), {
  176. stdin: wrapper.stdin,
  177. stdout: wrapper.stdout,
  178. stderr: wrapper.stderr,
  179. pid: wrapper.pid as number,
  180. direct,
  181. closed: new Promise<void>(() => {}),
  182. owner: { signal, waitForExit: async () => true },
  183. })
  184. try {
  185. await expect(handle.done).rejects.toThrow('runner failed')
  186. expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  187. expect(wrapper.stdout?.destroyed).toBe(true)
  188. expect(wrapper.stderr?.destroyed).toBe(true)
  189. } finally {
  190. wrapper.kill('SIGKILL')
  191. }
  192. })
  193. it('keeps abort ownership after direct exit until the managed range is empty', async () => {
  194. const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
  195. stdio: ['ignore', 'pipe', 'pipe'],
  196. })
  197. const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
  198. const stopped = Promise.withResolvers<undefined>()
  199. const signal = vi.fn((requested: NodeJS.Signals) => {
  200. if (requested !== 'SIGTERM') return
  201. wrapper.kill('SIGTERM')
  202. stopped.resolve(undefined)
  203. })
  204. const controller = new AbortController()
  205. const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, {
  206. stdin: wrapper.stdin,
  207. stdout: wrapper.stdout,
  208. stderr: wrapper.stderr,
  209. pid: wrapper.pid as number,
  210. direct: direct.promise,
  211. closed: Promise.resolve(),
  212. owner: {
  213. signal,
  214. waitForExit: async () => { await stopped.promise; return true },
  215. },
  216. })
  217. direct.resolve({ exitCode: 0, signal: null })
  218. await handle.done
  219. controller.abort()
  220. await expect(handle.waitForExit()).resolves.toBe(true)
  221. expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
  222. })
  223. })