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.
161 lines
7.4 KiB
TypeScript
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)
|
|
})
|
|
})
|