foreground-timeout.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. /** One foreground deadline covers sandbox preparation and native execution. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
  4. import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  5. import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
  6. import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
  7. import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
  8. import type { SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  9. import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
  10. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  11. import { SandboxBashExecutor } from '../src/index.ts'
  12. async function setup() {
  13. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
  14. const ctx = new Context()
  15. const prepared = Promise.withResolvers<ConfinedArgv>()
  16. const entered = Promise.withResolvers<AbortSignal | undefined>()
  17. const spawned = Promise.withResolvers<SubprocessSpawnSpec>()
  18. const completion = Promise.withResolvers<SubprocessOutcome>()
  19. const runs: Promise<ShellRunResult>[] = []
  20. const listeners: Array<() => void> = []
  21. const wrap: ConfinedArgv = { argv: ['fixture-runner'], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
  22. const confine = vi.fn(async (_argv: readonly string[], _policy: SandboxPolicy, signal?: AbortSignal) => {
  23. entered.resolve(signal)
  24. return prepared.promise
  25. })
  26. class ControlledSandbox extends SandboxProvider {
  27. override confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
  28. return confine(argv, policy, signal)
  29. }
  30. }
  31. const terminate = vi.fn(() => { completion.resolve({ exitCode: null, signal: 'SIGTERM' }) })
  32. const output = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }
  33. const handle: SubprocessHandle = {
  34. stdin: undefined, stdout: undefined, stderr: undefined, control: undefined,
  35. collected: { stdout: output, stderr: output }, done: completion.promise,
  36. terminate, waitForExit: async () => { await completion.promise; return true },
  37. }
  38. onTestFinished(async () => {
  39. prepared.resolve(wrap)
  40. completion.resolve({ exitCode: 0, signal: null })
  41. await Promise.allSettled(runs)
  42. for (const detach of listeners) detach()
  43. try { await ctx.fiber.dispose() }
  44. finally { vi.restoreAllMocks(); vi.useRealTimers() }
  45. })
  46. await ctx.plugin(SessionProjectionRegistry)
  47. await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
  48. await ctx.plugin(ControlledSandbox)
  49. await ctx.plugin(LocalSubprocessRuntime)
  50. await ctx.plugin(SandboxBashExecutor, { timeoutMs: 100, graceMs: 100 })
  51. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
  52. spawned.resolve(spec)
  53. spec.signal?.addEventListener('abort', terminate, { once: true })
  54. listeners.push(() => { spec.signal?.removeEventListener('abort', terminate) })
  55. if (spec.signal?.aborted) terminate()
  56. return handle
  57. })
  58. const start = (timeoutMs = 10, signal?: AbortSignal) => {
  59. const observed: { done: boolean; result?: ShellRunResult; error?: unknown } = { done: false }
  60. const promise = ctx.shell.run(ctx.shell.resolve({ command: 'fixture command', timeoutMs, signal }))
  61. runs.push(promise)
  62. void promise.then(
  63. (result) => { observed.done = true; observed.result = result },
  64. (error: unknown) => { observed.done = true; observed.error = error },
  65. )
  66. return { promise, observed }
  67. }
  68. return { ctx, prepared, entered, spawned, completion, wrap, confine, spawn, terminate, start }
  69. }
  70. describe('bash preparation deadline', () => {
  71. it.each(['success', 'rejection'] as const)('times out unresolved preparation and prevents late %s from spawning', async (late) => {
  72. const test = await setup()
  73. const run = test.start()
  74. const signal = await test.entered.promise
  75. await vi.advanceTimersByTimeAsync(10)
  76. expect(run.observed.done).toBe(true)
  77. expect(signal?.aborted).toBe(true)
  78. expect(run.observed.result).toEqual({
  79. exitCode: null, signal: null, timedOut: true, aborted: false, timeoutMs: 10,
  80. stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false },
  81. sandbox: { mode: 'read-only', denied: false },
  82. })
  83. expect(test.spawn).not.toHaveBeenCalled()
  84. if (late === 'success') test.prepared.resolve(test.wrap)
  85. else test.prepared.reject(new Error('late preparation rejection'))
  86. await vi.advanceTimersByTimeAsync(0)
  87. expect(test.spawn).not.toHaveBeenCalled()
  88. expect(vi.getTimerCount()).toBe(0)
  89. })
  90. it('passes the remaining deadline to native execution instead of restarting it', async () => {
  91. const test = await setup()
  92. const run = test.start(100)
  93. const preparationSignal = await test.entered.promise
  94. await vi.advanceTimersByTimeAsync(60)
  95. test.prepared.resolve(test.wrap)
  96. const spawn = await test.spawned.promise
  97. expect(spawn.signal).toBe(preparationSignal)
  98. await vi.advanceTimersByTimeAsync(39)
  99. expect(run.observed.done).toBe(false)
  100. await vi.advanceTimersByTimeAsync(1)
  101. expect(run.observed.result).toMatchObject({ timedOut: true, aborted: false, signal: 'SIGTERM', sandbox: { enforcement: 'full' } })
  102. expect(test.terminate).toHaveBeenCalledOnce()
  103. expect(vi.getTimerCount()).toBe(0)
  104. })
  105. it('preserves upstream cancellation when it wins during preparation', async () => {
  106. const test = await setup()
  107. const controller = new AbortController()
  108. const reason = new Error('caller stopped preparation')
  109. const run = test.start(10, controller.signal)
  110. await test.entered.promise
  111. controller.abort(reason)
  112. await vi.advanceTimersByTimeAsync(20)
  113. expect(run.observed.done).toBe(true)
  114. expect(run.observed.error).toBe(reason)
  115. expect(test.spawn).not.toHaveBeenCalled()
  116. test.prepared.resolve(test.wrap)
  117. await vi.advanceTimersByTimeAsync(0)
  118. expect(test.spawn).not.toHaveBeenCalled()
  119. })
  120. it('keeps timeout as the first cause when upstream cancellation follows its notification', async () => {
  121. const test = await setup()
  122. const controller = new AbortController()
  123. const run = test.start(10, controller.signal)
  124. const signal = await test.entered.promise
  125. signal?.addEventListener('abort', () => { controller.abort(new Error('later caller abort')) }, { once: true })
  126. await vi.advanceTimersByTimeAsync(10)
  127. expect(controller.signal.aborted).toBe(true)
  128. expect(run.observed.result).toMatchObject({ timedOut: true, aborted: false })
  129. expect(test.spawn).not.toHaveBeenCalled()
  130. })
  131. it('does not prepare after a pre-existing caller cancellation', async () => {
  132. const test = await setup()
  133. const reason = new Error('already cancelled')
  134. const run = test.start(10, AbortSignal.abort(reason))
  135. await vi.advanceTimersByTimeAsync(0)
  136. expect(run.observed.error).toBe(reason)
  137. expect(test.confine).not.toHaveBeenCalled()
  138. expect(test.spawn).not.toHaveBeenCalled()
  139. })
  140. it('preserves preparation failures and disposes the deadline', async () => {
  141. const test = await setup()
  142. const run = test.start()
  143. await test.entered.promise
  144. const error = new Error('confinement unavailable')
  145. test.prepared.reject(error)
  146. await expect(run.promise).rejects.toBe(error)
  147. expect(test.spawn).not.toHaveBeenCalled()
  148. expect(vi.getTimerCount()).toBe(0)
  149. })
  150. })