local.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import { PassThrough } from 'node:stream'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { basename, dirname, relative, resolve } from 'node:path'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  6. import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  7. import { childEnv } from '../src/spawn.ts'
  8. function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  9. // Windows has no bash; the suite's simple commands translate to node one-liners.
  10. const argv = process.platform === 'win32'
  11. ? [process.execPath, '-e', {
  12. 'echo managed': 'console.log("managed")',
  13. 'sleep 60': 'setTimeout(() => {}, 60000)',
  14. 'true': '',
  15. }[command] ?? command]
  16. : ['bash', '-c', command]
  17. return {
  18. argv,
  19. cwd: process.cwd(),
  20. stdio: {
  21. stdin: 'ignore',
  22. stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
  23. stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
  24. },
  25. graceMs: 200,
  26. ...overrides,
  27. }
  28. }
  29. describe('LocalSubprocessRuntime', () => {
  30. it('places the host-exit finalizer before listeners that predate the service', async () => {
  31. const baseline = new Set(process.listeners('exit'))
  32. const prior = vi.fn()
  33. process.on('exit', prior)
  34. const ctx = new Context()
  35. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  36. try {
  37. const listeners = process.listeners('exit')
  38. const finalizer = listeners.find(candidate => !baseline.has(candidate) && candidate !== prior)
  39. expect(finalizer).toBeTypeOf('function')
  40. expect(listeners.indexOf(finalizer!)).toBeLessThan(listeners.indexOf(prior))
  41. } finally {
  42. process.off('exit', prior)
  43. await fiber.dispose()
  44. }
  45. })
  46. it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => {
  47. const before = new Set(process.listeners('exit'))
  48. const ctx = new Context()
  49. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  50. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  51. expect(listener).toBeTypeOf('function')
  52. let finishExit!: () => void
  53. const exited = new Promise<void>((resolve) => { finishExit = resolve })
  54. const terminate = vi.fn()
  55. const terminateForHostExit = vi.fn()
  56. const live = (ctx.subprocess as unknown as {
  57. live: Set<{
  58. done: Promise<{ exitCode: number; signal: null }>
  59. terminate(): void
  60. terminateForHostExit(): void
  61. waitForExit(): Promise<boolean>
  62. }>
  63. }).live
  64. live.add({
  65. done: Promise.resolve({ exitCode: 0, signal: null }),
  66. terminate,
  67. terminateForHostExit,
  68. waitForExit: async () => { await exited; return true },
  69. })
  70. let disposed = false
  71. const disposing = fiber.dispose().then(() => { disposed = true })
  72. await new Promise(resolve => setImmediate(resolve))
  73. expect(disposed).toBe(false)
  74. expect(live.size).toBe(1)
  75. listener?.(0)
  76. expect(terminate).toHaveBeenCalledOnce()
  77. expect(terminateForHostExit).toHaveBeenCalledOnce()
  78. finishExit()
  79. await disposing
  80. expect(live.size).toBe(0)
  81. expect(process.listeners('exit')).not.toContain(listener)
  82. })
  83. it('contains each host-exit termination failure and continues with the other targets', async () => {
  84. const before = new Set(process.listeners('exit'))
  85. const ctx = new Context()
  86. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  87. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  88. expect(listener).toBeTypeOf('function')
  89. const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') })
  90. const ordinarySuccess = vi.fn()
  91. const terminalFailure = vi.fn(() => { throw new Error('terminal failed') })
  92. const terminalSuccess = vi.fn()
  93. const service = ctx.subprocess as unknown as {
  94. live: Set<{ terminateForHostExit(): void }>
  95. terminals: Set<{ terminateForHostExit(): void }>
  96. }
  97. service.live.add({ terminateForHostExit: ordinaryFailure })
  98. service.live.add({ terminateForHostExit: ordinarySuccess })
  99. service.terminals.add({ terminateForHostExit: terminalFailure })
  100. service.terminals.add({ terminateForHostExit: terminalSuccess })
  101. expect(() => { listener?.(0) }).not.toThrow()
  102. expect(ordinaryFailure).toHaveBeenCalledOnce()
  103. expect(ordinarySuccess).toHaveBeenCalledOnce()
  104. expect(terminalFailure).toHaveBeenCalledOnce()
  105. expect(terminalSuccess).toHaveBeenCalledOnce()
  106. service.live.clear()
  107. service.terminals.clear()
  108. await fiber.dispose()
  109. })
  110. it('resolves absolute and PATH executables and honors lookup cancellation', async () => {
  111. const ctx = new Context()
  112. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  113. expect(await ctx.subprocess.resolveExecutable(process.execPath)).toBe(process.execPath)
  114. expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
  115. PATH: dirname(process.execPath),
  116. })).toBe(process.execPath)
  117. expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
  118. PATH: relative(process.cwd(), dirname(process.execPath)) || '.',
  119. })).toBe(process.execPath)
  120. await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
  121. await expect(ctx.subprocess.resolveExecutable('./bin/tsserver'))
  122. .rejects.toThrow('is a relative path')
  123. await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server'))
  124. .rejects.toThrow('is a relative path')
  125. await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
  126. .rejects.toThrow('was not found on PATH')
  127. await expect(ctx.subprocess.resolveExecutable('/dsh-absolute-command-that-does-not-exist'))
  128. .rejects.toThrow('is not an executable file')
  129. await expect(ctx.subprocess.resolveExecutable(process.cwd()))
  130. .rejects.toThrow('is not an executable file')
  131. await expect(ctx.subprocess.resolveExecutable(process.execPath, {}, AbortSignal.abort('stop')))
  132. .rejects.toBe('stop')
  133. await fiber.dispose()
  134. })
  135. it('builds Windows executable candidates with case-insensitive overrides', async () => {
  136. const ctx = new Context()
  137. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  138. const service = ctx.subprocess as LocalSubprocessRuntime
  139. const candidates = (service as unknown as {
  140. executableCandidates(command: string, env: NodeJS.ProcessEnv): string[]
  141. }).executableCandidates.bind(service)
  142. const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  143. try {
  144. expect(Object.keys(childEnv()).filter(key => key.toUpperCase() === 'PATH')).toHaveLength(1)
  145. const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' })
  146. expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path'])
  147. expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt'])
  148. expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')])
  149. expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' }))
  150. .toEqual([resolve('/explicit', 'tool.EXE')])
  151. expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')])
  152. expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
  153. await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`))
  154. .rejects.toThrow('is a relative path')
  155. } finally {
  156. platform.mockRestore()
  157. await fiber.dispose()
  158. }
  159. })
  160. it('validates terminal allocation inputs before allocating a PTY', async () => {
  161. const ctx = new Context()
  162. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  163. const base: SubprocessTerminalSpawnSpec = {
  164. argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
  165. }
  166. await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
  167. await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
  168. await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
  169. await fiber.dispose()
  170. })
  171. it('terminates and joins an owned terminal during disposal', async () => {
  172. const ctx = new Context()
  173. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  174. const terminate = vi.fn(async () => {})
  175. const terminal: SubprocessTerminalHandle = {
  176. pid: 1,
  177. output: new PassThrough(),
  178. done: Promise.resolve({ exitCode: 0, signal: null }),
  179. write: async () => {},
  180. inspectForeground: async () => undefined,
  181. signalForeground: async () => 1,
  182. terminate,
  183. }
  184. const terminals = (ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  185. terminals.add(terminal)
  186. await fiber.dispose()
  187. expect(terminate).toHaveBeenCalledOnce()
  188. expect(terminals.size).toBe(0)
  189. })
  190. it('waits for every terminal cleanup and aggregates teardown failures', async () => {
  191. const ctx = new Context()
  192. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  193. const service = ctx.subprocess
  194. const firstFailure = new Error('first cleanup failure')
  195. const secondFailure = new Error('second cleanup failure')
  196. const disposalErrors: unknown[] = []
  197. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  198. const failedTerminal: SubprocessTerminalHandle = {
  199. pid: 1,
  200. output: new PassThrough(),
  201. done: Promise.resolve({ exitCode: 0, signal: null }),
  202. write: async () => {},
  203. inspectForeground: async () => undefined,
  204. signalForeground: async () => 1,
  205. terminate: vi.fn(async () => { throw firstFailure }),
  206. }
  207. const secondFailedTerminal: SubprocessTerminalHandle = {
  208. ...failedTerminal,
  209. terminate: vi.fn(async () => { throw secondFailure }),
  210. }
  211. let finishCleanup!: () => void
  212. const cleanup = new Promise<void>((resolve) => {
  213. finishCleanup = resolve
  214. })
  215. const drainingTerminal: SubprocessTerminalHandle = {
  216. ...failedTerminal,
  217. terminate: vi.fn(() => cleanup),
  218. }
  219. const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  220. terminals.add(failedTerminal)
  221. terminals.add(secondFailedTerminal)
  222. terminals.add(drainingTerminal)
  223. let disposed = false
  224. const disposing = fiber.dispose().then(() => { disposed = true })
  225. await new Promise(resolve => setImmediate(resolve))
  226. expect(disposed).toBe(false)
  227. finishCleanup()
  228. await disposing
  229. expect(terminals.size).toBe(0)
  230. expect(disposalErrors).toHaveLength(1)
  231. expect(disposalErrors[0]).toMatchObject({
  232. errors: [firstFailure, secondFailure],
  233. message: 'local subprocess teardown failed',
  234. })
  235. })
  236. it('reports one cleanup failure without wrapping it', async () => {
  237. const ctx = new Context()
  238. const failure = new Error('single cleanup failure')
  239. const disposalErrors: unknown[] = []
  240. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  241. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  242. const service = ctx.subprocess
  243. const terminal: SubprocessTerminalHandle = {
  244. pid: 1,
  245. output: new PassThrough(),
  246. done: Promise.resolve({ exitCode: 0, signal: null }),
  247. write: async () => {},
  248. inspectForeground: async () => undefined,
  249. signalForeground: async () => 1,
  250. terminate: vi.fn(async () => { throw failure }),
  251. }
  252. const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  253. terminals.add(terminal)
  254. await fiber.dispose()
  255. expect(disposalErrors).toEqual([failure])
  256. })
  257. it('force-terminates remaining targets before releasing a failed disposal', async () => {
  258. const before = new Set(process.listeners('exit'))
  259. const ctx = new Context()
  260. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  261. const listener = process.listeners('exit').find(candidate => !before.has(candidate))
  262. expect(listener).toBeTypeOf('function')
  263. const failure = new Error('cleanup failed')
  264. const terminateForHostExit = vi.fn(() => {
  265. expect(process.listeners('exit')).toContain(listener)
  266. })
  267. const terminal = {
  268. terminate: vi.fn(async () => { throw failure }),
  269. terminateForHostExit,
  270. }
  271. const terminals = (ctx.subprocess as unknown as { terminals: Set<typeof terminal> }).terminals
  272. terminals.add(terminal)
  273. await fiber.dispose()
  274. expect(terminateForHostExit).toHaveBeenCalledOnce()
  275. expect(terminals.size).toBe(0)
  276. expect(process.listeners('exit')).not.toContain(listener)
  277. })
  278. it('releases a terminal after top-level exit reaches quiescence', async () => {
  279. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  280. const inspector = {
  281. foregroundPgid: () => undefined,
  282. isStdinWaiting: () => false,
  283. processTree: () => [],
  284. processSession: () => [],
  285. isAlive: () => false,
  286. signalGroup: () => {},
  287. signalProcess: () => {},
  288. }
  289. const terminal = {
  290. pid: 123,
  291. onData: () => ({ dispose: () => {} }),
  292. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  293. exitListener = listener
  294. return { dispose: () => {} }
  295. },
  296. write: () => {},
  297. kill: () => {},
  298. }
  299. vi.resetModules()
  300. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  301. vi.doMock('../src/process-inspector.ts', async importOriginal => ({
  302. ...await importOriginal<typeof import('../src/process-inspector.ts')>(),
  303. createProcessInspector: () => inspector,
  304. }))
  305. try {
  306. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  307. const ctx = new Context()
  308. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  309. const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  310. const handle = await ctx.subprocess.spawnTerminal({
  311. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  312. })
  313. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  314. exitListener?.({ exitCode: 0 })
  315. await handle.done
  316. await new Promise(resolve => setImmediate(resolve))
  317. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(0)
  318. await fiber.dispose()
  319. } finally {
  320. vi.doUnmock('node-pty')
  321. vi.doUnmock('../src/process-inspector.ts')
  322. vi.resetModules()
  323. }
  324. })
  325. it('retains a terminal whose automatic cleanup fails', async () => {
  326. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  327. const terminal = {
  328. pid: 123,
  329. onData: () => ({ dispose: () => {} }),
  330. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  331. exitListener = listener
  332. return { dispose: () => {} }
  333. },
  334. write: () => {},
  335. kill: () => {},
  336. }
  337. vi.resetModules()
  338. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  339. try {
  340. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  341. const ctx = new Context()
  342. const disposalErrors: unknown[] = []
  343. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  344. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  345. const alive = new Set([124])
  346. ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
  347. foregroundPgid: () => 123,
  348. isStdinWaiting: () => false,
  349. processTree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
  350. processSession: () => [],
  351. isAlive: identity => alive.has(identity.pid),
  352. signalGroup: () => {},
  353. signalProcess: () => {},
  354. }
  355. const handle = await ctx.subprocess.spawnTerminal({
  356. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  357. })
  358. exitListener?.({ exitCode: 0 })
  359. await handle.done
  360. await new Promise(resolve => setTimeout(resolve, 10))
  361. expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  362. await fiber.dispose()
  363. expect(disposalErrors).toHaveLength(1)
  364. } finally {
  365. vi.doUnmock('node-pty')
  366. vi.resetModules()
  367. }
  368. })
  369. it('registers as ctx.subprocess and spawns managed handles', async () => {
  370. const ctx = new Context()
  371. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  372. const handle = ctx.subprocess.spawn(spec('echo managed'))
  373. const result = await handle.done
  374. expect(result.exitCode).toBe(0)
  375. expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
  376. await fiber.dispose()
  377. })
  378. it('disposal kills still-running processes and awaits their exit', async () => {
  379. const ctx = new Context()
  380. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  381. const handle = ctx.subprocess.spawn(spec('sleep 60'))
  382. await fiber.dispose()
  383. const outcome = await handle.done
  384. // Windows teardown terminates through taskkill, which reports no signal.
  385. expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
  386. })
  387. it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
  388. const ctx = new Context()
  389. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  390. const handle = ctx.subprocess.spawn(spec('true'))
  391. const outcome = await handle.done
  392. expect(outcome.exitCode).toBe(0)
  393. await fiber.dispose()
  394. })
  395. it('disposal tolerates a handle whose spawn already failed', async () => {
  396. const ctx = new Context()
  397. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  398. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  399. await expect(handle.done).rejects.toThrow()
  400. await fiber.dispose()
  401. })
  402. it('disposal contains a spawn-failure rejection that races teardown', async () => {
  403. const ctx = new Context()
  404. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  405. // Dispose before the rejection continuation removes the handle from the
  406. // live set, so teardown itself must swallow the rejected done.
  407. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  408. await fiber.dispose()
  409. await expect(handle.done).rejects.toThrow()
  410. })
  411. it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
  412. const ctx = new Context()
  413. await ctx.plugin(LocalSubprocessRuntime)
  414. class SecondManager extends LocalSubprocessRuntime {}
  415. await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
  416. })
  417. })