spawn-runner.spec.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. import { EventEmitter } from 'node:events'
  2. import { spawnSync } from 'node:child_process'
  3. import {
  4. existsSync,
  5. mkdtempSync,
  6. mkdirSync,
  7. readdirSync,
  8. rmSync,
  9. statSync,
  10. symlinkSync,
  11. unlinkSync,
  12. writeFileSync,
  13. } from 'node:fs'
  14. import { tmpdir } from 'node:os'
  15. import { join, posix, resolve } from 'node:path'
  16. import { afterEach, describe, expect, it, vi } from 'vitest'
  17. import { Win32Error } from '@deepseek-ai/dsh-win32-process'
  18. import type {
  19. CurrentTokenProcessBindings,
  20. NativePtr,
  21. } from '@deepseek-ai/dsh-win32-process'
  22. import {
  23. cleanupLinuxLaunchFiles,
  24. consumeLinuxLaunchRequest,
  25. createLinuxLaunchFiles,
  26. deserializeRunnerError,
  27. isWindowsTerminateRequest,
  28. linuxLaunchFilesFromLocator,
  29. parseWindowsRunnerResult,
  30. parseWindowsStartRequest,
  31. readLinuxStartupError,
  32. serializeRunnerError,
  33. writeLinuxStartupError,
  34. } from '../src/runner-protocol.ts'
  35. import {
  36. consumeRunnerSelection,
  37. parseRunnerTargetArgv,
  38. runnerEnvironment,
  39. runnerInvocationAvailable,
  40. runnerStdio,
  41. resolveWindowsExecutable,
  42. spawnRunnerInvocation,
  43. SUBPROCESS_RUNNER_ENV,
  44. targetEnvironment,
  45. WINDOWS_RUNNER_SELECTION,
  46. } from '../src/runner-launch.ts'
  47. import {
  48. reportSpawnRunnerFailure,
  49. runSpawnRunner,
  50. } from '../src/spawn-runner.ts'
  51. import type { SpawnRunnerInternals } from '../src/spawn-runner.ts'
  52. const scratch: string[] = []
  53. afterEach(() => {
  54. for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true })
  55. vi.restoreAllMocks()
  56. })
  57. function track<T extends { directory: string }>(files: T): T {
  58. scratch.push(files.directory)
  59. return files
  60. }
  61. class FakeRunnerHost extends EventEmitter {
  62. env: NodeJS.ProcessEnv = { [SUBPROCESS_RUNNER_ENV]: 'stale', SAFE: 'bootstrap' }
  63. exitCode: number | undefined
  64. connected = true
  65. directory = process.cwd()
  66. sent: unknown[] = []
  67. sendFailure: Error | undefined
  68. sendThrown: unknown
  69. cwd(): string { return this.directory }
  70. chdir(path: string): void { this.directory = posix.resolve(this.directory, path) }
  71. disconnect(): void {
  72. if (!this.connected) return
  73. this.connected = false
  74. this.emit('disconnect')
  75. }
  76. send(message: unknown, callback?: (error: Error | null) => void): boolean {
  77. if (this.sendThrown !== undefined) throw this.sendThrown
  78. this.sent.push(message)
  79. queueMicrotask(() => { callback?.(this.sendFailure ?? null) })
  80. return true
  81. }
  82. }
  83. function hostArgument(host: FakeRunnerHost): Parameters<typeof runSpawnRunner>[2] {
  84. return host as unknown as Parameters<typeof runSpawnRunner>[2]
  85. }
  86. function internals(overrides: Partial<SpawnRunnerInternals> = {}): SpawnRunnerInternals {
  87. return {
  88. execve: vi.fn(() => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) }),
  89. loadWin32ProcessBindings: vi.fn(() => ({} as CurrentTokenProcessBindings)),
  90. spawnCurrentTokenJobProcess: vi.fn(() => ({
  91. pid: 123,
  92. process: 10n as NativePtr,
  93. job: 20n as NativePtr,
  94. })),
  95. closeFileDescriptor: vi.fn(),
  96. resolveWindowsExecutable: vi.fn(() => 'C:\\resolved\\tool.exe'),
  97. pollProcessExit: vi.fn(() => 0),
  98. isJobEmpty: vi.fn(() => true),
  99. terminateJob: vi.fn(),
  100. closeHandleChecked: vi.fn(),
  101. ...overrides,
  102. }
  103. }
  104. async function runWindows(
  105. host: FakeRunnerHost,
  106. native: SpawnRunnerInternals,
  107. start: unknown = { type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes', dsh_subprocess_runner: 'restored' } },
  108. targetArgv: string[] = ['tool.exe', 'literal arg'],
  109. ): Promise<void> {
  110. const running = runSpawnRunner(
  111. WINDOWS_RUNNER_SELECTION,
  112. ['--', ...targetArgv],
  113. hostArgument(host),
  114. native,
  115. )
  116. host.emit('message', start)
  117. await running
  118. }
  119. describe('closed runner protocol', () => {
  120. it('preserves the explicit control request and rejects other transport values', () => {
  121. const files = track(createLinuxLaunchFiles({ cwd: '/target', env: {}, control: 'pipe' }))
  122. expect(consumeLinuxLaunchRequest(files.requestPath)).toEqual({ cwd: '/target', env: {}, control: 'pipe' })
  123. expect(parseWindowsStartRequest({ type: 'start', cwd: 'C:\\target', env: {}, control: 'pipe' }))
  124. .toEqual({ type: 'start', cwd: 'C:\\target', env: {}, control: 'pipe' })
  125. const invalid = track(createLinuxLaunchFiles({ cwd: '/target', env: {} }))
  126. writeFileSync(invalid.requestPath, JSON.stringify({ cwd: '/target', env: {}, control: 'ipc' }))
  127. expect(() => consumeLinuxLaunchRequest(invalid.requestPath)).toThrow('invalid Linux launch request')
  128. expect(() => parseWindowsStartRequest({ type: 'start', cwd: 'C:\\target', env: {}, control: 'ipc' })).toThrow()
  129. })
  130. it('creates, consumes, reports through, and cleans one private Linux exchange', () => {
  131. const files = track(createLinuxLaunchFiles({ cwd: '/target', env: { A: '1' } }))
  132. if (process.platform !== 'win32') {
  133. expect(statSync(files.directory).mode & 0o777).toBe(0o700)
  134. expect(statSync(files.requestPath).mode & 0o777).toBe(0o600)
  135. }
  136. expect(linuxLaunchFilesFromLocator(files.requestPath)).toEqual(files)
  137. expect(consumeLinuxLaunchRequest(files.requestPath)).toEqual({ cwd: '/target', env: { A: '1' } })
  138. expect(existsSync(files.requestPath)).toBe(false)
  139. const failure = Object.assign(new Error('spawn missing'), {
  140. name: 'SpawnError', code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['x'],
  141. })
  142. writeLinuxStartupError(files, { type: 'error', error: serializeRunnerError(failure) })
  143. if (process.platform !== 'win32') {
  144. expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600)
  145. }
  146. const result = readLinuxStartupError(files.startupErrorPath)
  147. expect(result).toEqual({
  148. type: 'error',
  149. error: {
  150. name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool',
  151. },
  152. })
  153. expect(deserializeRunnerError(result!.error)).toMatchObject({
  154. name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool',
  155. })
  156. cleanupLinuxLaunchFiles(files)
  157. expect(existsSync(files.directory)).toBe(false)
  158. })
  159. it('removes the private directory when request creation fails partway through', () => {
  160. const isolatedTmp = mkdtempSync(join(tmpdir(), 'dsh-launch-failure-spec-'))
  161. vi.stubEnv('TMPDIR', isolatedTmp)
  162. vi.stubEnv('TMP', isolatedTmp)
  163. vi.stubEnv('TEMP', isolatedTmp)
  164. try {
  165. const stringify = vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
  166. throw new Error('request serialization failed')
  167. })
  168. expect(() => createLinuxLaunchFiles({ cwd: '/target', env: {} })).toThrow('request serialization failed')
  169. stringify.mockRestore()
  170. expect(readdirSync(isolatedTmp)).toEqual([])
  171. } finally {
  172. vi.unstubAllEnvs()
  173. rmSync(isolatedTmp, { recursive: true, force: true })
  174. }
  175. })
  176. it('strictly rejects malformed Linux and Windows messages', () => {
  177. const files = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} }))
  178. writeFileSync(files.requestPath, JSON.stringify({ cwd: '/ok', env: {}, extra: true }))
  179. expect(() => consumeLinuxLaunchRequest(files.requestPath)).toThrow('invalid Linux launch request')
  180. expect(() => linuxLaunchFilesFromLocator('relative.json')).toThrow('invalid Linux launch-request locator')
  181. expect(readLinuxStartupError(files.startupErrorPath)).toBeUndefined()
  182. writeFileSync(files.startupErrorPath, 'null')
  183. expect(() => readLinuxStartupError(files.startupErrorPath)).toThrow('invalid startup error')
  184. writeFileSync(files.startupErrorPath, JSON.stringify({
  185. type: 'unknown', error: { name: 'Error', message: 'bad' },
  186. }))
  187. expect(() => readLinuxStartupError(files.startupErrorPath)).toThrow('unknown error result')
  188. expect(parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: { A: '1' } })).toEqual({
  189. type: 'start', cwd: 'C:\\x', env: { A: '1' },
  190. })
  191. expect(() => parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: {}, extra: 1 })).toThrow()
  192. expect(isWindowsTerminateRequest({ type: 'terminate' })).toBe(true)
  193. expect(isWindowsTerminateRequest({ type: 'terminate', reason: 'no' })).toBe(false)
  194. expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: 7 })).toEqual({
  195. type: 'target-exit', exitCode: 7,
  196. })
  197. expect(parseWindowsRunnerResult({ type: 'error', error: { name: 'Error', message: 'bad' } })).toEqual({
  198. type: 'error', error: { name: 'Error', message: 'bad' },
  199. })
  200. for (const invalid of [
  201. null,
  202. { type: 'unknown' },
  203. { type: 'start-cancelled' },
  204. { type: 'start-cancelled', payload: 1 },
  205. { type: 'target-exit', exitCode: -1 },
  206. { type: 'target-exit', exitCode: 0, signal: null },
  207. { type: 'spawn-error', error: { name: 'Error', message: 'bad' } },
  208. { type: 'runner-error', error: { name: 'Error', message: 'bad' } },
  209. { type: 'error', error: { name: 'Error', message: 'bad', cause: {} } },
  210. ]) expect(() => parseWindowsRunnerResult(invalid)).toThrow()
  211. })
  212. it('contains cleanup failures and removes a substituted symlink only', () => {
  213. const files = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} }))
  214. cleanupLinuxLaunchFiles(files)
  215. cleanupLinuxLaunchFiles(files)
  216. const target = join(tmpdir(), `dsh-runner-cleanup-target-${String(process.pid)}`)
  217. const link = join(tmpdir(), `dsh-runner-cleanup-link-${String(process.pid)}`)
  218. scratch.push(target, link)
  219. mkdirSync(target, { recursive: true })
  220. symlinkSync(target, link)
  221. cleanupLinuxLaunchFiles({
  222. directory: link,
  223. requestPath: join(link, 'launch-request.json'),
  224. startupErrorPath: join(link, 'startup-error.json'),
  225. })
  226. expect(existsSync(link)).toBe(false)
  227. expect(existsSync(target)).toBe(true)
  228. const blocked = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} }))
  229. unlinkSync(blocked.requestPath)
  230. mkdirSync(blocked.requestPath)
  231. cleanupLinuxLaunchFiles(blocked)
  232. expect(existsSync(blocked.directory)).toBe(true)
  233. })
  234. })
  235. describe('runner launch inputs', () => {
  236. const spec = {
  237. argv: ['node', 'a'],
  238. cwd: process.cwd(),
  239. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  240. graceMs: 100,
  241. env: { EXPLICIT: 'yes' },
  242. } as const
  243. it('keeps target state out of the bootstrap environment and consumes its selector', () => {
  244. const env = runnerEnvironment('/tmp/request')
  245. const sourceEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/bin.ts'])
  246. const builtEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/runner.js'])
  247. expect(env[SUBPROCESS_RUNNER_ENV]).toBe('/tmp/request')
  248. expect(env.SYSTEMD_LOG_TARGET).toBe('null')
  249. expect(env.EXPLICIT).toBeUndefined()
  250. expect(sourceEnv.TSX_TSCONFIG_PATH).toBe(resolve(import.meta.dirname, '../../../..', 'tsconfig.base.json'))
  251. expect(builtEnv.TSX_TSCONFIG_PATH).toBe(env.TSX_TSCONFIG_PATH)
  252. expect(consumeRunnerSelection(env)).toBe('/tmp/request')
  253. expect(env[SUBPROCESS_RUNNER_ENV]).toBeUndefined()
  254. expect(consumeRunnerSelection({})).toBeUndefined()
  255. expect(parseRunnerTargetArgv(['--', 'node', 'a'])).toEqual(['node', 'a'])
  256. expect(() => parseRunnerTargetArgv(['node'])).toThrow('private -- delimiter')
  257. expect(runnerStdio(spec, false)).toEqual(['pipe', 'pipe', 'inherit'])
  258. const withControl = { ...spec, stdio: { ...spec.stdio, control: 'pipe' as const } }
  259. expect(runnerStdio(withControl, false)).toEqual(['pipe', 'pipe', 'inherit', 'ignore', 'ignore', 'ignore', 'ignore', 'overlapped'])
  260. expect(runnerStdio(withControl, true)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2, 'overlapped'])
  261. expect(runnerStdio(spec, true)).toEqual([
  262. 'ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2,
  263. ])
  264. expect(runnerStdio({
  265. ...spec,
  266. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' },
  267. }, false)).toEqual(['ignore', 'inherit', 'pipe'])
  268. expect(runnerStdio({
  269. ...spec,
  270. stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' },
  271. }, true, 17)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 17, 1, 'pipe'])
  272. })
  273. it('removes ambient Node and tsx controls from the bootstrap environment only', () => {
  274. vi.stubEnv('NODE_OPTIONS', '--require /tmp/runner-bootstrap-control.cjs')
  275. vi.stubEnv('NODE_DEBUG', 'esm')
  276. vi.stubEnv('TSX_DISABLE_CACHE', '1')
  277. vi.stubEnv('TSX_TSCONFIG_PATH', '/ambient/tsconfig.json')
  278. try {
  279. const sourceEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/bin.ts'])
  280. const builtEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/runner.js'])
  281. expect(sourceEnv.NODE_OPTIONS).toBeUndefined()
  282. expect(sourceEnv.NODE_DEBUG).toBeUndefined()
  283. expect(sourceEnv.TSX_DISABLE_CACHE).toBeUndefined()
  284. expect(sourceEnv.TSX_TSCONFIG_PATH)
  285. .toBe(resolve(import.meta.dirname, '../../../..', 'tsconfig.base.json'))
  286. expect(builtEnv.NODE_OPTIONS).toBeUndefined()
  287. expect(builtEnv.NODE_DEBUG).toBeUndefined()
  288. expect(builtEnv.TSX_DISABLE_CACHE).toBeUndefined()
  289. expect(builtEnv.TSX_TSCONFIG_PATH).toBeUndefined()
  290. expect(targetEnvironment(spec)).toMatchObject({
  291. NODE_OPTIONS: '--require /tmp/runner-bootstrap-control.cjs',
  292. NODE_DEBUG: 'esm',
  293. TSX_DISABLE_CACHE: '1',
  294. TSX_TSCONFIG_PATH: '/ambient/tsconfig.json',
  295. })
  296. } finally {
  297. vi.unstubAllEnvs()
  298. }
  299. })
  300. it('validates every Node-baseline NUL location before launch', () => {
  301. expect(targetEnvironment(spec)).toMatchObject({ EXPLICIT: 'yes' })
  302. expect(targetEnvironment({ ...spec, env: { '=C:': 'C:\\target' } }))
  303. .toMatchObject({ '=C:': 'C:\\target' })
  304. for (const invalid of [
  305. { ...spec, argv: ['node\0'] },
  306. { ...spec, argv: ['node', 'a\0'] },
  307. { ...spec, cwd: 'bad\0cwd' },
  308. { ...spec, env: { 'BAD\0KEY': 'x' } },
  309. { ...spec, env: { BAD: 'x\0' } },
  310. ]) {
  311. try {
  312. targetEnvironment(invalid)
  313. throw new Error('expected targetEnvironment to reject')
  314. } catch (error) {
  315. expect(error).toMatchObject({ name: 'TypeError', code: 'ERR_INVALID_ARG_VALUE' })
  316. }
  317. }
  318. })
  319. it('resolves the source runner entry and checks concrete paths without executing it', () => {
  320. const invocation = spawnRunnerInvocation()
  321. expect(invocation[0]).toBe(process.execPath)
  322. expect(invocation).toContain(import.meta.resolve('tsx/esm'))
  323. expect(runnerInvocationAvailable(invocation)).toBe(true)
  324. expect(runnerInvocationAvailable(['/definitely/missing-dsh-runner'])).toBe(false)
  325. expect(runnerInvocationAvailable(['node'])).toBe(true)
  326. expect(runnerInvocationAvailable(['node', 'runner.js'])).toBe(true)
  327. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  328. try {
  329. expect(spawnRunnerInvocation()).toEqual([process.execPath])
  330. } finally {
  331. Reflect.deleteProperty(process, 'pkg')
  332. }
  333. })
  334. it('loads the source runner from an isolated application cwd', () => {
  335. const directory = mkdtempSync(join(tmpdir(), 'dsh-runner-cwd-'))
  336. scratch.push(directory)
  337. const invocation = spawnRunnerInvocation()
  338. const env = runnerEnvironment('unused', invocation)
  339. Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV)
  340. const launched = spawnSync(invocation[0], invocation.slice(1), {
  341. cwd: directory,
  342. env,
  343. encoding: 'utf8',
  344. })
  345. expect(launched.status).toBe(127)
  346. expect(launched.stderr).not.toContain('ERR_MODULE_NOT_FOUND')
  347. })
  348. it('serializes only the private protocol diagnostic fields', () => {
  349. expect(serializeRunnerError('plain failure')).toMatchObject({
  350. name: 'Error', message: 'plain failure',
  351. })
  352. const detailed = Object.assign(new Error('detailed'), {
  353. code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['arg'],
  354. })
  355. expect(serializeRunnerError(detailed)).toEqual({
  356. name: 'Error', message: 'detailed', code: 'ENOENT', syscall: 'spawn tool', path: 'tool',
  357. })
  358. const minimal = deserializeRunnerError({ name: 'Error', message: 'minimal' })
  359. expect(minimal).toMatchObject({ name: 'Error', message: 'minimal' })
  360. expect(minimal).not.toHaveProperty('code')
  361. expect(minimal).not.toHaveProperty('syscall')
  362. expect(minimal).not.toHaveProperty('path')
  363. })
  364. it('resolves Windows executables with target-cwd and PATH search semantics', () => {
  365. const probed: string[] = []
  366. const exists = (candidate: string): boolean => {
  367. probed.push(candidate)
  368. return candidate === 'C:\\tools\\git\\bin\\bash.exe'
  369. }
  370. expect(resolveWindowsExecutable('bash', 'C:\\target', {
  371. Path: 'relative;"C:\\semi;colon";"C:\\tools\\git\\bin";C:\\later',
  372. }, exists)).toBe('C:\\tools\\git\\bin\\bash.exe')
  373. expect(probed).toEqual([
  374. 'C:\\target\\bash.com',
  375. 'C:\\target\\bash.exe',
  376. 'C:\\target\\relative\\bash.com',
  377. 'C:\\target\\relative\\bash.exe',
  378. 'C:\\semi;colon\\bash.com',
  379. 'C:\\semi;colon\\bash.exe',
  380. 'C:\\tools\\git\\bin\\bash.com',
  381. 'C:\\tools\\git\\bin\\bash.exe',
  382. ])
  383. expect(resolveWindowsExecutable('local.exe', 'C:\\target', {}, candidate =>
  384. candidate === 'C:\\target\\local.exe')).toBe('C:\\target\\local.exe')
  385. expect(resolveWindowsExecutable('tool', 'C:\\target', {
  386. PATH: 'C:\\bin',
  387. }, candidate => candidate === 'C:\\bin\\tool.com', {
  388. NoDefaultCurrentDirectoryInExePath: '1',
  389. })).toBe('C:\\bin\\tool.com')
  390. expect(resolveWindowsExecutable('tool', 'C:\\target', {
  391. PATH: 'D:relative',
  392. }, candidate => candidate === 'D:relative\\tool.exe')).toBe('D:relative\\tool.exe')
  393. expect(resolveWindowsExecutable('tool.', 'C:\\target', {}, candidate =>
  394. candidate === 'C:\\target\\tool.exe')).toBe('C:\\target\\tool.exe')
  395. expect(resolveWindowsExecutable('.\\missing', 'C:\\target', {}, () => false))
  396. .toBeUndefined()
  397. expect(resolveWindowsExecutable('tool', 'C:\\target', {
  398. PATH: ';;C:\\bin',
  399. }, candidate => candidate === 'C:\\bin\\tool.exe')).toBe('C:\\bin\\tool.exe')
  400. expect(resolveWindowsExecutable('tool', 'C:\\target', {
  401. PATH: '"";C:\\bin',
  402. }, candidate => candidate === 'C:\\bin\\tool.exe')).toBe('C:\\bin\\tool.exe')
  403. expect(resolveWindowsExecutable('tool', 'C:\\target', {
  404. PATH: '"unterminated',
  405. }, candidate => candidate === 'C:\\target\\unterminated\\tool.exe'))
  406. .toBe('C:\\target\\unterminated\\tool.exe')
  407. expect(resolveWindowsExecutable('\\\\server\\share\\tool', 'C:\\target', {}, candidate =>
  408. candidate === '\\\\server\\share\\tool.exe')).toBe('\\\\server\\share\\tool.exe')
  409. expect(resolveWindowsExecutable('\\tools\\tool', 'C:\\target', {}, candidate =>
  410. candidate === 'C:\\tools\\tool.exe')).toBe('C:\\tools\\tool.exe')
  411. expect(resolveWindowsExecutable('C:tools\\tool', 'C:\\target', {}, candidate =>
  412. candidate === 'C:\\target\\tools\\tool.exe')).toBe('C:\\target\\tools\\tool.exe')
  413. const noSearchEnvironment = { NoDefaultCurrentDirectoryInExePath: '1' }
  414. expect(resolveWindowsExecutable('missing', 'C:\\target', {}, () => false, noSearchEnvironment))
  415. .toBeUndefined()
  416. expect(resolveWindowsExecutable('missing.cmd', 'C:\\target', {}, () => false, noSearchEnvironment))
  417. .toBeUndefined()
  418. const directory = mkdtempSync(join(tmpdir(), 'dsh-windows-resolver-'))
  419. scratch.push(directory)
  420. const executable = join(directory, 'direct.exe')
  421. const directoryCandidate = join(directory, 'directory')
  422. const missingExecutable = join(directory, 'missing.exe')
  423. const danglingAlias = join(directory, 'alias.exe')
  424. writeFileSync(executable, '')
  425. mkdirSync(`${directoryCandidate}.com`)
  426. writeFileSync(`${directoryCandidate}.exe`, '')
  427. symlinkSync(missingExecutable, danglingAlias, 'file')
  428. expect(resolveWindowsExecutable(executable, '', {})).toBe(executable)
  429. expect(resolveWindowsExecutable(directoryCandidate, '', {})).toBe(`${directoryCandidate}.exe`)
  430. expect(resolveWindowsExecutable(danglingAlias, '', {})).toBe(danglingAlias)
  431. expect(resolveWindowsExecutable(missingExecutable, '', {})).toBeUndefined()
  432. })
  433. })
  434. describe('Linux one-shot exec bootstrap', () => {
  435. it('uses final cwd/env PATH while preserving the original argv', async () => {
  436. const files = track(createLinuxLaunchFiles({
  437. cwd: '/final/work',
  438. env: { PATH: 'relative::/absolute', [SUBPROCESS_RUNNER_ENV]: 'target-value' },
  439. }))
  440. const host = new FakeRunnerHost()
  441. const execve = vi.fn((_file: string, _argv: string[], _env: Record<string, string>) => {
  442. throw Object.assign(new Error('not found'), { code: 'ENOENT' })
  443. })
  444. await runSpawnRunner(files.requestPath, ['--', 'tool', 'literal arg'], hostArgument(host), internals({ execve }))
  445. expect(host.directory).toBe('/final/work')
  446. expect(host.env[SUBPROCESS_RUNNER_ENV]).toBeUndefined()
  447. expect(execve.mock.calls.map(call => call[0])).toEqual([
  448. '/final/work/relative/tool',
  449. '/final/work/tool',
  450. '/absolute/tool',
  451. ])
  452. expect(execve.mock.calls[0]?.[1]).toEqual(['tool', 'literal arg'])
  453. expect(execve.mock.calls[0]?.[2]).toMatchObject({ [SUBPROCESS_RUNNER_ENV]: 'target-value' })
  454. expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
  455. type: 'error',
  456. error: {
  457. name: 'Error',
  458. message: 'spawn tool ENOENT',
  459. code: 'ENOENT',
  460. syscall: 'spawn tool',
  461. path: 'tool',
  462. },
  463. })
  464. })
  465. it('resolves relative PATH entries from the cwd after chdir', async () => {
  466. const files = track(createLinuxLaunchFiles({ cwd: 'work', env: { PATH: 'bin:' } }))
  467. const host = new FakeRunnerHost()
  468. host.directory = '/base'
  469. const execve = vi.fn((_file: string, _argv: string[], _env: Record<string, string>): never => {
  470. throw Object.assign(new Error('not found'), { code: 'ENOENT' })
  471. })
  472. await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(host), internals({ execve }))
  473. expect(host.directory).toBe('/base/work')
  474. expect(execve.mock.calls.map(call => call[0])).toEqual([
  475. '/base/work/bin/tool',
  476. '/base/work/tool',
  477. ])
  478. const rootFiles = track(createLinuxLaunchFiles({ cwd: '/', env: { PATH: '' } }))
  479. const rootExecve = vi.fn((): never => {
  480. throw Object.assign(new Error('not found'), { code: 'ENOENT' })
  481. })
  482. await runSpawnRunner(
  483. rootFiles.requestPath,
  484. ['--', 'tool'],
  485. hostArgument(new FakeRunnerHost()),
  486. internals({ execve: rootExecve }),
  487. )
  488. expect(rootExecve).toHaveBeenCalledWith('/tool', ['tool'], { PATH: '' })
  489. })
  490. it.skipIf(process.platform === 'win32')('preserves symlink-sensitive parent traversal in PATH candidates', async () => {
  491. const root = mkdtempSync(join(tmpdir(), 'dsh-linux-path-symlink-'))
  492. scratch.push(root)
  493. const cwd = join(root, 'cwd')
  494. const target = join(root, 'target')
  495. mkdirSync(cwd)
  496. mkdirSync(join(target, 'child'), { recursive: true })
  497. writeFileSync(join(target, 'tool'), '')
  498. symlinkSync(join(target, 'child'), join(cwd, 'link'), 'dir')
  499. const files = track(createLinuxLaunchFiles({ cwd, env: { PATH: 'link/..' } }))
  500. const execve = vi.fn((file: string): never => {
  501. throw Object.assign(new Error(existsSync(file) ? 'selected' : 'not found'), {
  502. code: existsSync(file) ? 'EIO' : 'ENOENT',
  503. })
  504. })
  505. await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve }))
  506. expect(execve).toHaveBeenCalledWith(`${cwd}/link/../tool`, ['tool'], { PATH: 'link/..' })
  507. expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
  508. type: 'error', error: { code: 'EIO' },
  509. })
  510. })
  511. it.each([undefined, 'pipe'] as const)('retries ENOEXEC through /bin/sh with control %s', async (control) => {
  512. const files = track(createLinuxLaunchFiles({ cwd: '/work', env: { PATH: 'bin' }, ...control === undefined ? {} : { control } }))
  513. const execve = vi.fn()
  514. .mockImplementationOnce(() => { throw Object.assign(new Error('exec format'), { code: 'ENOEXEC' }) })
  515. .mockImplementationOnce(() => { throw Object.assign(new Error('shell failed'), { code: 'EIO' }) })
  516. await runSpawnRunner(
  517. files.requestPath,
  518. ['--', 'tool', 'literal arg'],
  519. hostArgument(new FakeRunnerHost()),
  520. internals({ execve: execve as never }),
  521. )
  522. expect(execve.mock.calls).toEqual([
  523. ['/work/bin/tool', ['tool', 'literal arg'], { PATH: 'bin' }, ...control === undefined ? [] : [control]],
  524. ['/bin/sh', ['/bin/sh', '/work/bin/tool', 'literal arg'], { PATH: 'bin' }, ...control === undefined ? [] : [control]],
  525. ])
  526. expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
  527. type: 'error', error: { code: 'EIO', path: 'tool' },
  528. })
  529. })
  530. it('uses the default PATH and stops on a non-search error', async () => {
  531. const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
  532. const execve = vi.fn((_file: string) => {
  533. throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES', errno: -13 })
  534. })
  535. await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve }))
  536. expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool'])
  537. expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
  538. type: 'error',
  539. error: {
  540. name: 'Error',
  541. message: 'spawn tool EACCES',
  542. code: 'EACCES',
  543. syscall: 'spawn tool',
  544. path: 'tool',
  545. },
  546. })
  547. const explicit = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
  548. const fatal = vi.fn(() => { throw Object.assign(new Error('bad executable'), { code: 'EIO' }) })
  549. await runSpawnRunner(explicit.requestPath, ['--', './tool'], hostArgument(new FakeRunnerHost()), internals({ execve: fatal }))
  550. expect(fatal).toHaveBeenCalledOnce()
  551. const stackless = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
  552. await runSpawnRunner(stackless.requestPath, ['--', './tool'], hostArgument(new FakeRunnerHost()), internals({
  553. execve: vi.fn(() => { throw new Error('unclassified failure') }),
  554. }))
  555. expect(readLinuxStartupError(stackless.startupErrorPath)).toMatchObject({
  556. type: 'error', error: { message: 'unclassified failure' },
  557. })
  558. const searched = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
  559. const searchedExecve = vi.fn()
  560. .mockImplementationOnce(() => { throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }) })
  561. .mockImplementationOnce(() => { throw Object.assign(new Error('I/O failure'), { code: 'EIO' }) })
  562. await runSpawnRunner(searched.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({
  563. execve: searchedExecve as never,
  564. }))
  565. expect(readLinuxStartupError(searched.startupErrorPath)).toMatchObject({
  566. type: 'error', error: { code: 'EIO' },
  567. })
  568. })
  569. it('publishes request and early protocol failures through the single error branch', async () => {
  570. const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
  571. writeFileSync(files.requestPath, '{')
  572. await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals())
  573. expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'error' })
  574. const early = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
  575. await reportSpawnRunnerFailure(early.requestPath, new Error('delimiter failed'), hostArgument(new FakeRunnerHost()))
  576. expect(readLinuxStartupError(early.startupErrorPath)).toMatchObject({
  577. type: 'error', error: { message: 'delimiter failed' },
  578. })
  579. })
  580. })
  581. describe('Windows Job runner protocol owner', () => {
  582. it('publishes a Node-shaped path-search miss before loading Win32 bindings', async () => {
  583. const host = new FakeRunnerHost()
  584. const loadWin32ProcessBindings = vi.fn(() => ({} as CurrentTokenProcessBindings))
  585. const native = internals({
  586. loadWin32ProcessBindings,
  587. resolveWindowsExecutable: vi.fn(() => undefined),
  588. })
  589. await runWindows(host, native)
  590. expect(loadWin32ProcessBindings).not.toHaveBeenCalled()
  591. expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
  592. expect(host.sent).toEqual([{
  593. type: 'error',
  594. error: {
  595. name: 'Error',
  596. message: 'spawn tool.exe ENOENT',
  597. code: 'ENOENT',
  598. syscall: 'spawn tool.exe',
  599. path: 'tool.exe',
  600. },
  601. }])
  602. expect(host.exitCode).toBe(0)
  603. })
  604. it('maps only the promised Win32 process-creation error subset', async () => {
  605. for (const [win32Code, code, enriched, program] of [
  606. [2, 'ENOENT', true, 'tool.exe'],
  607. [3, 'ENOENT', true, 'tool.exe'],
  608. [267, 'ENOENT', true, 'tool.exe'],
  609. [740, 'EACCES', true, '$&.exe'],
  610. [5, 'EPERM', false, 'tool.exe'],
  611. [193, 'EFTYPE', false, 'tool.exe'],
  612. [4, 'UNKNOWN', false, 'tool.exe'],
  613. ] as const) {
  614. const host = new FakeRunnerHost()
  615. await runWindows(host, internals({
  616. spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }),
  617. }), undefined, [program, 'literal arg'])
  618. const syscall = enriched ? `spawn ${program}` : 'spawn'
  619. expect(host.sent).toMatchObject([{
  620. type: 'error',
  621. error: {
  622. name: 'Error',
  623. message: `${syscall} ${code}`,
  624. code,
  625. syscall,
  626. },
  627. }])
  628. const result = parseWindowsRunnerResult(host.sent[0])
  629. if (result.type !== 'error') throw new Error('expected runner error')
  630. if (enriched) {
  631. expect(result.error).toMatchObject({ path: program })
  632. } else {
  633. expect(result.error).not.toHaveProperty('path')
  634. }
  635. }
  636. })
  637. it('rejects a Windows runner without an initial IPC channel', async () => {
  638. const disconnected = new FakeRunnerHost()
  639. disconnected.connected = false
  640. await runSpawnRunner(
  641. WINDOWS_RUNNER_SELECTION,
  642. ['--', 'tool.exe'],
  643. hostArgument(disconnected),
  644. internals(),
  645. )
  646. expect(disconnected.exitCode).toBe(127)
  647. const missingSend = new FakeRunnerHost()
  648. Object.defineProperty(missingSend, 'send', { value: undefined })
  649. await runSpawnRunner(
  650. WINDOWS_RUNNER_SELECTION,
  651. ['--', 'tool.exe'],
  652. hostArgument(missingSend),
  653. internals(),
  654. )
  655. expect(missingSend.exitCode).toBe(127)
  656. })
  657. it('sends target-exit only after suspended Job launch and closes runner stdio', async () => {
  658. const host = new FakeRunnerHost()
  659. const closeFileDescriptor = vi.fn()
  660. const native = internals({ closeFileDescriptor })
  661. await runWindows(host, native)
  662. expect(native.resolveWindowsExecutable).toHaveBeenCalledWith(
  663. 'tool.exe',
  664. 'C:\\target',
  665. { TARGET: 'yes', dsh_subprocess_runner: 'restored' },
  666. undefined,
  667. { SAFE: 'bootstrap' },
  668. )
  669. expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), {
  670. command: 'tool.exe', applicationName: 'C:\\resolved\\tool.exe', args: ['literal arg'], cwd: 'C:\\target',
  671. env: { TARGET: 'yes', dsh_subprocess_runner: 'restored' },
  672. stdio: { stdin: 4, stdout: 5, stderr: 6 },
  673. })
  674. expect(closeFileDescriptor).toHaveBeenCalledTimes(3)
  675. expect(closeFileDescriptor).toHaveBeenNthCalledWith(1, 4)
  676. expect(closeFileDescriptor).toHaveBeenNthCalledWith(2, 5)
  677. expect(closeFileDescriptor).toHaveBeenNthCalledWith(3, 6)
  678. expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process')
  679. expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job')
  680. expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0 }])
  681. expect(host.exitCode).toBe(0)
  682. expect(host.env).toEqual({ SAFE: 'bootstrap' })
  683. })
  684. it.each([undefined, 'pipe'] as const)('closes every target carrier with control %s before the first Windows poll', async (control) => {
  685. const events: string[] = []
  686. const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => {
  687. events.push('interval')
  688. queueMicrotask(callback)
  689. return 1 as unknown as ReturnType<typeof setInterval>
  690. })
  691. try {
  692. const host = new FakeRunnerHost()
  693. const native = internals({
  694. closeFileDescriptor: vi.fn((fileDescriptor) => { events.push(`close:${String(fileDescriptor)}`) }),
  695. pollProcessExit: vi.fn(() => {
  696. events.push('poll')
  697. return 0
  698. }),
  699. })
  700. await runWindows(host, native, { type: 'start', cwd: 'C:\\target', env: {}, ...control === undefined ? {} : { control } })
  701. expect(events).toEqual(['close:4', 'close:5', 'close:6', ...control === 'pipe' ? ['close:7'] : [], 'interval', 'poll'])
  702. expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0 }])
  703. expect(host.exitCode).toBe(0)
  704. } finally {
  705. interval.mockRestore()
  706. }
  707. })
  708. it('exhausts target-exit and strict error results, including start cancellation', async () => {
  709. const spawnHost = new FakeRunnerHost()
  710. await runWindows(spawnHost, internals({
  711. spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }),
  712. }))
  713. expect(spawnHost.sent).toMatchObject([{ type: 'error', error: { code: 'ENOENT', path: 'tool.exe' } }])
  714. expect(spawnHost.exitCode).toBe(0)
  715. const runnerHost = new FakeRunnerHost()
  716. await runWindows(runnerHost, internals({
  717. loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding failed') }),
  718. }))
  719. expect(runnerHost.sent).toMatchObject([{ type: 'error', error: { message: 'binding failed' } }])
  720. expect(runnerHost.exitCode).toBe(127)
  721. const cancelledHost = new FakeRunnerHost()
  722. const native = internals()
  723. const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(cancelledHost), native)
  724. cancelledHost.emit('message', { type: 'terminate' })
  725. cancelledHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  726. await running
  727. expect(cancelledHost.sent).toEqual([{
  728. type: 'error',
  729. error: {
  730. name: 'Error',
  731. message: 'subprocess target start was cancelled',
  732. },
  733. }])
  734. expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
  735. })
  736. it('cancels after accepting start but before target commit', async () => {
  737. const host = new FakeRunnerHost()
  738. const native = internals()
  739. const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
  740. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  741. host.emit('message', { type: 'terminate' })
  742. await running
  743. expect(host.sent).toEqual([{
  744. type: 'error',
  745. error: {
  746. name: 'Error',
  747. message: 'subprocess target start was cancelled',
  748. },
  749. }])
  750. expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
  751. })
  752. it('does not create a target after pre-commit IPC disconnect', async () => {
  753. const host = new FakeRunnerHost()
  754. const native = internals()
  755. const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
  756. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  757. host.disconnect()
  758. await running
  759. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  760. expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
  761. expect(native.terminateJob).not.toHaveBeenCalled()
  762. expect(host.exitCode).toBe(127)
  763. })
  764. it('terminates and closes the unique Job immediately when IPC disconnects', async () => {
  765. const host = new FakeRunnerHost()
  766. const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
  767. const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
  768. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  769. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  770. host.disconnect()
  771. await running
  772. expect(native.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1)
  773. expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job cleanup')
  774. expect(host.exitCode).toBe(127)
  775. })
  776. it('honors terminate after commit and treats result-send failure as infrastructure failure', async () => {
  777. const host = new FakeRunnerHost()
  778. const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
  779. const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
  780. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  781. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  782. host.emit('message', { type: 'terminate' })
  783. host.emit('message', { type: 'terminate' })
  784. expect(native.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1)
  785. host.disconnect()
  786. await running
  787. const sendFailureHost = new FakeRunnerHost()
  788. sendFailureHost.sendFailure = new Error('send failed')
  789. const sendFailureNative = internals({ isJobEmpty: vi.fn(() => false) })
  790. await runWindows(sendFailureHost, sendFailureNative)
  791. expect(sendFailureNative.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1)
  792. expect(sendFailureNative.closeHandleChecked).toHaveBeenCalledWith(
  793. expect.anything(),
  794. 20n,
  795. 'ordinary process Job cleanup',
  796. )
  797. expect(sendFailureHost.exitCode).toBe(127)
  798. })
  799. it('reports a post-commit termination failure', async () => {
  800. const failedHost = new FakeRunnerHost()
  801. const failed = internals({
  802. pollProcessExit: vi.fn(() => undefined),
  803. isJobEmpty: vi.fn(() => false),
  804. terminateJob: vi.fn(() => { throw new Error('terminate Job failed') }),
  805. })
  806. const failedRun = runSpawnRunner(
  807. WINDOWS_RUNNER_SELECTION,
  808. ['--', 'tool.exe'],
  809. hostArgument(failedHost),
  810. failed,
  811. )
  812. failedHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  813. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  814. failedHost.emit('message', { type: 'terminate' })
  815. await failedRun
  816. expect(failedHost.sent).toMatchObject([{ type: 'error', error: { message: 'terminate Job failed' } }])
  817. })
  818. it('finishes when a later poll observes Job emptiness after result delivery', async () => {
  819. const host = new FakeRunnerHost()
  820. const native = internals({
  821. pollProcessExit: vi.fn().mockReturnValueOnce(0).mockReturnValue(undefined),
  822. isJobEmpty: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
  823. })
  824. const running = runSpawnRunner(
  825. WINDOWS_RUNNER_SELECTION,
  826. ['--', 'tool.exe'],
  827. hostArgument(host),
  828. native,
  829. )
  830. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  831. await running
  832. expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0 }])
  833. expect(native.isJobEmpty).toHaveBeenCalledTimes(2)
  834. })
  835. it('contains poll failures and queued ticks after disconnect', async () => {
  836. const failedHost = new FakeRunnerHost()
  837. await runWindows(failedHost, internals({
  838. pollProcessExit: vi.fn(() => { throw new Error('poll failed') }),
  839. }))
  840. expect(failedHost.sent).toMatchObject([{ type: 'error', error: { message: 'poll failed' } }])
  841. let tick: (() => void) | undefined
  842. const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => {
  843. tick = callback
  844. return 1 as unknown as ReturnType<typeof setInterval>
  845. })
  846. try {
  847. const host = new FakeRunnerHost()
  848. const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
  849. const running = runSpawnRunner(
  850. WINDOWS_RUNNER_SELECTION,
  851. ['--', 'tool.exe'],
  852. hostArgument(host),
  853. native,
  854. )
  855. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  856. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  857. tick?.()
  858. host.disconnect()
  859. await running
  860. tick?.()
  861. } finally {
  862. interval.mockRestore()
  863. }
  864. })
  865. it('cleans a direct handle after the Job identity was already cleared', async () => {
  866. let tick: (() => void) | undefined
  867. const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => {
  868. tick = callback
  869. return 1 as unknown as ReturnType<typeof setInterval>
  870. })
  871. try {
  872. const host = new FakeRunnerHost()
  873. const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => true) })
  874. const running = runSpawnRunner(
  875. WINDOWS_RUNNER_SELECTION,
  876. ['--', 'tool.exe'],
  877. hostArgument(host),
  878. native,
  879. )
  880. host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
  881. await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
  882. tick?.()
  883. host.emit('message', { type: 'terminate' })
  884. host.disconnect()
  885. await running
  886. expect(native.closeHandleChecked).toHaveBeenCalledWith(
  887. expect.anything(), 10n, 'ordinary direct process cleanup',
  888. )
  889. } finally {
  890. interval.mockRestore()
  891. }
  892. })
  893. it('fails closed for malformed or duplicate start messages and disconnected reporting', async () => {
  894. const malformed = new FakeRunnerHost()
  895. await runWindows(malformed, internals(), { type: 'start', cwd: 'C:\\x', env: {}, extra: true })
  896. expect(malformed.sent).toMatchObject([{ type: 'error' }])
  897. const duplicate = new FakeRunnerHost()
  898. const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
  899. const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(duplicate), native)
  900. duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} })
  901. duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} })
  902. await running
  903. expect(duplicate.sent).toMatchObject([{ type: 'error' }])
  904. const raced = new FakeRunnerHost()
  905. const racedRun = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(raced), internals())
  906. const lateMessage = raced.listeners('message')[0] as ((value: unknown) => void) | undefined
  907. const lateDisconnect = raced.listeners('disconnect')[0] as (() => void) | undefined
  908. raced.emit('message', { type: 'bad' })
  909. raced.emit('message', { type: 'bad' })
  910. await racedRun
  911. await Promise.resolve()
  912. lateMessage?.({ type: 'bad' })
  913. lateDisconnect?.()
  914. const disconnected = new FakeRunnerHost()
  915. disconnected.connected = false
  916. await reportSpawnRunnerFailure(WINDOWS_RUNNER_SELECTION, new Error('early'), hostArgument(disconnected))
  917. expect(disconnected.exitCode).toBe(127)
  918. const connected = new FakeRunnerHost()
  919. connected.sendThrown = new Error('synchronous send failure')
  920. await reportSpawnRunnerFailure(WINDOWS_RUNNER_SELECTION, new Error('early'), hostArgument(connected))
  921. expect(connected.exitCode).toBe(127)
  922. expect(connected.connected).toBe(false)
  923. const noSelection = new FakeRunnerHost()
  924. await reportSpawnRunnerFailure(undefined, new Error('no selector'), hostArgument(noSelection))
  925. expect(noSelection.exitCode).toBe(127)
  926. })
  927. })