windows-job.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import { EventEmitter } from 'node:events'
  2. import { fstatSync } from 'node:fs'
  3. import { PassThrough } from 'node:stream'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import {
  6. launchWindowsJob,
  7. probeWindowsJob,
  8. } from '../src/windows-job.ts'
  9. import { bindManagedProcess } from '../src/spawn.ts'
  10. class FakeChild extends EventEmitter {
  11. pid: number | undefined = 432
  12. connected = true
  13. stdin = new PassThrough()
  14. stdout = new PassThrough()
  15. stderr = new PassThrough()
  16. targetStdin = new PassThrough()
  17. targetStdout = new PassThrough()
  18. targetStderr = new PassThrough()
  19. stdio = [null, null, null, null, this.targetStdin, this.targetStdout, this.targetStderr]
  20. sent: unknown[] = []
  21. killed: NodeJS.Signals[] = []
  22. sendError: Error | undefined
  23. deferSendCallbacks = false
  24. pendingSendCallbacks: Array<(error: Error | null) => void> = []
  25. throwOnSendCall: number | undefined
  26. sendThrown: unknown = new Error('send threw')
  27. private sendCalls = 0
  28. exit(exitCode: number | null, signal: NodeJS.Signals | null): void {
  29. this.emit('exit', exitCode, signal)
  30. this.connected = false
  31. this.emit('disconnect')
  32. }
  33. send(message: unknown, callback?: (error: Error | null) => void): boolean {
  34. this.sendCalls += 1
  35. if (this.sendCalls === this.throwOnSendCall) throw this.sendThrown
  36. this.sent.push(message)
  37. if (callback !== undefined && this.deferSendCallbacks) {
  38. this.pendingSendCallbacks.push(callback)
  39. } else {
  40. queueMicrotask(() => { callback?.(this.sendError ?? null) })
  41. }
  42. return true
  43. }
  44. deliverNextSend(error: Error | null): void {
  45. const callback = this.pendingSendCallbacks.shift()
  46. if (callback === undefined) throw new Error('no deferred send callback')
  47. callback(error)
  48. }
  49. kill(signal: NodeJS.Signals): boolean {
  50. this.killed.push(signal)
  51. return true
  52. }
  53. }
  54. const spec = {
  55. argv: ['tool.exe', 'literal arg'],
  56. cwd: 'C:\\target',
  57. env: { TARGET: 'yes' },
  58. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  59. graceMs: 100,
  60. } as const
  61. function launch(
  62. child = new FakeChild(),
  63. request: Parameters<typeof launchWindowsJob>[0] = spec,
  64. emitSpawn = true,
  65. ) {
  66. const spawn = vi.fn((_command: string, _args: readonly string[], _options: unknown) => child)
  67. const result = launchWindowsJob(request, { TARGET: 'yes' }, {
  68. spawn: spawn as never,
  69. runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'],
  70. })
  71. if (emitSpawn) child.emit('spawn')
  72. return { child, result, spawn }
  73. }
  74. describe('Windows Job capability', () => {
  75. it('uses the production dependency paths by default', async () => {
  76. vi.resetModules()
  77. const child = new FakeChild()
  78. const spawn = vi.fn(() => child)
  79. const load = vi.fn(() => ({ bindings: true }) as never)
  80. const probe = vi.fn()
  81. vi.doMock('node:child_process', async importOriginal => ({
  82. ...await importOriginal<typeof import('node:child_process')>(),
  83. spawn,
  84. }))
  85. vi.doMock('@deepseek-ai/dsh-win32-process', () => ({
  86. loadWin32ProcessBindings: load,
  87. probeCurrentTokenJobSupport: probe,
  88. }))
  89. try {
  90. const isolated = await import('../src/windows-job.ts')
  91. expect(isolated.probeWindowsJob()).toBe(true)
  92. expect(load).toHaveBeenCalledOnce()
  93. expect(probe).toHaveBeenCalledOnce()
  94. const result = isolated.launchWindowsJob(spec, { TARGET: 'yes' })
  95. expect(spawn).toHaveBeenCalledOnce()
  96. child.emit('spawn')
  97. child.emit('message', { type: 'target-exit', exitCode: 0 })
  98. child.connected = false
  99. child.exit( 0, null)
  100. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  101. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  102. } finally {
  103. vi.doUnmock('node:child_process')
  104. vi.doUnmock('@deepseek-ai/dsh-win32-process')
  105. vi.resetModules()
  106. }
  107. })
  108. it('rechecks runner and empty Job support on every eligible spawn', () => {
  109. const runnerAvailable = vi.fn(() => true)
  110. const load = vi.fn(() => ({ bindings: true }) as never)
  111. const probe = vi.fn()
  112. const inputs = {
  113. runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'] as [string, ...string[]],
  114. runnerAvailable,
  115. loadWin32ProcessBindings: load,
  116. probeCurrentTokenJobSupport: probe,
  117. }
  118. expect(probeWindowsJob(inputs)).toBe(true)
  119. expect(probeWindowsJob(inputs)).toBe(true)
  120. expect(runnerAvailable).toHaveBeenCalledTimes(2)
  121. expect(load).toHaveBeenCalledTimes(2)
  122. expect(probe).toHaveBeenCalledTimes(2)
  123. })
  124. it('falls back when either runner or current Job capability is unavailable', () => {
  125. expect(probeWindowsJob({
  126. resolveRunnerInvocation: () => { throw new Error('runner resolution failed') },
  127. })).toBe(false)
  128. expect(probeWindowsJob({ runnerInvocation: ['/missing'], runnerAvailable: () => false })).toBe(false)
  129. expect(probeWindowsJob({
  130. runnerInvocation: ['C:\\node.exe'],
  131. runnerAvailable: () => true,
  132. loadWin32ProcessBindings: () => { throw new Error('bindings missing') },
  133. })).toBe(false)
  134. })
  135. })
  136. describe('Windows parent runner contract', () => {
  137. it('accepts a private result delivered after process exit without waiting for stdio close', async () => {
  138. const { child, result } = launch()
  139. child.emit('exit', 0, null)
  140. child.emit('message', { type: 'target-exit', exitCode: 0 })
  141. await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  142. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  143. })
  144. it('rejects a clean exit when private IPC disconnects without a result', async () => {
  145. const { child, result } = launch()
  146. const direct = result.direct.catch((error: unknown) => error)
  147. const range = result.owner.waitForExit().catch((error: unknown) => error)
  148. child.emit('disconnect')
  149. child.emit('exit', 0, null)
  150. expect(await direct).toBeInstanceOf(Error)
  151. expect(await range).toBeInstanceOf(Error)
  152. })
  153. it('keeps the requested control endpoint separate from runner IPC and ordinary output', () => {
  154. const child = new FakeChild()
  155. const control = new PassThrough()
  156. child.stdio.push(control)
  157. const request = { ...spec, stdio: { ...spec.stdio, control: 'pipe' as const } }
  158. const { result, spawn } = launch(child, request)
  159. expect(result.control).toBe(control)
  160. expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' }, control: 'pipe' }])
  161. expect(spawn).toHaveBeenCalledWith('C:\\node.exe', expect.any(Array), expect.objectContaining({
  162. stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2, 'overlapped'],
  163. }))
  164. control.destroy()
  165. })
  166. it('isolates runner stdio, carries target stdio on fd 4 through fd 6, and sends cwd/env', () => {
  167. const { child, result, spawn } = launch()
  168. expect(spawn).toHaveBeenCalledWith('C:\\node.exe', [
  169. 'C:\\runner.js', '--', 'tool.exe', 'literal arg',
  170. ], expect.objectContaining({
  171. cwd: process.cwd(),
  172. windowsHide: true,
  173. stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2],
  174. }))
  175. expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }])
  176. expect(result.stdin).toBe(child.targetStdin)
  177. expect(result.stdout).toBe(child.targetStdout)
  178. expect(result.stderr).toBe(child.targetStderr)
  179. })
  180. it('carries a null-device fd 4 for ignored stdin and closes the parent descriptor after spawn', () => {
  181. const child = new FakeChild()
  182. const ignored = {
  183. ...spec,
  184. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' },
  185. } as const
  186. const { result, spawn } = launch(child, ignored)
  187. expect(spawn).toHaveBeenCalledWith('C:\\node.exe', expect.any(Array), expect.objectContaining({
  188. stdio: ['ignore', 'ignore', 'ignore', 'ipc', expect.any(Number), 'pipe', 2],
  189. }))
  190. const options = spawn.mock.calls[0]?.[2] as { stdio: unknown[] }
  191. const carrier = options.stdio[4]
  192. if (typeof carrier !== 'number') throw new Error('expected numeric null-device carrier')
  193. expect(() => fstatSync(carrier)).toThrow()
  194. expect(result.stdin).toBeNull()
  195. })
  196. it('closes the ignored-stdin descriptor when runner spawn throws synchronously', () => {
  197. const ignored = {
  198. ...spec,
  199. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' },
  200. } as const
  201. let carrier: number | undefined
  202. const spawn = vi.fn((_command: string, _args: readonly string[], options: unknown) => {
  203. const candidate = (options as { stdio: unknown[] }).stdio[4]
  204. if (typeof candidate !== 'number') throw new Error('expected numeric null-device carrier')
  205. carrier = candidate
  206. throw new Error('runner spawn failed')
  207. })
  208. expect(() => launchWindowsJob(ignored, { TARGET: 'yes' }, {
  209. spawn: spawn as never,
  210. runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'],
  211. })).toThrow('runner spawn failed')
  212. if (carrier === undefined) throw new Error('runner spawn was not attempted')
  213. const closedCarrier = carrier
  214. expect(() => fstatSync(closedCarrier)).toThrow()
  215. })
  216. it('maps target-exit to direct outcome and clean close to range quiescence', async () => {
  217. const { child, result } = launch()
  218. child.emit('message', { type: 'target-exit', exitCode: 7 })
  219. await expect(result.direct).resolves.toEqual({ exitCode: 7, signal: null })
  220. child.connected = false
  221. child.exit( 0, null)
  222. await expect(result.owner.waitForExit()).resolves.toBeUndefined()
  223. })
  224. it('latches target-exit while stdio drains and leaves later runner failure to waitForExit', async () => {
  225. const { child, result } = launch()
  226. const handle = bindManagedProcess(spec, result)
  227. child.emit('message', { type: 'target-exit', exitCode: 7 })
  228. await Promise.resolve()
  229. child.connected = false
  230. child.exit( 127, null)
  231. child.targetStdout.end()
  232. child.targetStderr.end()
  233. await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
  234. await expect(handle.waitForExit()).rejects.toThrow('exit code 127')
  235. })
  236. it('maps errors and restores raw start-cancellation reasons from the parent latch', async () => {
  237. const spawned = launch()
  238. spawned.child.emit('message', {
  239. type: 'error', error: { name: 'Error', message: 'missing', code: 'ENOENT' },
  240. })
  241. await expect(spawned.result.direct).rejects.toMatchObject({ code: 'ENOENT' })
  242. spawned.child.connected = false
  243. spawned.child.exit( 0, null)
  244. await expect(spawned.result.owner.waitForExit()).resolves.toBeUndefined()
  245. const cancelled = launch()
  246. const reason = new Error('caller aborted')
  247. cancelled.result.owner.signal('SIGTERM', reason)
  248. expect(cancelled.child.sent.at(-1)).toEqual({ type: 'terminate' })
  249. cancelled.child.emit('message', {
  250. type: 'error',
  251. error: {
  252. name: 'Error', message: 'subprocess target start was cancelled',
  253. },
  254. })
  255. await expect(cancelled.result.direct).rejects.toBe(reason)
  256. cancelled.child.connected = false
  257. cancelled.child.exit( 0, null)
  258. await expect(cancelled.result.owner.waitForExit()).resolves.toBeUndefined()
  259. const nullCancelled = launch()
  260. nullCancelled.result.owner.signal('SIGTERM', null)
  261. nullCancelled.result.owner.signal('SIGKILL', new Error('later reason'))
  262. nullCancelled.child.emit('message', {
  263. type: 'error',
  264. error: {
  265. name: 'Error', message: 'subprocess target start was cancelled',
  266. },
  267. })
  268. await expect(nullCancelled.result.direct).rejects.toBeNull()
  269. nullCancelled.child.connected = false
  270. nullCancelled.child.exit( 0, null)
  271. await expect(nullCancelled.result.owner.waitForExit()).resolves.toBeUndefined()
  272. const implicit = launch()
  273. implicit.result.owner.signal('SIGTERM')
  274. implicit.child.emit('message', {
  275. type: 'error',
  276. error: {
  277. name: 'Error', message: 'subprocess target start was cancelled',
  278. },
  279. })
  280. await expect(implicit.result.direct).rejects.toBeUndefined()
  281. implicit.child.connected = false
  282. implicit.child.exit( 0, null)
  283. await expect(implicit.result.owner.waitForExit()).resolves.toBeUndefined()
  284. })
  285. it('preserves a strict provider error after a termination request', async () => {
  286. const spawned = launch()
  287. const localReason = new Error('caller aborted after target commit')
  288. spawned.result.owner.signal('SIGTERM', localReason)
  289. spawned.child.emit('message', {
  290. type: 'error',
  291. error: {
  292. name: 'Error',
  293. message: 'poll failed',
  294. code: 'EIO',
  295. syscall: 'QueryInformationJobObject',
  296. },
  297. })
  298. const failure = await spawned.result.direct.catch((error: unknown) => error)
  299. expect(failure).not.toBe(localReason)
  300. expect(failure).toMatchObject({
  301. message: 'poll failed',
  302. code: 'EIO',
  303. syscall: 'QueryInformationJobObject',
  304. })
  305. spawned.child.connected = false
  306. spawned.child.exit( 127, null)
  307. await expect(spawned.result.owner.waitForExit()).rejects.toThrow('exit code 127')
  308. })
  309. it('rejects direct and wait for runner error or abnormal runner exit', async () => {
  310. const failed = launch()
  311. failed.child.emit('message', {
  312. type: 'error', error: { name: 'Error', message: 'Job assignment failed' },
  313. })
  314. await expect(failed.result.direct).rejects.toThrow('Job assignment failed')
  315. failed.child.connected = false
  316. failed.child.exit( 127, null)
  317. await expect(failed.result.owner.waitForExit()).rejects.toThrow('exit code 127')
  318. const missing = launch()
  319. missing.child.connected = false
  320. missing.child.exit( null, 'SIGKILL')
  321. await expect(missing.result.direct).rejects.toThrow('signal SIGKILL')
  322. const statusless = launch()
  323. statusless.child.connected = false
  324. statusless.child.exit( null, null)
  325. await expect(statusless.result.direct).rejects.toThrow('without an exit status')
  326. })
  327. it('fails closed on malformed/duplicate result, runner spawn error, and start-send error', async () => {
  328. const malformed = launch()
  329. malformed.child.emit('message', { type: 'target-exit', exitCode: -1 })
  330. expect(malformed.child.killed).toEqual(['SIGKILL'])
  331. await expect(malformed.result.direct).rejects.toThrow('invalid target-exit')
  332. await expect(malformed.result.owner.waitForExit()).rejects.toThrow('invalid target-exit')
  333. const duplicate = launch()
  334. duplicate.child.emit('message', { type: 'target-exit', exitCode: 0 })
  335. duplicate.child.emit('message', { type: 'target-exit', exitCode: 0 })
  336. await expect(duplicate.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
  337. await expect(duplicate.result.owner.waitForExit()).rejects.toThrow('more than one direct result')
  338. const errored = launch(new FakeChild(), spec, false)
  339. const spawnError = new Error('runner executable missing')
  340. errored.child.emit('error', spawnError)
  341. await expect(errored.result.direct).rejects.toBe(spawnError)
  342. await expect(errored.result.owner.waitForExit()).resolves.toBeUndefined()
  343. errored.child.exit( 127, null)
  344. const postSpawnError = launch()
  345. const infrastructureError = new Error('runner failed after spawn')
  346. postSpawnError.child.emit('error', infrastructureError)
  347. await expect(postSpawnError.result.direct).rejects.toBe(infrastructureError)
  348. await expect(postSpawnError.result.owner.waitForExit()).rejects.toBe(infrastructureError)
  349. const sendFailedChild = new FakeChild()
  350. sendFailedChild.sendError = new Error('IPC send failed')
  351. const sendFailed = launch(sendFailedChild)
  352. await expect(sendFailed.result.direct).rejects.toThrow('IPC send failed')
  353. await expect(sendFailed.result.owner.waitForExit()).rejects.toThrow('IPC send failed')
  354. expect(sendFailedChild.killed).toEqual(['SIGKILL'])
  355. const noIpc = new FakeChild()
  356. Object.defineProperty(noIpc, 'send', { value: undefined })
  357. const noIpcResult = launch(noIpc).result
  358. await expect(noIpcResult.direct).rejects.toThrow('has no IPC channel')
  359. await expect(noIpcResult.owner.waitForExit()).rejects.toThrow('has no IPC channel')
  360. const nonError = new FakeChild()
  361. nonError.throwOnSendCall = 1
  362. nonError.sendThrown = 'start send failed'
  363. const nonErrorResult = launch(nonError).result
  364. await expect(nonErrorResult.direct).rejects.toBe('start send failed')
  365. await expect(nonErrorResult.owner.waitForExit()).rejects.toBe('start send failed')
  366. })
  367. it('fails infrastructure and kills the runner when termination delivery fails', async () => {
  368. const callback = launch()
  369. await Promise.resolve()
  370. callback.child.sendError = new Error('terminate callback failed')
  371. callback.result.owner.signal('SIGTERM')
  372. await expect(callback.result.direct).rejects.toThrow('terminate callback failed')
  373. await expect(callback.result.owner.waitForExit()).rejects.toThrow('terminate callback failed')
  374. expect(callback.child.killed).toEqual(['SIGKILL'])
  375. const throwingChild = new FakeChild()
  376. throwingChild.throwOnSendCall = 2
  377. throwingChild.sendThrown = 'terminate send threw'
  378. const throwing = launch(throwingChild)
  379. throwing.result.owner.signal('SIGTERM')
  380. await expect(throwing.result.direct).rejects.toBe('terminate send threw')
  381. await expect(throwing.result.owner.waitForExit()).rejects.toBe('terminate send threw')
  382. expect(throwing.child.killed).toEqual(['SIGKILL'])
  383. const errorChild = new FakeChild()
  384. errorChild.throwOnSendCall = 2
  385. const error = launch(errorChild)
  386. error.result.owner.signal('SIGTERM')
  387. await expect(error.result.direct).rejects.toThrow('send threw')
  388. await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw')
  389. })
  390. it('accepts clean range settlement after a direct error races redundant termination delivery', async () => {
  391. const child = new FakeChild()
  392. const launched = launch(child)
  393. const handle = bindManagedProcess(spec, launched.result)
  394. await Promise.resolve()
  395. child.deferSendCallbacks = true
  396. child.emit('message', {
  397. type: 'error', error: { name: 'Error', message: 'target start failed', code: 'ENOENT' },
  398. })
  399. await expect(handle.done).rejects.toMatchObject({ code: 'ENOENT' })
  400. expect(child.pendingSendCallbacks).toHaveLength(1)
  401. expect(child.connected).toBe(true)
  402. child.deliverNextSend(new Error('late EPIPE'))
  403. await Promise.resolve()
  404. expect(child.killed).toEqual([])
  405. child.connected = false
  406. child.exit( 0, null)
  407. await expect(handle.waitForExit()).resolves.toBe(true)
  408. })
  409. it('accepts clean range settlement when a target result races redundant termination delivery', async () => {
  410. const child = new FakeChild()
  411. const launched = launch(child)
  412. const handle = bindManagedProcess(spec, launched.result)
  413. await Promise.resolve()
  414. child.deferSendCallbacks = true
  415. child.emit('message', { type: 'target-exit', exitCode: 7 })
  416. child.targetStdout.end()
  417. child.targetStderr.end()
  418. await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
  419. launched.result.owner.signal('SIGTERM')
  420. expect(child.pendingSendCallbacks).toHaveLength(1)
  421. expect(child.connected).toBe(true)
  422. child.deliverNextSend(new Error('late EPIPE'))
  423. await Promise.resolve()
  424. expect(child.killed).toEqual([])
  425. child.connected = false
  426. child.exit( 0, null)
  427. await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
  428. await expect(handle.waitForExit()).resolves.toBe(true)
  429. })
  430. it('uses synchronous runner termination for host exit and isolates repeated control', () => {
  431. const { child, result } = launch()
  432. result.owner.signal('SIGTERM', new Error('first'))
  433. result.owner.signal('SIGKILL', new Error('second'))
  434. expect(child.sent.filter(message => (message as { type?: string }).type === 'terminate')).toHaveLength(1)
  435. result.owner.terminateForHostExit()
  436. expect(child.killed).toEqual(['SIGKILL'])
  437. })
  438. })