local.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  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 LocalSubprocessService 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('LocalSubprocessService', () => {
  22. it('resolves absolute and PATH executables and honors lookup cancellation', async () => {
  23. const ctx = new Context()
  24. const fiber = await ctx.plugin(LocalSubprocessService)
  25. expect(await ctx.subprocess.resolveExecutable(process.execPath)).toBe(process.execPath)
  26. expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
  27. PATH: dirname(process.execPath),
  28. })).toBe(process.execPath)
  29. expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
  30. PATH: relative(process.cwd(), dirname(process.execPath)) || '.',
  31. })).toBe(process.execPath)
  32. await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
  33. await expect(ctx.subprocess.resolveExecutable('./bin/tsserver'))
  34. .rejects.toThrow('is a relative path')
  35. await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server'))
  36. .rejects.toThrow('is a relative path')
  37. await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
  38. .rejects.toThrow('was not found on PATH')
  39. await expect(ctx.subprocess.resolveExecutable('/dsh-absolute-command-that-does-not-exist'))
  40. .rejects.toThrow('is not an executable file')
  41. await expect(ctx.subprocess.resolveExecutable(process.cwd()))
  42. .rejects.toThrow('is not an executable file')
  43. await expect(ctx.subprocess.resolveExecutable(process.execPath, {}, AbortSignal.abort('stop')))
  44. .rejects.toBe('stop')
  45. await fiber.dispose()
  46. })
  47. it('builds Windows executable candidates with case-insensitive overrides', async () => {
  48. const ctx = new Context()
  49. const fiber = await ctx.plugin(LocalSubprocessService)
  50. const service = ctx.subprocess as LocalSubprocessService
  51. const candidates = (service as unknown as {
  52. executableCandidates(command: string, env: NodeJS.ProcessEnv): string[]
  53. }).executableCandidates.bind(service)
  54. const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  55. try {
  56. expect(Object.keys(childEnv()).filter(key => key.toUpperCase() === 'PATH')).toHaveLength(1)
  57. const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' })
  58. expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path'])
  59. expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt'])
  60. expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD'])
  61. expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' }))
  62. .toEqual(['/explicit/tool.EXE'])
  63. expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')])
  64. expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
  65. await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`))
  66. .rejects.toThrow('is a relative path')
  67. } finally {
  68. platform.mockRestore()
  69. await fiber.dispose()
  70. }
  71. })
  72. it('validates terminal allocation inputs before allocating a PTY', async () => {
  73. const ctx = new Context()
  74. const fiber = await ctx.plugin(LocalSubprocessService)
  75. const base: SubprocessTerminalSpawnSpec = {
  76. argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
  77. }
  78. await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
  79. await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
  80. await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
  81. await fiber.dispose()
  82. })
  83. it('terminates and joins an owned terminal during disposal', async () => {
  84. const ctx = new Context()
  85. const fiber = await ctx.plugin(LocalSubprocessService)
  86. const terminate = vi.fn(async () => {})
  87. const terminal: SubprocessTerminalHandle = {
  88. pid: 1,
  89. output: new PassThrough(),
  90. done: Promise.resolve({ exitCode: 0, signal: null }),
  91. write: async () => {},
  92. inspectForeground: async () => undefined,
  93. signalForeground: async () => 1,
  94. terminate,
  95. }
  96. const terminals = (ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  97. terminals.add(terminal)
  98. await fiber.dispose()
  99. expect(terminate).toHaveBeenCalledOnce()
  100. expect(terminals.size).toBe(0)
  101. })
  102. it('waits for every terminal cleanup and aggregates teardown failures', async () => {
  103. const ctx = new Context()
  104. const fiber = await ctx.plugin(LocalSubprocessService)
  105. const service = ctx.subprocess
  106. const firstFailure = new Error('first cleanup failure')
  107. const secondFailure = new Error('second cleanup failure')
  108. const disposalErrors: unknown[] = []
  109. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  110. const failedTerminal: SubprocessTerminalHandle = {
  111. pid: 1,
  112. output: new PassThrough(),
  113. done: Promise.resolve({ exitCode: 0, signal: null }),
  114. write: async () => {},
  115. inspectForeground: async () => undefined,
  116. signalForeground: async () => 1,
  117. terminate: vi.fn(async () => { throw firstFailure }),
  118. }
  119. const secondFailedTerminal: SubprocessTerminalHandle = {
  120. ...failedTerminal,
  121. terminate: vi.fn(async () => { throw secondFailure }),
  122. }
  123. let finishCleanup!: () => void
  124. const cleanup = new Promise<void>((resolve) => {
  125. finishCleanup = resolve
  126. })
  127. const drainingTerminal: SubprocessTerminalHandle = {
  128. ...failedTerminal,
  129. terminate: vi.fn(() => cleanup),
  130. }
  131. const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  132. terminals.add(failedTerminal)
  133. terminals.add(secondFailedTerminal)
  134. terminals.add(drainingTerminal)
  135. let disposed = false
  136. const disposing = fiber.dispose().then(() => { disposed = true })
  137. await new Promise(resolve => setImmediate(resolve))
  138. expect(disposed).toBe(false)
  139. finishCleanup()
  140. await disposing
  141. expect(terminals.size).toBe(0)
  142. expect(disposalErrors).toHaveLength(1)
  143. expect(disposalErrors[0]).toMatchObject({
  144. errors: [firstFailure, secondFailure],
  145. message: 'local subprocess teardown failed',
  146. })
  147. })
  148. it('reports one cleanup failure without wrapping it', async () => {
  149. const ctx = new Context()
  150. const failure = new Error('single cleanup failure')
  151. const disposalErrors: unknown[] = []
  152. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  153. const fiber = await ctx.plugin(LocalSubprocessService)
  154. const service = ctx.subprocess
  155. const terminal: SubprocessTerminalHandle = {
  156. pid: 1,
  157. output: new PassThrough(),
  158. done: Promise.resolve({ exitCode: 0, signal: null }),
  159. write: async () => {},
  160. inspectForeground: async () => undefined,
  161. signalForeground: async () => 1,
  162. terminate: vi.fn(async () => { throw failure }),
  163. }
  164. const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
  165. terminals.add(terminal)
  166. await fiber.dispose()
  167. expect(disposalErrors).toEqual([failure])
  168. })
  169. it('releases a terminal after top-level exit reaches quiescence', async () => {
  170. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  171. const inspector = {
  172. foregroundPgid: () => undefined,
  173. isStdinWaiting: () => false,
  174. processTree: () => [],
  175. processSession: () => [],
  176. isAlive: () => false,
  177. signalGroup: () => {},
  178. signalProcess: () => {},
  179. }
  180. const terminal = {
  181. pid: 123,
  182. onData: () => ({ dispose: () => {} }),
  183. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  184. exitListener = listener
  185. return { dispose: () => {} }
  186. },
  187. write: () => {},
  188. kill: () => {},
  189. }
  190. vi.resetModules()
  191. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  192. vi.doMock('../src/process-inspector.ts', async importOriginal => ({
  193. ...await importOriginal<typeof import('../src/process-inspector.ts')>(),
  194. createProcessInspector: () => inspector,
  195. }))
  196. try {
  197. const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
  198. const ctx = new Context()
  199. const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
  200. const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>
  201. const handle = await ctx.subprocess.spawnTerminal({
  202. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  203. })
  204. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  205. exitListener?.({ exitCode: 0 })
  206. await handle.done
  207. await new Promise(resolve => setImmediate(resolve))
  208. expect((service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(0)
  209. await fiber.dispose()
  210. } finally {
  211. vi.doUnmock('node-pty')
  212. vi.doUnmock('../src/process-inspector.ts')
  213. vi.resetModules()
  214. }
  215. })
  216. it('retains a terminal whose automatic cleanup fails', async () => {
  217. let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
  218. const terminal = {
  219. pid: 123,
  220. onData: () => ({ dispose: () => {} }),
  221. onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
  222. exitListener = listener
  223. return { dispose: () => {} }
  224. },
  225. write: () => {},
  226. kill: () => {},
  227. }
  228. vi.resetModules()
  229. vi.doMock('node-pty', () => ({ spawn: () => terminal }))
  230. try {
  231. const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
  232. const ctx = new Context()
  233. const disposalErrors: unknown[] = []
  234. ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
  235. const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
  236. const alive = new Set([124])
  237. ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
  238. foregroundPgid: () => 123,
  239. isStdinWaiting: () => false,
  240. processTree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
  241. processSession: () => [],
  242. isAlive: identity => alive.has(identity.pid),
  243. signalGroup: () => {},
  244. signalProcess: () => {},
  245. }
  246. const handle = await ctx.subprocess.spawnTerminal({
  247. argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
  248. })
  249. exitListener?.({ exitCode: 0 })
  250. await handle.done
  251. await new Promise(resolve => setTimeout(resolve, 10))
  252. expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
  253. await fiber.dispose()
  254. expect(disposalErrors).toHaveLength(1)
  255. } finally {
  256. vi.doUnmock('node-pty')
  257. vi.resetModules()
  258. }
  259. })
  260. it('registers as ctx.subprocess and spawns managed handles', async () => {
  261. const ctx = new Context()
  262. const fiber = await ctx.plugin(LocalSubprocessService)
  263. const handle = ctx.subprocess.spawn(spec('echo managed'))
  264. const result = await handle.done
  265. expect(result.exitCode).toBe(0)
  266. expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
  267. await fiber.dispose()
  268. })
  269. it('disposal kills still-running processes and awaits their exit', async () => {
  270. const ctx = new Context()
  271. const fiber = await ctx.plugin(LocalSubprocessService)
  272. const handle = ctx.subprocess.spawn(spec('sleep 60'))
  273. await fiber.dispose()
  274. const outcome = await handle.done
  275. expect(outcome.signal).toBe('SIGTERM')
  276. })
  277. it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
  278. const ctx = new Context()
  279. const fiber = await ctx.plugin(LocalSubprocessService)
  280. const handle = ctx.subprocess.spawn(spec('true'))
  281. const outcome = await handle.done
  282. expect(outcome.exitCode).toBe(0)
  283. await fiber.dispose()
  284. })
  285. it('disposal tolerates a handle whose spawn already failed', async () => {
  286. const ctx = new Context()
  287. const fiber = await ctx.plugin(LocalSubprocessService)
  288. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  289. await expect(handle.done).rejects.toThrow()
  290. await fiber.dispose()
  291. })
  292. it('disposal contains a spawn-failure rejection that races teardown', async () => {
  293. const ctx = new Context()
  294. const fiber = await ctx.plugin(LocalSubprocessService)
  295. // Dispose before the rejection continuation removes the handle from the
  296. // live set, so teardown itself must swallow the rejected done.
  297. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  298. await fiber.dispose()
  299. await expect(handle.done).rejects.toThrow()
  300. })
  301. it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
  302. const ctx = new Context()
  303. await ctx.plugin(LocalSubprocessService)
  304. class SecondManager extends LocalSubprocessService {}
  305. await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
  306. })
  307. })