spawn-runner.spec.ts 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import type { ChildProcess } from 'node:child_process'
  3. import { EventEmitter } from 'node:events'
  4. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. import { describe, expect, it, vi } from 'vitest'
  9. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  10. import { Win32Error } from '@deepseek-ai/dsh-win32-process'
  11. import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process'
  12. import {
  13. cleanupAfterRunner,
  14. runnerDirectResult,
  15. runnerFiles,
  16. runnerStdio,
  17. spawnRunnerInvocation,
  18. } from '../src/runner-launch.ts'
  19. import { observeChildLifecycle } from '../src/managed-owner.ts'
  20. import {
  21. appendRunnerEvent,
  22. cleanupRunnerFiles,
  23. consumeRunnerRequest,
  24. createRunnerFiles,
  25. deserializeSpawnError,
  26. readRunnerEvents,
  27. readRunnerEventsAsync,
  28. serializeSpawnError,
  29. } from '../src/runner-protocol.ts'
  30. import { reportSpawnRunnerFailure, runSpawnRunner } from '../src/spawn-runner.ts'
  31. const sourceInvocation = [
  32. process.execPath,
  33. '--import',
  34. 'tsx/esm',
  35. fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')),
  36. ]
  37. function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  38. return {
  39. argv: [process.execPath, '-e', ''],
  40. cwd: process.cwd(),
  41. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
  42. graceMs: 100,
  43. ...overrides,
  44. }
  45. }
  46. function fakeChild(pid: number | undefined): ChildProcess {
  47. const child = new EventEmitter() as ChildProcess
  48. Object.assign(child, { pid, exitCode: null, signalCode: null })
  49. return child
  50. }
  51. class FakeRunnerHost extends EventEmitter {
  52. env: NodeJS.ProcessEnv = {}
  53. exitCode: number | undefined
  54. connected = false
  55. directory = process.cwd()
  56. readonly disconnect = vi.fn(() => { this.connected = false })
  57. cwd(): string { return this.directory }
  58. chdir(directory: string): void { this.directory = directory }
  59. }
  60. function asRunnerHost(host: FakeRunnerHost): Parameters<typeof runSpawnRunner>[1] {
  61. return host as unknown as Parameters<typeof runSpawnRunner>[1]
  62. }
  63. type RunnerInternals = NonNullable<Parameters<typeof runSpawnRunner>[2]>
  64. const fakeWin32Api = {} as Win32ProcessBindings
  65. const fakeProcessHandle = 60n as NativePtr
  66. const fakeJobHandle = 50n as NativePtr
  67. function fakeRunnerInternals(overrides: Partial<RunnerInternals> = {}): RunnerInternals {
  68. let nextPipeHandle = 70n
  69. return {
  70. spawn,
  71. loadWin32ProcessBindings: vi.fn(() => fakeWin32Api),
  72. openNamedPipeForStdio: vi.fn(() => nextPipeHandle++),
  73. spawnCurrentTokenJobProcess: vi.fn(() => ({
  74. pid: 1234,
  75. process: fakeProcessHandle,
  76. job: fakeJobHandle,
  77. })),
  78. pollProcessExit: vi.fn(() => 0),
  79. isJobEmpty: vi.fn(() => true),
  80. terminateJob: vi.fn(),
  81. waitForProcessExit: vi.fn(() => 0),
  82. closeHandleChecked: vi.fn(),
  83. ...overrides,
  84. } as RunnerInternals
  85. }
  86. function win32RunnerArgs(
  87. requestPath: string,
  88. eventsPath: string,
  89. pipes: string[] = [],
  90. ): string[] {
  91. return [
  92. '--mode', 'win32',
  93. '--request', requestPath,
  94. '--events', eventsPath,
  95. ...pipes,
  96. ]
  97. }
  98. function runRunner(invocation: string[], requestPath: string, eventsPath: string) {
  99. const [command, ...prefix] = invocation
  100. return spawnSync(command as string, [
  101. ...prefix,
  102. '--mode',
  103. 'node',
  104. '--request',
  105. requestPath,
  106. '--events',
  107. eventsPath,
  108. ], { encoding: 'utf8', timeout: 10_000 })
  109. }
  110. describe('spawn runner transport', () => {
  111. it('selects the source runner from source-plane execution', () => {
  112. expect(spawnRunnerInvocation()).toEqual(sourceInvocation)
  113. const manifest = JSON.parse(readFileSync(
  114. fileURLToPath(new URL('../package.json', import.meta.url)),
  115. 'utf8',
  116. )) as { exports: Record<string, { types: string; default: string }> }
  117. expect(manifest.exports['./spawn-runner']).toEqual({
  118. types: './lib/types/bin.d.ts',
  119. default: './lib/spawn-runner.js',
  120. })
  121. })
  122. it('observes runner events without SharedArrayBuffer', async () => {
  123. const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer')
  124. Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined })
  125. vi.resetModules()
  126. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  127. try {
  128. const isolated = await import('../src/runner-launch.ts')
  129. const result = isolated.runnerDirectResult(fakeChild(123), files, new Promise<void>(() => {}))
  130. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
  131. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
  132. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  133. expect(result.pid).toBe(456)
  134. } finally {
  135. cleanupRunnerFiles(files)
  136. if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer')
  137. else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor)
  138. vi.resetModules()
  139. }
  140. })
  141. it('re-enters a packaged executable through its private runner dispatch', () => {
  142. const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
  143. const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg')
  144. Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} })
  145. try {
  146. expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner'])
  147. } finally {
  148. if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg')
  149. else Object.defineProperty(packagedProcess, 'pkg', original)
  150. }
  151. })
  152. it('supports the node runner capability probe', () => {
  153. const result = spawnSync(sourceInvocation[0] as string, [
  154. ...sourceInvocation.slice(1),
  155. '--mode',
  156. 'probe-node',
  157. ], { encoding: 'utf8', timeout: 10_000 })
  158. expect(result.error).toBeUndefined()
  159. expect(result.status).toBe(0)
  160. })
  161. it('runs the Node target lifecycle in-process through the coverable runner logic', async () => {
  162. const files = createRunnerFiles({
  163. argv: [process.execPath, '-e', 'process.exit(12)'],
  164. cwd: process.cwd(),
  165. env: {},
  166. })
  167. const host = new FakeRunnerHost()
  168. try {
  169. await runSpawnRunner([
  170. '--mode', 'node',
  171. '--request', files.requestPath,
  172. '--events', files.eventsPath,
  173. ], asRunnerHost(host))
  174. expect(host.exitCode).toBe(12)
  175. expect(readRunnerEvents(files.eventsPath)).toEqual([
  176. expect.objectContaining({ type: 'started' }),
  177. { type: 'exit', exitCode: 12, signal: null },
  178. ])
  179. } finally {
  180. cleanupRunnerFiles(files)
  181. }
  182. })
  183. it('reports an in-process Node target spawn failure', async () => {
  184. const files = createRunnerFiles({
  185. argv: [`missing-dsh-runner-target-${String(process.pid)}-${String(Date.now())}`],
  186. cwd: process.cwd(),
  187. env: {},
  188. })
  189. const host = new FakeRunnerHost()
  190. try {
  191. await runSpawnRunner([
  192. '--mode', 'node',
  193. '--request', files.requestPath,
  194. '--events', files.eventsPath,
  195. ], asRunnerHost(host))
  196. expect(host.exitCode).toBe(127)
  197. const [event] = readRunnerEvents(files.eventsPath)
  198. expect(event?.type).toBe('spawn-error')
  199. if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
  200. expect(event.error.code).toBe('ENOENT')
  201. } finally {
  202. cleanupRunnerFiles(files)
  203. }
  204. })
  205. it('contains a post-start Node runner error and ignores scope signals', async () => {
  206. const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} })
  207. const host = new FakeRunnerHost()
  208. const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess
  209. const injectedSpawn = vi.fn(() => {
  210. queueMicrotask(() => {
  211. host.emit('SIGTERM')
  212. child.emit('spawn')
  213. child.emit('error', new Error('post-start node failure'))
  214. child.emit('exit', 0, null)
  215. })
  216. return child
  217. }) as unknown as typeof spawn
  218. try {
  219. await runSpawnRunner([
  220. '--mode', 'node',
  221. '--request', files.requestPath,
  222. '--events', files.eventsPath,
  223. ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn }))
  224. expect(host.exitCode).toBe(127)
  225. expect(readRunnerEvents(files.eventsPath)).toEqual([
  226. { type: 'started', pid: 4321 },
  227. { type: 'runner-error', error: { name: 'Error', message: 'post-start node failure' } },
  228. ])
  229. expect(host.listenerCount('SIGTERM')).toBe(0)
  230. } finally {
  231. cleanupRunnerFiles(files)
  232. }
  233. })
  234. it('maps a signal-only Node exit to the runner failure exit code', async () => {
  235. const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} })
  236. const host = new FakeRunnerHost()
  237. const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess
  238. const injectedSpawn = vi.fn(() => {
  239. queueMicrotask(() => {
  240. child.emit('spawn')
  241. child.emit('exit', null, 'SIGTERM')
  242. })
  243. return child
  244. }) as unknown as typeof spawn
  245. try {
  246. await runSpawnRunner([
  247. '--mode', 'node',
  248. '--request', files.requestPath,
  249. '--events', files.eventsPath,
  250. ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn }))
  251. expect(host.exitCode).toBe(1)
  252. expect(readRunnerEvents(files.eventsPath)).toEqual([
  253. { type: 'started', pid: 4321 },
  254. { type: 'exit', exitCode: null, signal: 'SIGTERM' },
  255. ])
  256. } finally {
  257. cleanupRunnerFiles(files)
  258. }
  259. })
  260. it('runs the in-process capability probes and always closes the probe Job', async () => {
  261. const nodeHost = new FakeRunnerHost()
  262. await expect(runSpawnRunner(
  263. ['--mode', 'probe-node'],
  264. asRunnerHost(nodeHost),
  265. fakeRunnerInternals(),
  266. )).resolves.toBeUndefined()
  267. const host = new FakeRunnerHost()
  268. host.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
  269. host.directory = 'C:\\runner'
  270. const internals = fakeRunnerInternals()
  271. await expect(runSpawnRunner(
  272. ['--mode', 'probe-win32'],
  273. asRunnerHost(host),
  274. internals,
  275. )).resolves.toBeUndefined()
  276. expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(fakeWin32Api, {
  277. command: 'C:\\Windows\\System32\\cmd.exe',
  278. args: ['/d', '/s', '/c', 'exit 0'],
  279. cwd: 'C:\\runner',
  280. })
  281. expect(internals.waitForProcessExit).toHaveBeenCalledWith(fakeWin32Api, fakeProcessHandle)
  282. expect(internals.closeHandleChecked).toHaveBeenCalledWith(
  283. fakeWin32Api,
  284. fakeJobHandle,
  285. 'subprocess Windows Job probe',
  286. )
  287. const legacyHost = new FakeRunnerHost()
  288. legacyHost.env.COMSPEC = 'legacy-cmd.exe'
  289. const failing = fakeRunnerInternals({ waitForProcessExit: vi.fn(() => 9) })
  290. await expect(runSpawnRunner(
  291. ['--mode', 'probe-win32'],
  292. asRunnerHost(legacyHost),
  293. failing,
  294. )).rejects.toThrow('probe exited with code 9')
  295. expect(failing.closeHandleChecked).toHaveBeenCalledWith(
  296. fakeWin32Api,
  297. fakeJobHandle,
  298. 'subprocess Windows Job probe',
  299. )
  300. await expect(runSpawnRunner(
  301. ['--mode', 'probe-win32'],
  302. asRunnerHost(new FakeRunnerHost()),
  303. fakeRunnerInternals(),
  304. )).rejects.toThrow('without ComSpec')
  305. })
  306. it('runs the Win32 target, forwards every pipe, and waits for an empty Job', async () => {
  307. vi.useFakeTimers()
  308. const files = createRunnerFiles({
  309. argv: ['tool.exe', 'literal $HOME'],
  310. cwd: 'C:\\target',
  311. env: { ONLY: 'kept' },
  312. })
  313. const host = new FakeRunnerHost()
  314. host.env.STALE = 'removed'
  315. host.directory = 'C:\\runner'
  316. host.connected = true
  317. const pollProcessExit = vi.fn()
  318. .mockReturnValueOnce(undefined)
  319. .mockReturnValueOnce(42)
  320. const isJobEmpty = vi.fn()
  321. .mockReturnValueOnce(false)
  322. .mockReturnValueOnce(true)
  323. const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty })
  324. try {
  325. const running = runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
  326. '--stdin-pipe', '\\\\.\\pipe\\stdin',
  327. '--stdout-pipe', '\\\\.\\pipe\\stdout',
  328. '--stderr-pipe', '\\\\.\\pipe\\stderr',
  329. ]), asRunnerHost(host), internals)
  330. await vi.advanceTimersByTimeAsync(30)
  331. await running
  332. expect(host.env).toEqual({ ONLY: 'kept' })
  333. expect(host.directory).toBe('C:\\runner')
  334. expect(host.disconnect).toHaveBeenCalledOnce()
  335. expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
  336. 1,
  337. fakeWin32Api,
  338. '\\\\.\\pipe\\stdin',
  339. 'read',
  340. )
  341. expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
  342. 2,
  343. fakeWin32Api,
  344. '\\\\.\\pipe\\stdout',
  345. 'write',
  346. )
  347. expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
  348. 3,
  349. fakeWin32Api,
  350. '\\\\.\\pipe\\stderr',
  351. 'write',
  352. )
  353. expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(
  354. fakeWin32Api,
  355. { command: 'tool.exe', args: ['literal $HOME'], cwd: 'C:\\target' },
  356. {
  357. stdin: 70n,
  358. stdout: 71n,
  359. stderr: 72n,
  360. },
  361. )
  362. expect(pollProcessExit).toHaveBeenCalledTimes(2)
  363. expect(isJobEmpty).toHaveBeenCalledTimes(2)
  364. expect(readRunnerEvents(files.eventsPath)).toEqual([
  365. { type: 'started', pid: 1234 },
  366. { type: 'exit', exitCode: 42, signal: null },
  367. ])
  368. expect(internals.closeHandleChecked).toHaveBeenCalledWith(
  369. fakeWin32Api,
  370. fakeProcessHandle,
  371. 'ordinary direct process',
  372. )
  373. expect(internals.closeHandleChecked).toHaveBeenCalledWith(
  374. fakeWin32Api,
  375. fakeJobHandle,
  376. 'ordinary process Job',
  377. )
  378. } finally {
  379. vi.useRealTimers()
  380. cleanupRunnerFiles(files)
  381. }
  382. })
  383. it('accepts only the Win32 terminate IPC message and coalesces disconnect', async () => {
  384. vi.useFakeTimers()
  385. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  386. const host = new FakeRunnerHost()
  387. const internals = fakeRunnerInternals()
  388. try {
  389. const running = runSpawnRunner(
  390. win32RunnerArgs(files.requestPath, files.eventsPath),
  391. asRunnerHost(host),
  392. internals,
  393. )
  394. host.emit('message', null)
  395. host.emit('message', 'terminate')
  396. host.emit('message', { type: 'other' })
  397. host.emit('message', { type: 'terminate' })
  398. host.emit('message', { type: 'terminate' })
  399. host.emit('disconnect')
  400. await vi.advanceTimersByTimeAsync(10)
  401. await running
  402. expect(internals.terminateJob).toHaveBeenCalledOnce()
  403. expect(internals.terminateJob).toHaveBeenCalledWith(fakeWin32Api, fakeJobHandle, 1)
  404. expect(host.disconnect).not.toHaveBeenCalled()
  405. expect(readRunnerEvents(files.eventsPath)).toEqual([
  406. { type: 'started', pid: 1234 },
  407. { type: 'exit', exitCode: 0, signal: null },
  408. ])
  409. } finally {
  410. vi.useRealTimers()
  411. cleanupRunnerFiles(files)
  412. }
  413. })
  414. it('reports a non-Error Win32 termination failure and closes both live handles', async () => {
  415. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  416. const host = new FakeRunnerHost()
  417. host.connected = true
  418. const terminateJob = vi.fn(() => { throw 'raw termination failure' })
  419. const internals = fakeRunnerInternals({ terminateJob })
  420. try {
  421. const running = runSpawnRunner(
  422. win32RunnerArgs(files.requestPath, files.eventsPath),
  423. asRunnerHost(host),
  424. internals,
  425. )
  426. host.emit('disconnect')
  427. await running
  428. expect(host.exitCode).toBe(127)
  429. expect(host.disconnect).toHaveBeenCalledOnce()
  430. expect(readRunnerEvents(files.eventsPath)).toEqual([
  431. { type: 'started', pid: 1234 },
  432. { type: 'runner-error', error: { name: 'Error', message: 'raw termination failure' } },
  433. ])
  434. expect(internals.closeHandleChecked).toHaveBeenCalledWith(
  435. fakeWin32Api,
  436. fakeProcessHandle,
  437. 'ordinary direct process cleanup',
  438. )
  439. expect(internals.closeHandleChecked).toHaveBeenCalledWith(
  440. fakeWin32Api,
  441. fakeJobHandle,
  442. 'ordinary process Job cleanup',
  443. )
  444. } finally {
  445. cleanupRunnerFiles(files)
  446. }
  447. })
  448. it.each([
  449. [2, 'ENOENT'],
  450. [3, 'ENOENT'],
  451. [267, 'ENOENT'],
  452. [5, 'EACCES'],
  453. [193, 'EFTYPE'],
  454. [999, 'UNKNOWN'],
  455. ] as const)('maps Win32 CreateProcess error %i to %s', async (win32Code, code) => {
  456. const files = createRunnerFiles({
  457. argv: ['missing.exe', 'literal argument'],
  458. cwd: 'C:\\target',
  459. env: {},
  460. })
  461. const host = new FakeRunnerHost()
  462. const internals = fakeRunnerInternals({
  463. spawnCurrentTokenJobProcess: vi.fn(() => {
  464. throw new Win32Error('CreateProcessW', win32Code)
  465. }),
  466. })
  467. try {
  468. await runSpawnRunner(
  469. win32RunnerArgs(files.requestPath, files.eventsPath),
  470. asRunnerHost(host),
  471. internals,
  472. )
  473. expect(host.exitCode).toBeUndefined()
  474. const [event] = readRunnerEvents(files.eventsPath)
  475. expect(event?.type).toBe('spawn-error')
  476. if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
  477. expect(event.error).toMatchObject({
  478. code,
  479. syscall: 'spawn missing.exe',
  480. path: 'missing.exe',
  481. spawnargs: ['literal argument'],
  482. })
  483. } finally {
  484. cleanupRunnerFiles(files)
  485. }
  486. })
  487. it.each([
  488. [undefined, false],
  489. ['ENOENT', true],
  490. ] as const)('maps a target chdir failure with code %s', async (code, hasSpawnShape) => {
  491. const files = createRunnerFiles({ argv: ['tool.exe', 'arg'], cwd: 'C:\\missing', env: {} })
  492. const host = new FakeRunnerHost()
  493. const error = Object.assign(new Error('target cwd failed'), {
  494. syscall: 'chdir',
  495. ...code === undefined ? {} : { code },
  496. })
  497. host.chdir = vi.fn(() => { throw error })
  498. try {
  499. await runSpawnRunner(
  500. win32RunnerArgs(files.requestPath, files.eventsPath),
  501. asRunnerHost(host),
  502. fakeRunnerInternals(),
  503. )
  504. expect(host.exitCode).toBeUndefined()
  505. const [event] = readRunnerEvents(files.eventsPath)
  506. expect(event?.type).toBe('spawn-error')
  507. if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
  508. expect(typeof event.error.message).toBe('string')
  509. expect('path' in event.error).toBe(hasSpawnShape)
  510. if (hasSpawnShape) {
  511. expect(event.error).toMatchObject({
  512. code: 'ENOENT',
  513. syscall: 'spawn tool.exe',
  514. path: 'tool.exe',
  515. spawnargs: ['arg'],
  516. })
  517. } else {
  518. expect(event.error).toMatchObject({ message: 'target cwd failed', syscall: 'chdir' })
  519. }
  520. } finally {
  521. cleanupRunnerFiles(files)
  522. }
  523. })
  524. it.each([
  525. ['a non-CreateProcess Win32 error', new Win32Error('CreateFileW', 5), 'Win32Error'],
  526. ['a non-Error setup failure', 'raw pipe setup failure', 'Error'],
  527. ])('reports %s as runner infrastructure failure', async (_label, failure, name) => {
  528. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  529. const host = new FakeRunnerHost()
  530. const internals = fakeRunnerInternals({
  531. openNamedPipeForStdio: vi.fn(() => { throw failure }),
  532. })
  533. try {
  534. await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
  535. '--stdin-pipe', '\\\\.\\pipe\\stdin',
  536. ]), asRunnerHost(host), internals)
  537. expect(host.exitCode).toBe(127)
  538. const [event] = readRunnerEvents(files.eventsPath)
  539. expect(event?.type).toBe('runner-error')
  540. if (event?.type !== 'runner-error') throw new Error('expected runner error')
  541. expect(event.error.name).toBe(name)
  542. } finally {
  543. cleanupRunnerFiles(files)
  544. }
  545. })
  546. it.each([
  547. ['an Error', new Error('stdio close failed')],
  548. ['a non-Error value', 'raw stdio close failure'],
  549. ])('reports %s from the initial stdio close and retries cleanup', async (_label, failure) => {
  550. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  551. let failedOnce = false
  552. const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
  553. if (!failedOnce && label.includes('pipe')) {
  554. failedOnce = true
  555. throw failure
  556. }
  557. })
  558. const internals = fakeRunnerInternals({ closeHandleChecked })
  559. try {
  560. await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
  561. '--stdin-pipe', '\\\\.\\pipe\\stdin',
  562. ]), asRunnerHost(new FakeRunnerHost()), internals)
  563. expect(readRunnerEvents(files.eventsPath)).toEqual([
  564. { type: 'started', pid: 1234 },
  565. {
  566. type: 'runner-error',
  567. error: { name: 'Error', message: failure instanceof Error ? failure.message : failure },
  568. },
  569. ])
  570. expect(closeHandleChecked).toHaveBeenCalledWith(
  571. fakeWin32Api,
  572. 70n,
  573. 'ordinary target stdin pipe',
  574. )
  575. expect(closeHandleChecked).toHaveBeenCalledWith(
  576. fakeWin32Api,
  577. 70n,
  578. 'ordinary target stdin pipe',
  579. )
  580. } finally {
  581. cleanupRunnerFiles(files)
  582. }
  583. })
  584. it('preserves the first stdio close failure while retaining every failed handle', async () => {
  585. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  586. let remainingFailures = 2
  587. const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
  588. if (remainingFailures > 0 && label.includes('pipe')) {
  589. remainingFailures -= 1
  590. throw remainingFailures === 1 ? new Error('first close failure') : 'second close failure'
  591. }
  592. })
  593. const internals = fakeRunnerInternals({ closeHandleChecked })
  594. try {
  595. await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
  596. '--stdin-pipe', '\\\\.\\pipe\\stdin',
  597. '--stdout-pipe', '\\\\.\\pipe\\stdout',
  598. ]), asRunnerHost(new FakeRunnerHost()), internals)
  599. expect(readRunnerEvents(files.eventsPath)).toContainEqual({
  600. type: 'runner-error',
  601. error: { name: 'Error', message: 'first close failure' },
  602. })
  603. expect(closeHandleChecked).toHaveBeenCalledTimes(6)
  604. } finally {
  605. cleanupRunnerFiles(files)
  606. }
  607. })
  608. it.each([
  609. ['poll', 'poll failed'],
  610. ['direct close', 'direct close failed'],
  611. ['Job query', 'Job query failed'],
  612. ['Job close', 'Job close failed'],
  613. ] as const)('reports a Win32 %s failure and cleans remaining handles', async (stage, message) => {
  614. vi.useFakeTimers()
  615. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  616. const pollProcessExit = vi.fn(() => {
  617. if (stage === 'poll') throw new Error(message)
  618. return 0
  619. })
  620. const isJobEmpty = vi.fn(() => {
  621. if (stage === 'Job query') throw new Error(message)
  622. return true
  623. })
  624. const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
  625. if (stage === 'direct close' && label === 'ordinary direct process') {
  626. throw new Error(message)
  627. }
  628. if (stage === 'Job close' && label === 'ordinary process Job') {
  629. throw new Error(message)
  630. }
  631. if (label.endsWith('cleanup')) throw new Error('ignored cleanup failure')
  632. })
  633. const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty, closeHandleChecked })
  634. try {
  635. const running = runSpawnRunner(
  636. win32RunnerArgs(files.requestPath, files.eventsPath),
  637. asRunnerHost(new FakeRunnerHost()),
  638. internals,
  639. )
  640. await vi.advanceTimersByTimeAsync(10)
  641. await running
  642. expect(readRunnerEvents(files.eventsPath)).toEqual([
  643. { type: 'started', pid: 1234 },
  644. ...stage === 'poll' ? [] : [{ type: 'exit' as const, exitCode: 0, signal: null }],
  645. { type: 'runner-error', error: { name: 'Error', message } },
  646. ])
  647. expect(closeHandleChecked).toHaveBeenCalledWith(
  648. fakeWin32Api,
  649. fakeJobHandle,
  650. expect.stringContaining('Job'),
  651. )
  652. } finally {
  653. vi.useRealTimers()
  654. cleanupRunnerFiles(files)
  655. }
  656. })
  657. it('preserves the first failure when termination settles reentrantly during polling', async () => {
  658. vi.useFakeTimers()
  659. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  660. const host = new FakeRunnerHost()
  661. const terminateJob = vi.fn(() => { throw new Error('reentrant termination failed') })
  662. const pollProcessExit = vi.fn(() => {
  663. host.emit('disconnect')
  664. return 0
  665. })
  666. const internals = fakeRunnerInternals({ terminateJob, pollProcessExit })
  667. try {
  668. const running = runSpawnRunner(
  669. win32RunnerArgs(files.requestPath, files.eventsPath),
  670. asRunnerHost(host),
  671. internals,
  672. )
  673. await vi.advanceTimersByTimeAsync(10)
  674. await running
  675. expect(readRunnerEvents(files.eventsPath)).toEqual([
  676. { type: 'started', pid: 1234 },
  677. { type: 'exit', exitCode: 0, signal: null },
  678. {
  679. type: 'runner-error',
  680. error: { name: 'Error', message: 'reentrant termination failed' },
  681. },
  682. ])
  683. } finally {
  684. vi.useRealTimers()
  685. cleanupRunnerFiles(files)
  686. }
  687. })
  688. it('reports failure while restoring cwd after a successful Win32 spawn', async () => {
  689. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  690. const host = new FakeRunnerHost()
  691. host.directory = 'C:\\runner'
  692. const chdir = vi.fn((directory: string) => {
  693. if (directory === 'C:\\runner') throw new Error('cwd restore failed')
  694. host.directory = directory
  695. })
  696. host.chdir = chdir
  697. try {
  698. await runSpawnRunner(
  699. win32RunnerArgs(files.requestPath, files.eventsPath),
  700. asRunnerHost(host),
  701. fakeRunnerInternals(),
  702. )
  703. expect(chdir).toHaveBeenCalledTimes(2)
  704. expect(host.exitCode).toBe(127)
  705. expect(readRunnerEvents(files.eventsPath)).toEqual([
  706. { type: 'started', pid: 1234 },
  707. { type: 'runner-error', error: { name: 'Error', message: 'cwd restore failed' } },
  708. ])
  709. } finally {
  710. cleanupRunnerFiles(files)
  711. }
  712. })
  713. it('disconnects after an uncaught Win32 binding setup failure', async () => {
  714. const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
  715. const host = new FakeRunnerHost()
  716. host.connected = true
  717. const internals = fakeRunnerInternals({
  718. loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding setup failed') }),
  719. })
  720. try {
  721. await expect(runSpawnRunner(
  722. win32RunnerArgs(files.requestPath, files.eventsPath),
  723. asRunnerHost(host),
  724. internals,
  725. )).rejects.toThrow('binding setup failed')
  726. expect(host.disconnect).toHaveBeenCalledOnce()
  727. expect(readRunnerEvents(files.eventsPath)).toEqual([])
  728. } finally {
  729. cleanupRunnerFiles(files)
  730. }
  731. })
  732. it.each([
  733. [['--mode'], 'missing value'],
  734. [['--unknown', 'value'], 'unknown argument'],
  735. [['--mode', 'unknown'], 'unknown mode'],
  736. [['--mode', 'node'], 'requires request and event paths'],
  737. ] as const)('rejects invalid runner arguments: %s', async (argv, message) => {
  738. await expect(runSpawnRunner([...argv], asRunnerHost(new FakeRunnerHost()))).rejects.toThrow(message)
  739. })
  740. it('reports only failures whose arguments identify an event transport', () => {
  741. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  742. try {
  743. reportSpawnRunnerFailure([
  744. '--mode', 'node',
  745. '--request', files.requestPath,
  746. '--events', files.eventsPath,
  747. ], new Error('runner main failed'))
  748. reportSpawnRunnerFailure(['--mode', 'probe-node'], new Error('ignored probe failure'))
  749. reportSpawnRunnerFailure(['--mode'], new Error('unparseable failure'))
  750. expect(readRunnerEvents(files.eventsPath)).toEqual([
  751. { type: 'runner-error', error: { name: 'Error', message: 'runner main failed' } },
  752. ])
  753. } finally {
  754. cleanupRunnerFiles(files)
  755. }
  756. })
  757. it('maps every target stdio disposition', () => {
  758. expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
  759. expect(runnerStdio(spec({
  760. stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
  761. }))).toEqual(['pipe', 'inherit', 'inherit'])
  762. })
  763. it('materializes and consumes the exact runner request once', () => {
  764. const removed = `DSH_RUNNER_REMOVED_${process.pid}`
  765. const files = runnerFiles(spec({
  766. argv: [process.execPath, 'literal $HOME'],
  767. env: { RUNNER_VALUE: 'explicit', [removed]: undefined },
  768. }))
  769. try {
  770. const request = consumeRunnerRequest(files.requestPath)
  771. expect(request.argv).toEqual([process.execPath, 'literal $HOME'])
  772. expect(request.cwd).toBe(process.cwd())
  773. expect(request.env.RUNNER_VALUE).toBe('explicit')
  774. expect(request.env).not.toHaveProperty(removed)
  775. expect(existsSync(files.requestPath)).toBe(false)
  776. } finally {
  777. cleanupRunnerFiles(files)
  778. }
  779. })
  780. it.each([
  781. ['non-object request', null, 'no executable'],
  782. ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'],
  783. ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'],
  784. ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'],
  785. ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'],
  786. ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'],
  787. ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'],
  788. ])('rejects an invalid %s', (_label, request, message) => {
  789. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  790. try {
  791. writeFileSync(files.requestPath, JSON.stringify(request))
  792. expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message)
  793. } finally {
  794. cleanupRunnerFiles(files)
  795. }
  796. })
  797. it('unlinks a substituted runner-directory link without traversing it', () => {
  798. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  799. const outside = mkdtempSync(join(tmpdir(), 'dsh-runner-outside-'))
  800. const sentinel = join(outside, 'events.ndjson')
  801. writeFileSync(sentinel, 'keep')
  802. rmSync(files.directory, { recursive: true, force: true })
  803. symlinkSync(outside, files.directory, process.platform === 'win32' ? 'junction' : 'dir')
  804. try {
  805. cleanupRunnerFiles(files)
  806. expect(existsSync(files.directory)).toBe(false)
  807. expect(existsSync(sentinel)).toBe(true)
  808. } finally {
  809. rmSync(files.directory, { recursive: true, force: true })
  810. rmSync(outside, { recursive: true, force: true })
  811. }
  812. })
  813. it('contains an unexpected owned-path cleanup failure', () => {
  814. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  815. rmSync(files.requestPath, { force: true })
  816. mkdirSync(files.requestPath)
  817. try {
  818. expect(() => { cleanupRunnerFiles(files) }).not.toThrow()
  819. expect(existsSync(files.directory)).toBe(true)
  820. } finally {
  821. rmSync(files.directory, { recursive: true, force: true })
  822. }
  823. })
  824. it('reads only complete known event records and propagates file errors', async () => {
  825. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  826. try {
  827. expect(readRunnerEvents(files.eventsPath)).toEqual([])
  828. await expect(readRunnerEventsAsync(join(files.directory, 'missing.ndjson'))).resolves.toEqual([])
  829. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 })
  830. appendRunnerEvent(files.eventsPath, {
  831. type: 'runner-error',
  832. error: { name: 'Error', message: 'runner failed' },
  833. })
  834. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: null, signal: 'SIGTERM' })
  835. appendRunnerEvent(files.eventsPath, {
  836. type: 'spawn-error',
  837. error: {
  838. name: 'Error',
  839. message: 'spawn failed',
  840. code: 'ENOENT',
  841. errno: -2,
  842. syscall: 'spawn missing',
  843. path: 'missing',
  844. spawnargs: ['argument'],
  845. },
  846. })
  847. expect(readRunnerEvents(files.eventsPath)).toEqual([
  848. { type: 'started', pid: 123 },
  849. { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } },
  850. { type: 'exit', exitCode: null, signal: 'SIGTERM' },
  851. {
  852. type: 'spawn-error',
  853. error: {
  854. name: 'Error',
  855. message: 'spawn failed',
  856. code: 'ENOENT',
  857. errno: -2,
  858. syscall: 'spawn missing',
  859. path: 'missing',
  860. spawnargs: ['argument'],
  861. },
  862. },
  863. ])
  864. await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual(readRunnerEvents(files.eventsPath))
  865. writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"')
  866. expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }])
  867. for (const event of [null, []]) {
  868. writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
  869. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event')
  870. }
  871. writeFileSync(files.eventsPath, '{"type":"unknown"}\n')
  872. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event')
  873. await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event')
  874. expect(() => readRunnerEvents(files.directory)).toThrow()
  875. await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow()
  876. } finally {
  877. cleanupRunnerFiles(files)
  878. }
  879. })
  880. it.each([
  881. ['started without a pid', { type: 'started' }],
  882. ['started with a non-number pid', { type: 'started', pid: '1' }],
  883. ['started with a fractional pid', { type: 'started', pid: 1.5 }],
  884. ['started with a non-positive pid', { type: 'started', pid: 0 }],
  885. ['exit with a missing code', { type: 'exit', signal: null }],
  886. ['exit with a non-number code', { type: 'exit', exitCode: '0', signal: null }],
  887. ['exit with a fractional code', { type: 'exit', exitCode: 1.5, signal: null }],
  888. ['exit with a negative code', { type: 'exit', exitCode: -1, signal: null }],
  889. ['exit with a non-string signal', { type: 'exit', exitCode: 0, signal: 9 }],
  890. ['exit with an unknown signal', { type: 'exit', exitCode: 0, signal: 'NOT_A_SIGNAL' }],
  891. ['spawn error without an object', { type: 'spawn-error', error: null }],
  892. ['spawn error without a name', { type: 'spawn-error', error: { message: 'failed' } }],
  893. ['spawn error without a message', { type: 'spawn-error', error: { name: 'Error' } }],
  894. ['spawn error with a numeric code', { type: 'spawn-error', error: { name: 'Error', message: 'failed', code: 1 } }],
  895. ['spawn error with a string errno', { type: 'spawn-error', error: { name: 'Error', message: 'failed', errno: '1' } }],
  896. ['spawn error with a numeric syscall', { type: 'spawn-error', error: { name: 'Error', message: 'failed', syscall: 1 } }],
  897. ['spawn error with a numeric path', { type: 'spawn-error', error: { name: 'Error', message: 'failed', path: 1 } }],
  898. ['spawn error with non-array args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: 'arg' } }],
  899. ['spawn error with non-string args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: [1] } }],
  900. ])('rejects an invalid event payload: %s', (_label, event) => {
  901. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  902. try {
  903. writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
  904. expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event')
  905. } finally {
  906. cleanupRunnerFiles(files)
  907. }
  908. })
  909. it('creates private request files and preserves Node-shaped error fields', () => {
  910. const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} })
  911. try {
  912. if (process.platform !== 'win32') expect(statSync(files.requestPath).mode & 0o777).toBe(0o600)
  913. const source = Object.assign(new Error('spawn missing ENOENT'), {
  914. code: 'ENOENT',
  915. errno: -2,
  916. syscall: 'spawn missing',
  917. path: 'missing',
  918. spawnargs: ['literal $VALUE'],
  919. })
  920. const restored = deserializeSpawnError(serializeSpawnError(source)) as NodeJS.ErrnoException & {
  921. path?: string
  922. spawnargs?: string[]
  923. }
  924. expect(restored).toMatchObject({
  925. message: 'spawn missing ENOENT',
  926. code: 'ENOENT',
  927. errno: -2,
  928. syscall: 'spawn missing',
  929. path: 'missing',
  930. spawnargs: ['literal $VALUE'],
  931. })
  932. const minimal = serializeSpawnError('plain failure')
  933. expect(minimal).toEqual({ name: 'Error', message: 'plain failure' })
  934. expect(deserializeSpawnError(minimal)).toMatchObject({ name: 'Error', message: 'plain failure' })
  935. } finally {
  936. cleanupRunnerFiles(files)
  937. }
  938. })
  939. it('maps runner failures and missing direct results', async () => {
  940. const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  941. try {
  942. appendRunnerEvent(runnerFailure.eventsPath, {
  943. type: 'runner-error',
  944. error: { name: 'Error', message: 'runner setup failed', code: 'EIO' },
  945. })
  946. const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise<void>(() => {}))
  947. expect(result.pid).toBeUndefined()
  948. await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' })
  949. } finally {
  950. cleanupRunnerFiles(runnerFailure)
  951. }
  952. const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  953. try {
  954. appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 })
  955. const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise<void>(() => {}))
  956. const directFailure = result.direct.catch((error: unknown) => error)
  957. appendRunnerEvent(afterStartFailure.eventsPath, {
  958. type: 'runner-error',
  959. error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' },
  960. })
  961. await vi.waitFor(() => { expect(result.pid).toBe(456) })
  962. await expect(directFailure).resolves.toMatchObject({ message: 'post-start runner failed', code: 'EIO' })
  963. } finally {
  964. cleanupRunnerFiles(afterStartFailure)
  965. }
  966. const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  967. try {
  968. appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 })
  969. const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve())
  970. await vi.waitFor(() => { expect(result.pid).toBe(456) })
  971. await expect(result.direct).rejects.toThrow('exited without a direct-command result')
  972. } finally {
  973. cleanupRunnerFiles(missing)
  974. }
  975. })
  976. it('publishes terminal events already present when asynchronous observation starts', async () => {
  977. const failed = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  978. try {
  979. appendRunnerEvent(failed.eventsPath, {
  980. type: 'spawn-error',
  981. error: { name: 'Error', message: 'target missing', code: 'ENOENT' },
  982. })
  983. const result = runnerDirectResult(fakeChild(123), failed, new Promise<void>(() => {}))
  984. await expect(result.direct).rejects.toMatchObject({ message: 'target missing', code: 'ENOENT' })
  985. } finally {
  986. cleanupRunnerFiles(failed)
  987. }
  988. const exited = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  989. try {
  990. appendRunnerEvent(exited.eventsPath, { type: 'started', pid: 456 })
  991. appendRunnerEvent(exited.eventsPath, { type: 'exit', exitCode: 23, signal: null })
  992. const result = runnerDirectResult(fakeChild(123), exited, new Promise<void>(() => {}))
  993. await expect(result.direct).resolves.toEqual({ exitCode: 23, signal: null })
  994. expect(result.pid).toBe(456)
  995. } finally {
  996. cleanupRunnerFiles(exited)
  997. }
  998. })
  999. it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => {
  1000. const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
  1001. let readCount = 0
  1002. vi.resetModules()
  1003. vi.doMock('../src/runner-protocol.ts', async (importOriginal) => {
  1004. const actual = await importOriginal<typeof import('../src/runner-protocol.ts')>()
  1005. return {
  1006. ...actual,
  1007. readRunnerEventsAsync: vi.fn(async (eventsPath: string) => {
  1008. readCount += 1
  1009. if (readCount === 1) return staleRead.promise
  1010. return actual.readRunnerEvents(eventsPath)
  1011. }),
  1012. }
  1013. })
  1014. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  1015. try {
  1016. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
  1017. const exited = Promise.withResolvers<undefined>()
  1018. const isolated = await import('../src/runner-launch.ts')
  1019. const result = isolated.runnerDirectResult(fakeChild(123), files, exited.promise)
  1020. expect(readCount).toBe(1)
  1021. exited.resolve(undefined)
  1022. await Promise.resolve()
  1023. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
  1024. staleRead.resolve([{ type: 'started', pid: 456 }])
  1025. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  1026. expect(readCount).toBe(2)
  1027. } finally {
  1028. cleanupRunnerFiles(files)
  1029. vi.doUnmock('../src/runner-protocol.ts')
  1030. vi.resetModules()
  1031. }
  1032. })
  1033. it('reports a missing direct result at runner exit without waiting for pipe close', async () => {
  1034. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  1035. try {
  1036. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
  1037. const child = new EventEmitter() as ChildProcess
  1038. Object.assign(child, { pid: 123, exitCode: null, signalCode: null })
  1039. const lifecycle = observeChildLifecycle(child)
  1040. const result = runnerDirectResult(child, files, lifecycle.exited)
  1041. child.emit('exit', 1, null)
  1042. await expect(result.direct).rejects.toThrow('exited without a direct-command result')
  1043. child.emit('close', 1, null)
  1044. await lifecycle.closed
  1045. } finally {
  1046. cleanupRunnerFiles(files)
  1047. }
  1048. })
  1049. it('contains wrapper spawn errors while publishing the runner startup rejection', async () => {
  1050. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  1051. try {
  1052. const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
  1053. stdio: 'ignore',
  1054. })
  1055. const lifecycle = observeChildLifecycle(child)
  1056. const result = runnerDirectResult(child, files, lifecycle.exited)
  1057. expect(result.pid).toBeUndefined()
  1058. await expect(result.direct).rejects.toThrow('runner failed to start')
  1059. await expect(lifecycle.closed).resolves.toBeUndefined()
  1060. } finally {
  1061. cleanupRunnerFiles(files)
  1062. }
  1063. })
  1064. it('returns before target publication and updates the pid getter from runner events', async () => {
  1065. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  1066. try {
  1067. const result = runnerDirectResult(fakeChild(process.pid), files, new Promise<void>(() => {}))
  1068. expect(result.pid).toBeUndefined()
  1069. appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
  1070. await vi.waitFor(() => { expect(result.pid).toBe(456) })
  1071. appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
  1072. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  1073. } finally {
  1074. cleanupRunnerFiles(files)
  1075. }
  1076. })
  1077. it('cleans runner files only after the direct result and runner close settle', async () => {
  1078. const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
  1079. const closed = Promise.withResolvers<undefined>()
  1080. cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), closed.promise)
  1081. await new Promise(resolve => setImmediate(resolve))
  1082. expect(existsSync(files.directory)).toBe(true)
  1083. closed.resolve(undefined)
  1084. await new Promise(resolve => setImmediate(resolve))
  1085. expect(existsSync(files.directory)).toBe(false)
  1086. })
  1087. it('reports the direct target pid and exit outcome from the source entry', () => {
  1088. const files = createRunnerFiles({
  1089. argv: [process.execPath, '-e', 'process.exit(7)'],
  1090. cwd: process.cwd(),
  1091. env: {},
  1092. })
  1093. try {
  1094. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  1095. expect(result.error).toBeUndefined()
  1096. const events = readRunnerEvents(files.eventsPath)
  1097. expect(events).toHaveLength(2)
  1098. expect(events[0]?.type).toBe('started')
  1099. if (events[0]?.type !== 'started') throw new Error('expected started event')
  1100. expect(events[0].pid).toBeGreaterThan(0)
  1101. expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null })
  1102. } finally {
  1103. cleanupRunnerFiles(files)
  1104. }
  1105. })
  1106. it('preserves literal argv, cwd, and the exact target environment', () => {
  1107. const files = createRunnerFiles({
  1108. argv: [
  1109. process.execPath,
  1110. '-e',
  1111. 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))',
  1112. 'literal $HOME ${UNCHANGED}',
  1113. ],
  1114. cwd: process.cwd(),
  1115. env: { RUNNER_VALUE: 'explicit' },
  1116. })
  1117. try {
  1118. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  1119. expect(result.status).toBe(0)
  1120. expect(result.stdout.trim()).toBe(JSON.stringify({
  1121. cwd: process.cwd(),
  1122. value: 'explicit',
  1123. arg: 'literal $HOME ${UNCHANGED}',
  1124. }))
  1125. } finally {
  1126. cleanupRunnerFiles(files)
  1127. }
  1128. })
  1129. it('reports target spawn failure without executing a fallback command', () => {
  1130. const files = createRunnerFiles({
  1131. argv: [`missing-dsh-runner-${Date.now()}`],
  1132. cwd: process.cwd(),
  1133. env: {},
  1134. })
  1135. try {
  1136. const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
  1137. expect(result.error).toBeUndefined()
  1138. const events = readRunnerEvents(files.eventsPath)
  1139. expect(events).toHaveLength(1)
  1140. expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } })
  1141. } finally {
  1142. cleanupRunnerFiles(files)
  1143. }
  1144. })
  1145. })