Files
deepseek-harness/packages/shell/bash-sandbox/tests/foreground-timeout.spec.ts
T
Yichen Jiang bb20149360 refactor(shell): register foreground commands as jobs at start and drop the promotion protocol
With a job registry composed, tool-bash and tool-pwsh now register every
command with ctx.jobs as it starts and wait on the job with the registry's
own bounded wait: a command that finishes in time returns the ordinary
foreground result and its record is removed, one that outlives the timeout
returns the same job id it already had, and a kill from outside the call is
reported as `[stopped: <reason>]`. Nothing changes hands at the deadline, so
the shell seam loses `onExpiry: 'offer'`, `ShellExecution.promotion`, and
`ShellPromotionOffer`, and both local executors drop their offer arm.

The registry stays optional: the tools register a foreground-only definition
while no registry is composed and swap to the job-backed one inside a
`ctx.inject(['jobs'])` fork, so a composition without jobs keeps a plain bash
with no job tools in the model's schema. A refused registration at the start
(admission, no controller) runs under the executor's deadline kill, and a wait
that expires before the process spawned ends as the preparation timeout.

JobRegistry gains `remove(id, caller?)` and the `settled` event reports
`awaited`, so dsh-tool-jobs no longer keeps its own wait ledger; it withholds
notices for awaited settlements and for kills the model requested. The tool
catalog harvest composes the registry so the shipped schema stays documented,
and the two compaction fixtures move their context window from 10000 to 10300
tokens because the branch's larger tool schemas pushed their condensed history
over the old trigger, which requested a fourth scripted model call.
2026-09-21 15:23:08 +08:00

161 lines
7.4 KiB
TypeScript

/** One foreground deadline covers sandbox preparation and native execution. */
import { Context } from '@deepseek-ai/cordis'
import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { SandboxBashExecutor } from '../src/index.ts'
async function setup() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const ctx = new Context()
const prepared = Promise.withResolvers<ConfinedArgv>()
const entered = Promise.withResolvers<AbortSignal | undefined>()
const spawned = Promise.withResolvers<SubprocessSpawnSpec>()
const completion = Promise.withResolvers<SubprocessOutcome>()
const runs: Promise<ShellRunResult>[] = []
const listeners: Array<() => void> = []
const wrap: ConfinedArgv = { argv: ['fixture-runner'], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
const confine = vi.fn(async (_argv: readonly string[], _policy: SandboxPolicy, signal?: AbortSignal) => {
entered.resolve(signal)
return prepared.promise
})
class ControlledSandbox extends SandboxProvider {
override confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
return confine(argv, policy, signal)
}
}
const terminate = vi.fn(() => { completion.resolve({ exitCode: null, signal: 'SIGTERM' }) })
const output = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }
const handle: SubprocessHandle = {
stdin: undefined, stdout: undefined, stderr: undefined, control: undefined,
collected: { stdout: output, stderr: output }, done: completion.promise,
terminate, waitForExit: async () => { await completion.promise; return true },
}
onTestFinished(async () => {
prepared.resolve(wrap)
completion.resolve({ exitCode: 0, signal: null })
await Promise.allSettled(runs)
for (const detach of listeners) detach()
try { await ctx.fiber.dispose() }
finally { vi.restoreAllMocks(); vi.useRealTimers() }
})
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(ControlledSandbox)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { timeoutMs: 100, graceMs: 100 })
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
spawned.resolve(spec)
spec.signal?.addEventListener('abort', terminate, { once: true })
listeners.push(() => { spec.signal?.removeEventListener('abort', terminate) })
if (spec.signal?.aborted) terminate()
return handle
})
const start = (timeoutMs = 10, signal?: AbortSignal) => {
const observed: { done: boolean; result?: ShellRunResult; error?: unknown } = { done: false }
const execution = ctx.shell.execute(ctx.shell.resolve({ command: 'fixture command', timeoutMs, signal }))
const promise = execution.then(process => process.result())
runs.push(promise)
void promise.then(
(result) => { observed.done = true; observed.result = result },
(error: unknown) => { observed.done = true; observed.error = error },
)
return { promise, observed, execution }
}
return { ctx, prepared, entered, spawned, completion, wrap, confine, spawn, terminate, start }
}
describe('bash preparation deadline', () => {
it.each([
{ late: 'success' }, { late: 'rejection' },
] as const)('times out preparation and prevents late $late from spawning', async ({ late }) => {
const test = await setup()
const run = test.start(10)
const signal = await test.entered.promise
await vi.advanceTimersByTimeAsync(10)
expect(run.observed.done).toBe(true)
expect(signal?.aborted).toBe(true)
expect(run.observed.result).toEqual({
exitCode: null, signal: null, timedOut: true, aborted: false, timeoutMs: 10,
stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false },
sandbox: { mode: 'read-only', denied: false },
})
expect(test.spawn).not.toHaveBeenCalled()
if (late === 'success') test.prepared.resolve(test.wrap)
else test.prepared.reject(new Error('late preparation rejection'))
await vi.advanceTimersByTimeAsync(0)
expect(test.spawn).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('passes the remaining deadline to native execution instead of restarting it', async () => {
const test = await setup()
const run = test.start(100)
const preparationSignal = await test.entered.promise
await vi.advanceTimersByTimeAsync(60)
test.prepared.resolve(test.wrap)
const spawn = await test.spawned.promise
expect(spawn.signal).toBe(preparationSignal)
await vi.advanceTimersByTimeAsync(39)
expect(run.observed.done).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(run.observed.result).toMatchObject({ timedOut: true, aborted: false, signal: 'SIGTERM', sandbox: { enforcement: 'full' } })
expect(test.terminate).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('preserves upstream cancellation when it wins during preparation', async () => {
const test = await setup()
const controller = new AbortController()
const reason = new Error('caller stopped preparation')
const run = test.start(10, controller.signal)
await test.entered.promise
controller.abort(reason)
await vi.advanceTimersByTimeAsync(20)
expect(run.observed.done).toBe(true)
expect(run.observed.error).toBe(reason)
expect(test.spawn).not.toHaveBeenCalled()
test.prepared.resolve(test.wrap)
await vi.advanceTimersByTimeAsync(0)
expect(test.spawn).not.toHaveBeenCalled()
})
it('keeps timeout as the first cause when upstream cancellation follows its notification', async () => {
const test = await setup()
const controller = new AbortController()
const run = test.start(10, controller.signal)
const signal = await test.entered.promise
signal?.addEventListener('abort', () => { controller.abort(new Error('later caller abort')) }, { once: true })
await vi.advanceTimersByTimeAsync(10)
expect(controller.signal.aborted).toBe(true)
expect(run.observed.result).toMatchObject({ timedOut: true, aborted: false })
expect(test.spawn).not.toHaveBeenCalled()
})
it('does not prepare after a pre-existing caller cancellation', async () => {
const test = await setup()
const reason = new Error('already cancelled')
const run = test.start(10, AbortSignal.abort(reason))
await vi.advanceTimersByTimeAsync(0)
expect(run.observed.error).toBe(reason)
expect(test.confine).not.toHaveBeenCalled()
expect(test.spawn).not.toHaveBeenCalled()
})
it('preserves preparation failures and disposes the deadline', async () => {
const test = await setup()
const run = test.start()
await test.entered.promise
const error = new Error('confinement unavailable')
test.prepared.reject(error)
await expect(run.promise).rejects.toBe(error)
expect(test.spawn).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
})