local.spec.ts 18 KB

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