spawn-runner.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import { spawnSync } from 'node:child_process'
  2. import type { ChildProcess } from 'node:child_process'
  3. import { existsSync, statSync, writeFileSync } from 'node:fs'
  4. import { fileURLToPath } from 'node:url'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  7. import {
  8. cleanupAfterRunner,
  9. runnerDirectResult,
  10. runnerFiles,
  11. runnerStdio,
  12. } from '../src/runner-launch.ts'
  13. import {
  14. appendRunnerEvent,
  15. cleanupRunnerFiles,
  16. consumeRunnerRequest,
  17. createRunnerFiles,
  18. deserializeSpawnError,
  19. readRunnerEvents,
  20. serializeSpawnError,
  21. } from '../src/runner-protocol.ts'
  22. const sourceInvocation = [
  23. process.execPath,
  24. '--import',
  25. 'tsx/esm',
  26. fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')),
  27. ]
  28. const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner'))
  29. function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  30. return {
  31. argv: [process.execPath, '-e', ''],
  32. cwd: process.cwd(),
  33. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
  34. graceMs: 100,
  35. ...overrides,
  36. }
  37. }
  38. function fakeChild(pid: number | undefined): ChildProcess {
  39. return { pid } as ChildProcess
  40. }
  41. function runRunner(invocation: string[], requestPath: string, eventsPath: string) {
  42. const [command, ...prefix] = invocation
  43. return spawnSync(command as string, [
  44. ...prefix,
  45. '--mode',
  46. 'node',
  47. '--request',
  48. requestPath,
  49. '--events',
  50. eventsPath,
  51. ], { encoding: 'utf8', timeout: 10_000 })
  52. }
  53. describe('spawn runner transport', () => {
  54. it('selects built and source runner entries according to artifact availability', async () => {
  55. vi.resetModules()
  56. vi.doMock('node:fs', async importOriginal => ({
  57. ...await importOriginal<typeof import('node:fs')>(),
  58. existsSync: () => true,
  59. }))
  60. try {
  61. const built = await import('../src/runner-launch.ts')
  62. expect(built.spawnRunnerInvocation()).toEqual([process.execPath, builtEntry])
  63. } finally {
  64. vi.doUnmock('node:fs')
  65. vi.resetModules()
  66. }
  67. vi.doMock('node:fs', async importOriginal => ({
  68. ...await importOriginal<typeof import('node:fs')>(),
  69. existsSync: () => false,
  70. }))
  71. try {
  72. const source = await import('../src/runner-launch.ts')
  73. expect(source.spawnRunnerInvocation()).toEqual(sourceInvocation)
  74. } finally {
  75. vi.doUnmock('node:fs')
  76. vi.resetModules()
  77. }
  78. })
  79. it('maps every target stdio disposition and optional IPC channel', () => {
  80. expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
  81. expect(runnerStdio(spec({
  82. stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
  83. }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc'])
  84. })
  85. it('materializes and consumes the exact runner request once', () => {
  86. const removed = `DSH_RUNNER_REMOVED_${process.pid}`
  87. const files = runnerFiles(spec({
  88. argv: [process.execPath, 'literal $HOME'],
  89. env: { RUNNER_VALUE: 'explicit', [removed]: undefined },
  90. }))
  91. try {
  92. const request = consumeRunnerRequest(files.requestPath)
  93. expect(request.argv).toEqual([process.execPath, 'literal $HOME'])
  94. expect(request.cwd).toBe(process.cwd())
  95. expect(request.env.RUNNER_VALUE).toBe('explicit')
  96. expect(request.env).not.toHaveProperty(removed)
  97. expect(existsSync(files.requestPath)).toBe(false)
  98. } finally {
  99. cleanupRunnerFiles(files)
  100. }
  101. })
  102. it.each([
  103. ['non-object request', null, 'no executable'],
  104. ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'],
  105. ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'],
  106. ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'],
  107. ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'],
  108. ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'],
  109. ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'],
  110. ])('rejects an invalid %s', (_label, request, message) => {
  111. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  112. try {
  113. writeFileSync(files.requestPath, JSON.stringify(request))
  114. expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message)
  115. } finally {
  116. cleanupRunnerFiles(files)
  117. }
  118. })
  119. it('reads only complete known event records and propagates file errors', () => {
  120. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  121. try {
  122. expect(readRunnerEvents(files.eventsPath)).toEqual([])
  123. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 })
  124. appendRunnerEvent(files.eventsPath, {
  125. type: 'runner-error',
  126. error: { name: 'Error', message: 'runner failed' },
  127. })
  128. expect(readRunnerEvents(files.eventsPath)).toEqual([
  129. { type: 'started', pid: 123 },
  130. { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } },
  131. ])
  132. writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"')
  133. expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }])
  134. for (const event of [null, [], { type: 'unknown' }]) {
  135. writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
  136. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event')
  137. }
  138. expect(() => readRunnerEvents(files.directory)).toThrow()
  139. } finally {
  140. cleanupRunnerFiles(files)
  141. }
  142. })
  143. it('creates private request files and preserves Node-shaped error fields', () => {
  144. const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} })
  145. try {
  146. if (process.platform !== 'win32') expect(statSync(files.requestPath).mode & 0o777).toBe(0o600)
  147. const source = Object.assign(new Error('spawn missing ENOENT'), {
  148. code: 'ENOENT',
  149. errno: -2,
  150. syscall: 'spawn missing',
  151. path: 'missing',
  152. spawnargs: ['literal $VALUE'],
  153. })
  154. const restored = deserializeSpawnError(serializeSpawnError(source)) as NodeJS.ErrnoException & {
  155. path?: string
  156. spawnargs?: string[]
  157. }
  158. expect(restored).toMatchObject({
  159. message: 'spawn missing ENOENT',
  160. code: 'ENOENT',
  161. errno: -2,
  162. syscall: 'spawn missing',
  163. path: 'missing',
  164. spawnargs: ['literal $VALUE'],
  165. })
  166. const minimal = serializeSpawnError('plain failure')
  167. expect(minimal).toEqual({ name: 'Error', message: 'plain failure' })
  168. expect(deserializeSpawnError(minimal)).toMatchObject({ name: 'Error', message: 'plain failure' })
  169. } finally {
  170. cleanupRunnerFiles(files)
  171. }
  172. })
  173. it('maps runner failures and wrapper-close fallback outcomes', async () => {
  174. const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  175. try {
  176. appendRunnerEvent(runnerFailure.eventsPath, {
  177. type: 'runner-error',
  178. error: { name: 'Error', message: 'runner setup failed', code: 'EIO' },
  179. })
  180. const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise<void>(() => {}))
  181. expect(result.pid).toBe(-1)
  182. await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' })
  183. } finally {
  184. cleanupRunnerFiles(runnerFailure)
  185. }
  186. const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  187. try {
  188. appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 })
  189. const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve())
  190. expect(result.pid).toBe(456)
  191. await expect(result.direct).rejects.toThrow('exited without a direct-command result')
  192. } finally {
  193. cleanupRunnerFiles(missing)
  194. }
  195. const forced = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  196. try {
  197. appendRunnerEvent(forced.eventsPath, { type: 'started', pid: 789 })
  198. const result = runnerDirectResult(
  199. fakeChild(123),
  200. forced,
  201. Promise.resolve(),
  202. () => ({ exitCode: null, signal: 'SIGKILL' }),
  203. )
  204. await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  205. } finally {
  206. cleanupRunnerFiles(forced)
  207. }
  208. })
  209. it('reports runner startup failure and handshake timeout without leaking request files', async () => {
  210. const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  211. const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise<void>(() => {}))
  212. expect(missingResult.pid).toBe(-1)
  213. await expect(missingResult.direct).rejects.toThrow('runner failed to start')
  214. expect(existsSync(missingChild.directory)).toBe(false)
  215. const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  216. const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001)
  217. try {
  218. const timedOutResult = runnerDirectResult(fakeChild(123), timedOut, new Promise<void>(() => {}))
  219. expect(timedOutResult.pid).toBe(-1)
  220. await expect(timedOutResult.direct).rejects.toThrow('did not report target start')
  221. expect(existsSync(timedOut.directory)).toBe(false)
  222. } finally {
  223. now.mockRestore()
  224. cleanupRunnerFiles(timedOut)
  225. }
  226. })
  227. it('cleans runner files only after both direct and owner lifecycles settle', async () => {
  228. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  229. const exited = Promise.withResolvers<undefined>()
  230. cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), {
  231. signal: vi.fn(),
  232. waitForExit: async () => { await exited.promise; return true },
  233. })
  234. await new Promise(resolve => setImmediate(resolve))
  235. expect(existsSync(files.directory)).toBe(true)
  236. exited.resolve(undefined)
  237. await new Promise(resolve => setImmediate(resolve))
  238. expect(existsSync(files.directory)).toBe(false)
  239. })
  240. it('reports the direct target pid and exit outcome from the source entry', () => {
  241. const files = createRunnerFiles({
  242. argv: [process.execPath, '-e', 'process.exit(7)'],
  243. cwd: process.cwd(),
  244. env: {},
  245. })
  246. try {
  247. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  248. expect(result.error).toBeUndefined()
  249. const events = readRunnerEvents(files.eventsPath)
  250. expect(events).toHaveLength(2)
  251. expect(events[0]?.type).toBe('started')
  252. if (events[0]?.type !== 'started') throw new Error('expected started event')
  253. expect(events[0].pid).toBeGreaterThan(0)
  254. expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null })
  255. } finally {
  256. cleanupRunnerFiles(files)
  257. }
  258. })
  259. it('preserves literal argv, cwd, and the exact target environment', () => {
  260. const files = createRunnerFiles({
  261. argv: [
  262. process.execPath,
  263. '-e',
  264. 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))',
  265. 'literal $HOME ${UNCHANGED}',
  266. ],
  267. cwd: process.cwd(),
  268. env: { RUNNER_VALUE: 'explicit' },
  269. })
  270. try {
  271. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  272. expect(result.status).toBe(0)
  273. expect(result.stdout.trim()).toBe(JSON.stringify({
  274. cwd: process.cwd(),
  275. value: 'explicit',
  276. arg: 'literal $HOME ${UNCHANGED}',
  277. }))
  278. } finally {
  279. cleanupRunnerFiles(files)
  280. }
  281. })
  282. it('reports target spawn failure without executing a fallback command', () => {
  283. const files = createRunnerFiles({
  284. argv: [`missing-dsh-runner-${Date.now()}`],
  285. cwd: process.cwd(),
  286. env: {},
  287. })
  288. try {
  289. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  290. expect(result.error).toBeUndefined()
  291. const events = readRunnerEvents(files.eventsPath)
  292. expect(events).toHaveLength(1)
  293. expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } })
  294. } finally {
  295. cleanupRunnerFiles(files)
  296. }
  297. })
  298. it.skipIf(!existsSync(builtEntry))('reports direct outcome from the built entry', () => {
  299. const files = createRunnerFiles({
  300. argv: [process.execPath, '-e', 'process.exit(11)'],
  301. cwd: process.cwd(),
  302. env: {},
  303. })
  304. try {
  305. const result = runRunner([process.execPath, builtEntry], files.requestPath, files.eventsPath)
  306. expect(result.error).toBeUndefined()
  307. const events = readRunnerEvents(files.eventsPath)
  308. expect(events).toHaveLength(2)
  309. expect(events[0]?.type).toBe('started')
  310. if (events[0]?.type !== 'started') throw new Error('expected started event')
  311. expect(events[0].pid).toBeGreaterThan(0)
  312. expect(events[1]).toEqual({ type: 'exit', exitCode: 11, signal: null })
  313. } finally {
  314. cleanupRunnerFiles(files)
  315. }
  316. })
  317. })