spawn-runner.spec.ts 46 KB

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