child-process.spec.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. /**
  2. * The `node:child_process` face over the in-worker shell, and the ladder above
  3. * it: the REAL local subprocess service, running unmodified against this
  4. * module instead of a host kernel. That ladder is what the bash tool walks in
  5. * the browser, so proving it here is what makes the browser probe a
  6. * confirmation rather than the only evidence.
  7. *
  8. * A Node test host has no DOM `Worker`, so the commands here run through the
  9. * inline strategy; the worker strategy and its frames are proven in
  10. * `../shell/shell-process.spec.ts`, and both meet again in the preview probe.
  11. *
  12. * `process.kill` is redirected to the worker's process table for the same
  13. * reason the worker does it: the subprocess service polls process-group
  14. * liveness through it, and on a test host those pids belong to real processes.
  15. */
  16. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  17. import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
  18. import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
  19. import { spawn, spawnSync } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts'
  20. import { processAlive, signalProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/process-table.ts'
  21. import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
  22. vi.mock('node:child_process', async () =>
  23. await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts'))
  24. const WORKSPACE = '/dsh/workspace'
  25. let vfs: MemoryVfs
  26. beforeEach(() => {
  27. vfs = new MemoryVfs()
  28. setActiveVfs(vfs)
  29. vfs.mkdirSync(WORKSPACE, { recursive: true })
  30. vi.spyOn(process, 'kill').mockImplementation((pid: number, signal?: string | number): true => {
  31. if (signal === 0) {
  32. if (processAlive(pid)) return true
  33. const error = new Error('kill ESRCH') as NodeJS.ErrnoException
  34. error.code = 'ESRCH'
  35. throw error
  36. }
  37. signalProcess(pid, (signal ?? 'SIGTERM') as NodeJS.Signals)
  38. return true
  39. })
  40. })
  41. afterEach(() => {
  42. vi.restoreAllMocks()
  43. })
  44. /** Collect one child's stdout, stderr, and settlement. */
  45. async function collect(child: ReturnType<typeof spawn>): Promise<{ stdout: string; stderr: string; code: number | null }> {
  46. let stdout = ''
  47. let stderr = ''
  48. child.stdout?.on('data', (chunk: unknown) => { stdout += String(chunk) })
  49. child.stderr?.on('data', (chunk: unknown) => { stderr += String(chunk) })
  50. const code = await new Promise<number | null>((settle, fail) => {
  51. child.on('close', (value: unknown) => { settle(value as number | null) })
  52. child.on('error', fail)
  53. })
  54. return { stdout, stderr, code }
  55. }
  56. it('runs a bash command line and reports its output through the pipes', async () => {
  57. const child = spawn('bash', ['-c', 'echo hi; echo oops >&2'], { cwd: WORKSPACE })
  58. expect(child.pid).toBeGreaterThan(1)
  59. expect(await collect(child)).toEqual({ stdout: 'hi\n', stderr: 'oops\n', code: 0 })
  60. })
  61. it('runs an explicit argv without re-parsing it as a command line', async () => {
  62. vfs.writeFileSync(`${WORKSPACE}/spaced name.txt`, 'kept\n')
  63. const child = spawn('cat', ['spaced name.txt'], { cwd: WORKSPACE })
  64. expect((await collect(child)).stdout).toBe('kept\n')
  65. })
  66. it('fails a program the command table does not hold the way a missing binary does', async () => {
  67. const child = spawn('nowhere-binary', [], { cwd: WORKSPACE })
  68. // A caller that configures the pipes first (the browser launcher does) must
  69. // reach the ENOENT, not a TypeError on the configuration line.
  70. child.stdout?.setEncoding()
  71. child.stderr?.setEncoding()
  72. const error = await new Promise<NodeJS.ErrnoException>((settle) => {
  73. child.on('error', (value: unknown) => { settle(value as NodeJS.ErrnoException) })
  74. })
  75. expect(error.code).toBe('ENOENT')
  76. expect(error.syscall).toBe('spawn nowhere-binary')
  77. })
  78. it('refuses a command name that is not a string, as Node does', () => {
  79. expect(() => spawn(undefined as unknown as string)).toThrow(/must be a non-empty string/)
  80. })
  81. it('reports that a synchronous run cannot happen, without throwing at the probe', () => {
  82. expect(spawnSync('bwrap').error?.code).toBe('ENOENT')
  83. expect(spawnSync('echo').error?.message).toContain('commands run asynchronously')
  84. })
  85. it('carries a command through the real local subprocess service', async () => {
  86. const handle = spawnSubprocess({
  87. argv: ['bash', '-c', 'echo written > note.txt && cat note.txt'],
  88. cwd: WORKSPACE,
  89. stdio: {
  90. stdin: 'ignore',
  91. stdout: { maxBytes: 64_000 },
  92. stderr: { maxBytes: 64_000 },
  93. },
  94. graceMs: 3_000,
  95. env: {},
  96. })
  97. const outcome = await handle.done
  98. expect(outcome).toEqual({ exitCode: 0, signal: null })
  99. expect(handle.collected.stdout?.readFrom(0).text).toBe('written\n')
  100. expect(vfs.readFileSync(`${WORKSPACE}/note.txt`, 'utf8')).toBe('written\n')
  101. })
  102. it('writes the caller-supplied standard input into the command', async () => {
  103. const handle = spawnSubprocess({
  104. argv: ['bash', '-c', 'grep -c ""'],
  105. cwd: WORKSPACE,
  106. stdio: {
  107. stdin: { data: 'one\ntwo\nthree\n' },
  108. stdout: { maxBytes: 64_000 },
  109. stderr: { maxBytes: 64_000 },
  110. },
  111. graceMs: 3_000,
  112. env: {},
  113. })
  114. await handle.done
  115. expect(handle.collected.stdout?.readFrom(0).text).toBe('3\n')
  116. })
  117. it('kills a running command through the service and reports the signal', async () => {
  118. const handle = spawnSubprocess({
  119. argv: ['bash', '-c', 'sleep 30; echo never'],
  120. cwd: WORKSPACE,
  121. stdio: {
  122. stdin: 'ignore',
  123. stdout: { maxBytes: 64_000 },
  124. stderr: { maxBytes: 64_000 },
  125. },
  126. graceMs: 3_000,
  127. env: {},
  128. })
  129. const started = performance.now()
  130. handle.terminate()
  131. const outcome = await handle.done
  132. expect(outcome.signal).toBe('SIGTERM')
  133. expect(outcome.exitCode).toBeNull()
  134. expect(handle.collected.stdout?.readFrom(0).text).toBe('')
  135. // The command settles on the signal, not on the interval it was waiting out:
  136. // a `sleep` that ignored the abort would hold this handle open for 30s.
  137. expect(performance.now() - started).toBeLessThan(5_000)
  138. })