spawn-runner.spec.ts 19 KB

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