spawn-runner.spec.ts 41 KB

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