control.spec.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import { mkdtemp, rm } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { once } from 'node:events'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { Context } from '@deepseek-ai/cordis'
  8. import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  9. import { SUBPROCESS_CONTROL_ENV } from '@deepseek-ai/dsh-subprocess/control'
  10. import { LocalSubprocessRuntime } from '../src/index.ts'
  11. import { spawnSubprocess } from '../src/spawn.ts'
  12. const fixture = fileURLToPath(new URL('./fixtures/control-child.ts', import.meta.url))
  13. const helper = fileURLToPath(new URL('../../subprocess/src/control.ts', import.meta.url))
  14. let ctx: Context | undefined
  15. let root: string | undefined
  16. let handle: SubprocessHandle | undefined
  17. afterEach(async () => {
  18. handle?.terminate()
  19. await handle?.waitForExit()
  20. handle?.control?.destroy()
  21. await ctx?.fiber.dispose()
  22. if (root !== undefined) await rm(root, { recursive: true, force: true })
  23. ctx = undefined
  24. root = undefined
  25. handle = undefined
  26. })
  27. describe('managed subprocess control pipe', () => {
  28. it('closes the caller endpoint when service disposal terminates an active program', async () => {
  29. ctx = new Context()
  30. await ctx.plugin(LocalSubprocessRuntime)
  31. handle = ctx.subprocess.spawn({
  32. argv: [process.execPath, '--input-type=module', '-e',
  33. 'import { Socket } from "node:net"; const c = new Socket({fd:7,readable:true,writable:true}); c.write("ready"); setInterval(()=>{},60000)'],
  34. cwd: process.cwd(),
  35. stdio: { stdin: 'ignore', stdout: { maxBytes: 32 }, stderr: { maxBytes: 32 }, control: 'pipe' },
  36. graceMs: 1000,
  37. })
  38. const channel = handle.control
  39. if (channel === undefined) throw new Error('requested control pipe is absent')
  40. await once(channel, 'data')
  41. await ctx.fiber.dispose()
  42. expect(channel.destroyed).toBe(true)
  43. expect(await handle.waitForExit()).toBe(true)
  44. })
  45. it('leaves the channel absent on an ordinary spawn', async () => {
  46. ctx = new Context()
  47. await ctx.plugin(LocalSubprocessRuntime)
  48. handle = ctx.subprocess.spawn({
  49. argv: [process.execPath, '-e', 'process.stdout.write("plain")'],
  50. cwd: process.cwd(),
  51. stdio: { stdin: 'ignore', stdout: { maxBytes: 32 }, stderr: { maxBytes: 32 } },
  52. graceMs: 1000,
  53. })
  54. expect(handle.control).toBeUndefined()
  55. expect(await handle.done).toEqual({ exitCode: 0, signal: null })
  56. expect(handle.collected.stdout?.readFrom(0).text).toBe('plain')
  57. expect(await handle.waitForExit()).toBe(true)
  58. })
  59. it.each(['managed', 'fallback'] as const)('returns exact binary control bytes through %s independently of stdio', async (backend) => {
  60. root = await mkdtemp(join(tmpdir(), 'dsh-control-'))
  61. ctx = new Context()
  62. await ctx.plugin(LocalSubprocessRuntime)
  63. const input = Buffer.alloc(256 * 1024)
  64. for (let index = 0; index < input.length; index++) input[index] = index % 256
  65. const request: SubprocessSpawnSpec = {
  66. argv: [process.execPath, fixture, helper, String(input.length)],
  67. cwd: root,
  68. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 }, control: 'pipe' },
  69. graceMs: 1000,
  70. }
  71. handle = backend === 'managed' ? ctx.subprocess.spawn(request) : spawnSubprocess(request)
  72. const channel = handle.control
  73. if (channel === undefined) throw new Error('requested control pipe is absent')
  74. const received = (async () => {
  75. const chunks: Buffer[] = []
  76. for await (const chunk of channel) chunks.push(Buffer.from(chunk as Uint8Array))
  77. return Buffer.concat(chunks)
  78. })()
  79. channel.write(input)
  80. expect(await received).toEqual(input)
  81. expect(await handle.done).toEqual({ exitCode: 0, signal: null })
  82. expect(handle.collected.stdout?.readFrom(0).text).toBe('ordinary stdout\n')
  83. expect(handle.collected.stderr?.readFrom(0).text).toBe('ordinary stderr\n')
  84. expect(await handle.waitForExit()).toBe(true)
  85. })
  86. it('rejects a caller-authored control marker before starting a child', async () => {
  87. ctx = new Context()
  88. await ctx.plugin(LocalSubprocessRuntime)
  89. expect(() => ctx?.subprocess.spawn({
  90. argv: [process.execPath, '-e', 'throw new Error("must not execute")'],
  91. cwd: process.cwd(),
  92. env: { [SUBPROCESS_CONTROL_ENV]: 'pipe' },
  93. stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
  94. graceMs: 1000,
  95. })).toThrow('reserved')
  96. })
  97. })