local.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  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. snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }),
  284. isAlive: () => false,
  285. signalGroup: () => {},
  286. signalProcess: () => {},
  287. }
  288. const terminal = {
  289. pid: 123,
  290. onData: () => ({ dispose: () => {} }),
  291. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  292. exitListener = listener
  293. return { dispose: () => {} }
  294. },
  295. write: () => {},
  296. kill: () => {},
  297. }
  298. vi.resetModules()
  299. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  300. vi.doMock('../src/process-inspector.ts', async importOriginal => ({
  301. ...await importOriginal<typeof import('../src/process-inspector.ts')>(),
  302. createProcessInspector: () => inspector,
  303. }))
  304. try {
  305. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  306. const ctx = new Context()
  307. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  308. const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
  309. const handle = await ctx.subprocess.spawnTerminal({
  310. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  311. })
  312. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  313. exitListener?.({ exitCode: 0 })
  314. await handle.done
  315. await new Promise(resolve => setImmediate(resolve))
  316. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(0)
  317. await fiber.dispose()
  318. } finally {
  319. vi.doUnmock('node-pty')
  320. vi.doUnmock('../src/process-inspector.ts')
  321. vi.resetModules()
  322. }
  323. })
  324. it('retains a terminal whose automatic cleanup fails', async () => {
  325. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  326. const terminal = {
  327. pid: 123,
  328. onData: () => ({ dispose: () => {} }),
  329. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  330. exitListener = listener
  331. return { dispose: () => {} }
  332. },
  333. write: () => {},
  334. kill: () => {},
  335. }
  336. vi.resetModules()
  337. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  338. try {
  339. const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
  340. const ctx = new Context()
  341. const disposalErrors: unknown[] = []
  342. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  343. const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
  344. const alive = new Set([124])
  345. ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
  346. foregroundPgid: () => 123,
  347. isStdinWaiting: () => false,
  348. snapshot: () => ({
  349. tree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
  350. session: () => [],
  351. alive: identity => alive.has(identity.pid),
  352. }),
  353. isAlive: identity => alive.has(identity.pid),
  354. signalGroup: () => {},
  355. signalProcess: () => {},
  356. }
  357. const handle = await ctx.subprocess.spawnTerminal({
  358. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  359. })
  360. exitListener?.({ exitCode: 0 })
  361. await handle.done
  362. await new Promise(resolve => setTimeout(resolve, 10))
  363. expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  364. await fiber.dispose()
  365. expect(disposalErrors).toHaveLength(1)
  366. } finally {
  367. vi.doUnmock('node-pty')
  368. vi.resetModules()
  369. }
  370. })
  371. it('registers as ctx.subprocess and spawns managed handles', async () => {
  372. const ctx = new Context()
  373. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  374. const handle = ctx.subprocess.spawn(spec('echo managed'))
  375. const result = await handle.done
  376. expect(result.exitCode).toBe(0)
  377. expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
  378. await fiber.dispose()
  379. })
  380. it('disposal kills still-running processes and awaits their exit', async () => {
  381. const ctx = new Context()
  382. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  383. const handle = ctx.subprocess.spawn(spec('sleep 60'))
  384. await fiber.dispose()
  385. const outcome = await handle.done
  386. // Windows teardown terminates through taskkill, which reports no signal.
  387. expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
  388. })
  389. it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
  390. const ctx = new Context()
  391. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  392. const handle = ctx.subprocess.spawn(spec('true'))
  393. const outcome = await handle.done
  394. expect(outcome.exitCode).toBe(0)
  395. await fiber.dispose()
  396. })
  397. it('disposal tolerates a handle whose spawn already failed', async () => {
  398. const ctx = new Context()
  399. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  400. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  401. await expect(handle.done).rejects.toThrow()
  402. await fiber.dispose()
  403. })
  404. it('disposal contains a spawn-failure rejection that races teardown', async () => {
  405. const ctx = new Context()
  406. const fiber = await ctx.plugin(LocalSubprocessRuntime)
  407. // Dispose before the rejection continuation removes the handle from the
  408. // live set, so teardown itself must swallow the rejected done.
  409. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  410. await fiber.dispose()
  411. await expect(handle.done).rejects.toThrow()
  412. })
  413. it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
  414. const ctx = new Context()
  415. await ctx.plugin(LocalSubprocessRuntime)
  416. class SecondManager extends LocalSubprocessRuntime {}
  417. await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
  418. })
  419. })