spawn-runner.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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('unlinks a substituted runner-directory link without traversing it', () => {
  105. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  106. const outside = mkdtempSync(join(tmpdir(), 'dsh-runner-outside-'))
  107. const sentinel = join(outside, 'events.ndjson')
  108. writeFileSync(sentinel, 'keep')
  109. rmSync(files.directory, { recursive: true, force: true })
  110. symlinkSync(outside, files.directory, process.platform === 'win32' ? 'junction' : 'dir')
  111. try {
  112. cleanupRunnerFiles(files)
  113. expect(existsSync(files.directory)).toBe(false)
  114. expect(existsSync(sentinel)).toBe(true)
  115. } finally {
  116. rmSync(files.directory, { recursive: true, force: true })
  117. rmSync(outside, { recursive: true, force: true })
  118. }
  119. })
  120. it('contains an unexpected owned-path cleanup failure', () => {
  121. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  122. rmSync(files.requestPath, { force: true })
  123. mkdirSync(files.requestPath)
  124. try {
  125. expect(() => { cleanupRunnerFiles(files) }).not.toThrow()
  126. expect(existsSync(files.directory)).toBe(true)
  127. } finally {
  128. rmSync(files.directory, { recursive: true, force: true })
  129. }
  130. })
  131. it.each([
  132. ['non-object request', null, 'no executable'],
  133. ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'],
  134. ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'],
  135. ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'],
  136. ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'],
  137. ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'],
  138. ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'],
  139. ])('rejects an invalid %s', (_label, request, message) => {
  140. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  141. try {
  142. writeFileSync(files.requestPath, JSON.stringify(request))
  143. expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message)
  144. } finally {
  145. cleanupRunnerFiles(files)
  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 wrapper-close fallback outcomes', 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 missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  277. try {
  278. appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 })
  279. const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve())
  280. expect(result.pid).toBe(456)
  281. await expect(result.direct).rejects.toThrow('exited without a direct-command result')
  282. } finally {
  283. cleanupRunnerFiles(missing)
  284. }
  285. })
  286. it('requires an event snapshot started after wrapper close before reporting a missing result', async () => {
  287. const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
  288. let readCount = 0
  289. vi.resetModules()
  290. vi.doMock('../src/runner-protocol.ts', async (importOriginal) => {
  291. const actual = await importOriginal<typeof import('../src/runner-protocol.ts')>()
  292. return {
  293. ...actual,
  294. readRunnerEventsAsync: vi.fn(async (eventsPath: string) => {
  295. readCount += 1
  296. if (readCount === 1) return staleRead.promise
  297. return actual.readRunnerEvents(eventsPath)
  298. }),
  299. }
  300. })
  301. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  302. try {
  303. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
  304. const closed = Promise.withResolvers<undefined>()
  305. const isolated = await import('../src/runner-launch.ts')
  306. const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise)
  307. expect(readCount).toBe(1)
  308. closed.resolve(undefined)
  309. await Promise.resolve()
  310. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
  311. staleRead.resolve([{ type: 'started', pid: 456 }])
  312. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  313. expect(readCount).toBe(2)
  314. } finally {
  315. cleanupRunnerFiles(files)
  316. vi.doUnmock('../src/runner-protocol.ts')
  317. vi.resetModules()
  318. }
  319. })
  320. it('contains wrapper spawn errors while publishing the runner startup rejection', async () => {
  321. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  322. try {
  323. const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
  324. stdio: 'ignore',
  325. })
  326. const closed = observeChildClose(child)
  327. const result = runnerDirectResult(child, files, closed)
  328. expect(result.pid).toBe(-1)
  329. await expect(result.direct).rejects.toThrow('runner failed to start')
  330. await expect(closed).resolves.toBeUndefined()
  331. } finally {
  332. cleanupRunnerFiles(files)
  333. }
  334. })
  335. it('reports runner startup failure and handshake timeout without leaking request files', async () => {
  336. const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  337. const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise<void>(() => {}))
  338. expect(missingResult.pid).toBe(-1)
  339. await expect(missingResult.direct).rejects.toThrow('runner failed to start')
  340. expect(existsSync(missingChild.directory)).toBe(false)
  341. const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  342. const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise<void>(() => {}))
  343. expect(exitedResult.pid).toBe(-1)
  344. await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start')
  345. expect(existsSync(exitedChild.directory)).toBe(false)
  346. const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  347. const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001)
  348. try {
  349. const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise<void>(() => {}))
  350. expect(timedOutResult.pid).toBe(-1)
  351. await expect(timedOutResult.direct).rejects.toThrow('did not report target start')
  352. expect(existsSync(timedOut.directory)).toBe(false)
  353. } finally {
  354. now.mockRestore()
  355. cleanupRunnerFiles(timedOut)
  356. }
  357. })
  358. it('cleans runner files only after the direct result and runner close settle', async () => {
  359. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  360. const closed = Promise.withResolvers<undefined>()
  361. cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), closed.promise)
  362. await new Promise(resolve => setImmediate(resolve))
  363. expect(existsSync(files.directory)).toBe(true)
  364. closed.resolve(undefined)
  365. await new Promise(resolve => setImmediate(resolve))
  366. expect(existsSync(files.directory)).toBe(false)
  367. })
  368. it('reports the direct target pid and exit outcome from the source entry', () => {
  369. const files = createRunnerFiles({
  370. argv: [process.execPath, '-e', 'process.exit(7)'],
  371. cwd: process.cwd(),
  372. env: {},
  373. })
  374. try {
  375. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  376. expect(result.error).toBeUndefined()
  377. const events = readRunnerEvents(files.eventsPath)
  378. expect(events).toHaveLength(2)
  379. expect(events[0]?.type).toBe('started')
  380. if (events[0]?.type !== 'started') throw new Error('expected started event')
  381. expect(events[0].pid).toBeGreaterThan(0)
  382. expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null })
  383. } finally {
  384. cleanupRunnerFiles(files)
  385. }
  386. })
  387. it('preserves literal argv, cwd, and the exact target environment', () => {
  388. const files = createRunnerFiles({
  389. argv: [
  390. process.execPath,
  391. '-e',
  392. 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))',
  393. 'literal $HOME ${UNCHANGED}',
  394. ],
  395. cwd: process.cwd(),
  396. env: { RUNNER_VALUE: 'explicit' },
  397. })
  398. try {
  399. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  400. expect(result.status).toBe(0)
  401. expect(result.stdout.trim()).toBe(JSON.stringify({
  402. cwd: process.cwd(),
  403. value: 'explicit',
  404. arg: 'literal $HOME ${UNCHANGED}',
  405. }))
  406. } finally {
  407. cleanupRunnerFiles(files)
  408. }
  409. })
  410. it('reports target spawn failure without executing a fallback command', () => {
  411. const files = createRunnerFiles({
  412. argv: [`missing-dsh-runner-${Date.now()}`],
  413. cwd: process.cwd(),
  414. env: {},
  415. })
  416. try {
  417. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  418. expect(result.error).toBeUndefined()
  419. const events = readRunnerEvents(files.eventsPath)
  420. expect(events).toHaveLength(1)
  421. expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } })
  422. } finally {
  423. cleanupRunnerFiles(files)
  424. }
  425. })
  426. })