managed-spawn.spec.ts 9.3 KB

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