spawn-runner.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import type { ChildProcess } from 'node:child_process'
  3. import { existsSync, statSync, writeFileSync } from 'node:fs'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { describe, expect, it, vi } from 'vitest'
  7. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  8. import {
  9. cleanupAfterRunner,
  10. runnerDirectResult,
  11. runnerFiles,
  12. runnerStdio,
  13. spawnRunnerInvocation,
  14. } from '../src/runner-launch.ts'
  15. import { observeChildClose } from '../src/managed-owner.ts'
  16. import {
  17. appendRunnerEvent,
  18. cleanupRunnerFiles,
  19. consumeRunnerRequest,
  20. createRunnerFiles,
  21. deserializeSpawnError,
  22. readRunnerEvents,
  23. readRunnerEventsAsync,
  24. serializeSpawnError,
  25. } from '../src/runner-protocol.ts'
  26. const sourceInvocation = [
  27. process.execPath,
  28. '--import',
  29. 'tsx/esm',
  30. fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')),
  31. ]
  32. function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  33. return {
  34. argv: [process.execPath, '-e', ''],
  35. cwd: process.cwd(),
  36. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
  37. graceMs: 100,
  38. ...overrides,
  39. }
  40. }
  41. function fakeChild(pid: number | undefined): ChildProcess {
  42. return { pid } as ChildProcess
  43. }
  44. function runRunner(invocation: string[], requestPath: string, eventsPath: string) {
  45. const [command, ...prefix] = invocation
  46. return spawnSync(command as string, [
  47. ...prefix,
  48. '--mode',
  49. 'node',
  50. '--request',
  51. requestPath,
  52. '--events',
  53. eventsPath,
  54. ], { encoding: 'utf8', timeout: 10_000 })
  55. }
  56. describe('spawn runner transport', () => {
  57. it('selects the source runner from source-plane execution', () => {
  58. expect(spawnRunnerInvocation()).toEqual(sourceInvocation)
  59. })
  60. it('re-enters a packaged executable through its private runner dispatch', () => {
  61. const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
  62. const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg')
  63. Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} })
  64. try {
  65. expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner'])
  66. } finally {
  67. if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg')
  68. else Object.defineProperty(packagedProcess, 'pkg', original)
  69. }
  70. })
  71. it('supports the node runner capability probe', () => {
  72. const result = spawnSync(sourceInvocation[0] as string, [
  73. ...sourceInvocation.slice(1),
  74. '--mode',
  75. 'probe-node',
  76. ], { encoding: 'utf8', timeout: 10_000 })
  77. expect(result.error).toBeUndefined()
  78. expect(result.status).toBe(0)
  79. })
  80. it('maps every target stdio disposition and optional IPC channel', () => {
  81. expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
  82. expect(runnerStdio(spec({
  83. stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
  84. }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc'])
  85. })
  86. it('materializes and consumes the exact runner request once', () => {
  87. const removed = `DSH_RUNNER_REMOVED_${process.pid}`
  88. const files = runnerFiles(spec({
  89. argv: [process.execPath, 'literal $HOME'],
  90. env: { RUNNER_VALUE: 'explicit', [removed]: undefined },
  91. }))
  92. try {
  93. const request = consumeRunnerRequest(files.requestPath)
  94. expect(request.argv).toEqual([process.execPath, 'literal $HOME'])
  95. expect(request.cwd).toBe(process.cwd())
  96. expect(request.env.RUNNER_VALUE).toBe('explicit')
  97. expect(request.env).not.toHaveProperty(removed)
  98. expect(existsSync(files.requestPath)).toBe(false)
  99. } finally {
  100. cleanupRunnerFiles(files)
  101. }
  102. })
  103. it.each([
  104. ['non-object request', null, 'no executable'],
  105. ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'],
  106. ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'],
  107. ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'],
  108. ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'],
  109. ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'],
  110. ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'],
  111. ])('rejects an invalid %s', (_label, request, message) => {
  112. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  113. try {
  114. writeFileSync(files.requestPath, JSON.stringify(request))
  115. expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message)
  116. } finally {
  117. cleanupRunnerFiles(files)
  118. }
  119. })
  120. it('reads only complete known event records and propagates file errors', async () => {
  121. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  122. try {
  123. expect(readRunnerEvents(files.eventsPath)).toEqual([])
  124. await expect(readRunnerEventsAsync(join(files.directory, 'missing.ndjson'))).resolves.toEqual([])
  125. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 })
  126. appendRunnerEvent(files.eventsPath, {
  127. type: 'runner-error',
  128. error: { name: 'Error', message: 'runner failed' },
  129. })
  130. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: null, signal: 'SIGTERM' })
  131. appendRunnerEvent(files.eventsPath, {
  132. type: 'spawn-error',
  133. error: {
  134. name: 'Error',
  135. message: 'spawn failed',
  136. code: 'ENOENT',
  137. errno: -2,
  138. syscall: 'spawn missing',
  139. path: 'missing',
  140. spawnargs: ['argument'],
  141. },
  142. })
  143. expect(readRunnerEvents(files.eventsPath)).toEqual([
  144. { type: 'started', pid: 123 },
  145. { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } },
  146. { type: 'exit', exitCode: null, signal: 'SIGTERM' },
  147. {
  148. type: 'spawn-error',
  149. error: {
  150. name: 'Error',
  151. message: 'spawn failed',
  152. code: 'ENOENT',
  153. errno: -2,
  154. syscall: 'spawn missing',
  155. path: 'missing',
  156. spawnargs: ['argument'],
  157. },
  158. },
  159. ])
  160. await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual(readRunnerEvents(files.eventsPath))
  161. writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"')
  162. expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }])
  163. for (const event of [null, []]) {
  164. writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
  165. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event')
  166. }
  167. writeFileSync(files.eventsPath, '{"type":"unknown"}\n')
  168. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event')
  169. await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event')
  170. expect(() => readRunnerEvents(files.directory)).toThrow()
  171. await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow()
  172. } finally {
  173. cleanupRunnerFiles(files)
  174. }
  175. })
  176. it.each([
  177. ['started without a pid', { type: 'started' }],
  178. ['started with a non-number pid', { type: 'started', pid: '1' }],
  179. ['started with a fractional pid', { type: 'started', pid: 1.5 }],
  180. ['started with a non-positive pid', { type: 'started', pid: 0 }],
  181. ['exit with a missing code', { type: 'exit', signal: null }],
  182. ['exit with a non-number code', { type: 'exit', exitCode: '0', signal: null }],
  183. ['exit with a fractional code', { type: 'exit', exitCode: 1.5, signal: null }],
  184. ['exit with a negative code', { type: 'exit', exitCode: -1, signal: null }],
  185. ['exit with a non-string signal', { type: 'exit', exitCode: 0, signal: 9 }],
  186. ['exit with an unknown signal', { type: 'exit', exitCode: 0, signal: 'NOT_A_SIGNAL' }],
  187. ['spawn error without an object', { type: 'spawn-error', error: null }],
  188. ['spawn error without a name', { type: 'spawn-error', error: { message: 'failed' } }],
  189. ['spawn error without a message', { type: 'spawn-error', error: { name: 'Error' } }],
  190. ['spawn error with a numeric code', { type: 'spawn-error', error: { name: 'Error', message: 'failed', code: 1 } }],
  191. ['spawn error with a string errno', { type: 'spawn-error', error: { name: 'Error', message: 'failed', errno: '1' } }],
  192. ['spawn error with a numeric syscall', { type: 'spawn-error', error: { name: 'Error', message: 'failed', syscall: 1 } }],
  193. ['spawn error with a numeric path', { type: 'spawn-error', error: { name: 'Error', message: 'failed', path: 1 } }],
  194. ['spawn error with non-array args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: 'arg' } }],
  195. ['spawn error with non-string args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: [1] } }],
  196. ])('rejects an invalid event payload: %s', (_label, event) => {
  197. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  198. try {
  199. writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
  200. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event')
  201. } finally {
  202. cleanupRunnerFiles(files)
  203. }
  204. })
  205. it('creates private request files and preserves Node-shaped error fields', () => {
  206. const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} })
  207. try {
  208. if (process.platform !== 'win32') expect(statSync(files.requestPath).mode & 0o777).toBe(0o600)
  209. const source = Object.assign(new Error('spawn missing ENOENT'), {
  210. code: 'ENOENT',
  211. errno: -2,
  212. syscall: 'spawn missing',
  213. path: 'missing',
  214. spawnargs: ['literal $VALUE'],
  215. })
  216. const restored = deserializeSpawnError(serializeSpawnError(source)) as NodeJS.ErrnoException & {
  217. path?: string
  218. spawnargs?: string[]
  219. }
  220. expect(restored).toMatchObject({
  221. message: 'spawn missing ENOENT',
  222. code: 'ENOENT',
  223. errno: -2,
  224. syscall: 'spawn missing',
  225. path: 'missing',
  226. spawnargs: ['literal $VALUE'],
  227. })
  228. const minimal = serializeSpawnError('plain failure')
  229. expect(minimal).toEqual({ name: 'Error', message: 'plain failure' })
  230. expect(deserializeSpawnError(minimal)).toMatchObject({ name: 'Error', message: 'plain failure' })
  231. } finally {
  232. cleanupRunnerFiles(files)
  233. }
  234. })
  235. it('maps runner failures and wrapper-close fallback outcomes', async () => {
  236. const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  237. try {
  238. appendRunnerEvent(runnerFailure.eventsPath, {
  239. type: 'runner-error',
  240. error: { name: 'Error', message: 'runner setup failed', code: 'EIO' },
  241. })
  242. const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise<void>(() => {}))
  243. expect(result.pid).toBe(-1)
  244. await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' })
  245. } finally {
  246. cleanupRunnerFiles(runnerFailure)
  247. }
  248. const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  249. try {
  250. appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 })
  251. const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve())
  252. expect(result.pid).toBe(456)
  253. await expect(result.direct).rejects.toThrow('exited without a direct-command result')
  254. } finally {
  255. cleanupRunnerFiles(missing)
  256. }
  257. const forced = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  258. try {
  259. appendRunnerEvent(forced.eventsPath, { type: 'started', pid: 789 })
  260. const result = runnerDirectResult(
  261. fakeChild(123),
  262. forced,
  263. Promise.resolve(),
  264. () => ({ exitCode: null, signal: 'SIGKILL' }),
  265. )
  266. await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  267. } finally {
  268. cleanupRunnerFiles(forced)
  269. }
  270. })
  271. it('requires an event snapshot started after wrapper close before reporting a missing result', async () => {
  272. const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
  273. let readCount = 0
  274. vi.resetModules()
  275. vi.doMock('../src/runner-protocol.ts', async (importOriginal) => {
  276. const actual = await importOriginal<typeof import('../src/runner-protocol.ts')>()
  277. return {
  278. ...actual,
  279. readRunnerEventsAsync: vi.fn(async (eventsPath: string) => {
  280. readCount += 1
  281. if (readCount === 1) return staleRead.promise
  282. return actual.readRunnerEvents(eventsPath)
  283. }),
  284. }
  285. })
  286. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  287. try {
  288. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
  289. const closed = Promise.withResolvers<undefined>()
  290. const isolated = await import('../src/runner-launch.ts')
  291. const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise)
  292. expect(readCount).toBe(1)
  293. closed.resolve(undefined)
  294. await Promise.resolve()
  295. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
  296. staleRead.resolve([{ type: 'started', pid: 456 }])
  297. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  298. expect(readCount).toBe(2)
  299. } finally {
  300. cleanupRunnerFiles(files)
  301. vi.doUnmock('../src/runner-protocol.ts')
  302. vi.resetModules()
  303. }
  304. })
  305. it('contains wrapper spawn errors while publishing the runner startup rejection', async () => {
  306. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  307. try {
  308. const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
  309. stdio: 'ignore',
  310. })
  311. const closed = observeChildClose(child)
  312. const result = runnerDirectResult(child, files, closed)
  313. expect(result.pid).toBe(-1)
  314. await expect(result.direct).rejects.toThrow('runner failed to start')
  315. await expect(closed).resolves.toBeUndefined()
  316. } finally {
  317. cleanupRunnerFiles(files)
  318. }
  319. })
  320. it('reports runner startup failure and handshake timeout without leaking request files', async () => {
  321. const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  322. const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise<void>(() => {}))
  323. expect(missingResult.pid).toBe(-1)
  324. await expect(missingResult.direct).rejects.toThrow('runner failed to start')
  325. expect(existsSync(missingChild.directory)).toBe(false)
  326. const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  327. const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise<void>(() => {}))
  328. expect(exitedResult.pid).toBe(-1)
  329. await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start')
  330. expect(existsSync(exitedChild.directory)).toBe(false)
  331. const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  332. const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001)
  333. try {
  334. const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise<void>(() => {}))
  335. expect(timedOutResult.pid).toBe(-1)
  336. await expect(timedOutResult.direct).rejects.toThrow('did not report target start')
  337. expect(existsSync(timedOut.directory)).toBe(false)
  338. } finally {
  339. now.mockRestore()
  340. cleanupRunnerFiles(timedOut)
  341. }
  342. })
  343. it('cleans runner files only after the direct result and runner close settle', async () => {
  344. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  345. const closed = Promise.withResolvers<undefined>()
  346. cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), closed.promise)
  347. await new Promise(resolve => setImmediate(resolve))
  348. expect(existsSync(files.directory)).toBe(true)
  349. closed.resolve(undefined)
  350. await new Promise(resolve => setImmediate(resolve))
  351. expect(existsSync(files.directory)).toBe(false)
  352. })
  353. it('reports the direct target pid and exit outcome from the source entry', () => {
  354. const files = createRunnerFiles({
  355. argv: [process.execPath, '-e', 'process.exit(7)'],
  356. cwd: process.cwd(),
  357. env: {},
  358. })
  359. try {
  360. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  361. expect(result.error).toBeUndefined()
  362. const events = readRunnerEvents(files.eventsPath)
  363. expect(events).toHaveLength(2)
  364. expect(events[0]?.type).toBe('started')
  365. if (events[0]?.type !== 'started') throw new Error('expected started event')
  366. expect(events[0].pid).toBeGreaterThan(0)
  367. expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null })
  368. } finally {
  369. cleanupRunnerFiles(files)
  370. }
  371. })
  372. it('preserves literal argv, cwd, and the exact target environment', () => {
  373. const files = createRunnerFiles({
  374. argv: [
  375. process.execPath,
  376. '-e',
  377. 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))',
  378. 'literal $HOME ${UNCHANGED}',
  379. ],
  380. cwd: process.cwd(),
  381. env: { RUNNER_VALUE: 'explicit' },
  382. })
  383. try {
  384. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  385. expect(result.status).toBe(0)
  386. expect(result.stdout.trim()).toBe(JSON.stringify({
  387. cwd: process.cwd(),
  388. value: 'explicit',
  389. arg: 'literal $HOME ${UNCHANGED}',
  390. }))
  391. } finally {
  392. cleanupRunnerFiles(files)
  393. }
  394. })
  395. it('reports target spawn failure without executing a fallback command', () => {
  396. const files = createRunnerFiles({
  397. argv: [`missing-dsh-runner-${Date.now()}`],
  398. cwd: process.cwd(),
  399. env: {},
  400. })
  401. try {
  402. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  403. expect(result.error).toBeUndefined()
  404. const events = readRunnerEvents(files.eventsPath)
  405. expect(events).toHaveLength(1)
  406. expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } })
  407. } finally {
  408. cleanupRunnerFiles(files)
  409. }
  410. })
  411. })