windows-job.spec.ts 18 KB

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