local.spec.ts 2.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  4. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  5. function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
  6. return {
  7. argv: ['bash', '-c', command],
  8. cwd: process.cwd(),
  9. stdio: {
  10. stdin: 'ignore',
  11. stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
  12. stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
  13. },
  14. graceMs: 200,
  15. ...overrides,
  16. }
  17. }
  18. describe('LocalSubprocessService', () => {
  19. it('registers as ctx.subprocess and spawns managed handles', async () => {
  20. const ctx = new Context()
  21. const fiber = await ctx.plugin(LocalSubprocessService)
  22. const handle = ctx.subprocess.spawn(spec('echo managed'))
  23. const result = await handle.done
  24. expect(result.exitCode).toBe(0)
  25. expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
  26. await fiber.dispose()
  27. })
  28. it('disposal kills still-running processes and awaits their exit', async () => {
  29. const ctx = new Context()
  30. const fiber = await ctx.plugin(LocalSubprocessService)
  31. const handle = ctx.subprocess.spawn(spec('sleep 60'))
  32. await fiber.dispose()
  33. const outcome = await handle.done
  34. expect(outcome.signal).toBe('SIGTERM')
  35. })
  36. it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
  37. const ctx = new Context()
  38. const fiber = await ctx.plugin(LocalSubprocessService)
  39. const handle = ctx.subprocess.spawn(spec('true'))
  40. const outcome = await handle.done
  41. expect(outcome.exitCode).toBe(0)
  42. await fiber.dispose()
  43. })
  44. it('disposal tolerates a handle whose spawn already failed', async () => {
  45. const ctx = new Context()
  46. const fiber = await ctx.plugin(LocalSubprocessService)
  47. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  48. await expect(handle.done).rejects.toThrow()
  49. await fiber.dispose()
  50. })
  51. it('disposal contains a spawn-failure rejection that races teardown', async () => {
  52. const ctx = new Context()
  53. const fiber = await ctx.plugin(LocalSubprocessService)
  54. // Dispose before the rejection continuation removes the handle from the
  55. // live set, so teardown itself must swallow the rejected done.
  56. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
  57. await fiber.dispose()
  58. await expect(handle.done).rejects.toThrow()
  59. })
  60. it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
  61. const ctx = new Context()
  62. await ctx.plugin(LocalSubprocessService)
  63. class SecondManager extends LocalSubprocessService {}
  64. await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
  65. })
  66. })