324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { mkdtempSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
|
import { BashTaskId } from '@deepseek-ai/dsh-bash'
|
|
import type { BashTaskRead } from '@deepseek-ai/dsh-bash'
|
|
|
|
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
|
|
|
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LocalBashExecutor, config)
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
return { ctx, bash }
|
|
}
|
|
|
|
/** Poll until a pid no longer exists. */
|
|
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
process.kill(pid, 0)
|
|
} catch {
|
|
return
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
|
}
|
|
|
|
async function readUntil(
|
|
bash: LocalBashExecutor,
|
|
id: BashTaskId,
|
|
expected: string,
|
|
timeoutMs = 5_000,
|
|
): Promise<BashTaskRead> {
|
|
const deadline = Date.now() + timeoutMs
|
|
let last: BashTaskRead | undefined
|
|
while (Date.now() < deadline) {
|
|
last = bash.readOutput(id)
|
|
if (last.delta.includes(expected)) return last
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
|
|
}
|
|
|
|
describe('LocalBashExecutor.run', () => {
|
|
it('resolves with output and the effective timeout', async () => {
|
|
const { bash } = await setup({ timeoutMs: 5_000 })
|
|
const result = await bash.run(bash.resolve({ command: 'echo hi' }))
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.stdout.text).toBe('hi\n')
|
|
expect(result.timeoutMs).toBe(5_000)
|
|
})
|
|
|
|
it('uses config cwd, overridable per call', async () => {
|
|
const { bash } = await setup({ cwd: '/tmp' })
|
|
const fromConfig = await bash.run(bash.resolve({ command: 'pwd' }))
|
|
expect(fromConfig.stdout.text.trim()).toMatch(/\/tmp$/)
|
|
const fromCall = await bash.run(bash.resolve({ command: 'pwd', workdir: '/' }))
|
|
expect(fromCall.stdout.text.trim()).toBe('/')
|
|
})
|
|
|
|
it('defaults cwd to process.cwd()', async () => {
|
|
const { bash } = await setup()
|
|
const result = await bash.run(bash.resolve({ command: 'pwd' }))
|
|
expect(result.stdout.text.trim()).toBe(process.cwd())
|
|
})
|
|
|
|
it('caps per-call timeouts at maxTimeoutMs', async () => {
|
|
const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
|
|
const result = await bash.run(bash.resolve({ command: 'true', timeoutMs: 99_999 }))
|
|
expect(result.timeoutMs).toBe(2_000)
|
|
})
|
|
|
|
it('rejects invalid numeric config and timeout overrides', async () => {
|
|
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
|
|
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
|
|
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
|
|
|
const { bash } = await setup()
|
|
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
|
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
|
|
})
|
|
|
|
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
|
|
const { bash } = await setup({ timeoutMs: 60_000 })
|
|
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
|
|
expect(result.timedOut).toBe(true)
|
|
expect(result.timeoutMs).toBe(100)
|
|
})
|
|
|
|
it('propagates abort signals', async () => {
|
|
const { bash } = await setup()
|
|
const controller = new AbortController()
|
|
const pending = bash.run(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
|
|
setTimeout(() => { controller.abort() }, 50)
|
|
const result = await pending
|
|
expect(result.aborted).toBe(true)
|
|
})
|
|
|
|
it('rejects on spawn failure (bad workdir)', async () => {
|
|
const { bash } = await setup()
|
|
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
|
|
})
|
|
})
|
|
|
|
describe('LocalBashExecutor background tasks', () => {
|
|
it('start returns immediately with a registered running task', async () => {
|
|
const { bash } = await setup()
|
|
const before = Date.now()
|
|
const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
|
|
expect(Date.now() - before).toBeLessThan(150)
|
|
expect(task.status).toBe('running')
|
|
expect(bash.get(task.id)).toBe(task)
|
|
expect(bash.list()).toContain(task)
|
|
await task.done
|
|
expect(task.status).toBe('completed')
|
|
expect(task.exitCode).toBe(0)
|
|
})
|
|
|
|
it('assigns sequential ids', async () => {
|
|
const { bash } = await setup()
|
|
const first = bash.start(bash.resolve({ command: 'true' }))
|
|
const second = bash.start(bash.resolve({ command: 'true' }))
|
|
expect(first.id).toBe('bash-1')
|
|
expect(second.id).toBe('bash-2')
|
|
await Promise.all([first.done, second.done])
|
|
})
|
|
|
|
it('readOutput returns increments without re-delivery', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
|
|
const first = await readUntil(bash, task.id, 'first\n')
|
|
expect(first.delta).toBe('first\n')
|
|
expect(first.lossy).toBe(false)
|
|
await task.done
|
|
const second = bash.readOutput(task.id)
|
|
expect(second.delta).toBe('second\n')
|
|
const third = bash.readOutput(task.id)
|
|
expect(third.delta).toBe('')
|
|
})
|
|
|
|
it('readOutput marks stderr sections', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
|
|
await task.done
|
|
const read = bash.readOutput(task.id)
|
|
expect(read.delta).toBe('out\n[stderr]\nerr\n')
|
|
})
|
|
|
|
it('readOutput reports stderr-only deltas without a leading newline', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'echo err >&2' }))
|
|
await task.done
|
|
expect(bash.readOutput(task.id).delta).toBe('[stderr]\nerr\n')
|
|
})
|
|
|
|
it('readOutput flags lossy reads and reports spill paths', async () => {
|
|
const { bash } = await setup({ maxOutputBytes: 100 })
|
|
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
|
|
await task.done
|
|
const read = bash.readOutput(task.id)
|
|
// Window slid past offset 0 → lossy, spill path points at the full stream.
|
|
expect(read.lossy).toBe(true)
|
|
expect(read.stdoutSpillPath).toBeDefined()
|
|
})
|
|
|
|
it('readOutput throws for unknown ids', async () => {
|
|
const { bash } = await setup()
|
|
expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
|
})
|
|
|
|
it('kill terminates the process group and reports status killed', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
expect(bash.kill(task.id)).toBe(true)
|
|
await task.done
|
|
expect(task.status).toBe('killed')
|
|
expect(task.signal).toBe('SIGTERM')
|
|
})
|
|
|
|
it('kill returns false for finished tasks and throws for unknown ids', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'true' }))
|
|
await task.done
|
|
expect(bash.kill(task.id)).toBe(false)
|
|
expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
|
|
})
|
|
|
|
it('notifies onTaskDone listeners on completion', async () => {
|
|
const { bash } = await setup()
|
|
const seen: [string, string][] = []
|
|
bash.onTaskDone(task => void seen.push([task.id, task.status]))
|
|
const task = bash.start(bash.resolve({ command: 'true' }))
|
|
await task.done
|
|
expect(seen).toEqual([[task.id, 'completed']])
|
|
})
|
|
|
|
it('notifies onTaskDone for killed tasks too', async () => {
|
|
const { bash } = await setup()
|
|
const listener = vi.fn()
|
|
bash.onTaskDone(listener)
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
bash.kill(task.id)
|
|
await task.done
|
|
expect(listener).toHaveBeenCalledWith(task)
|
|
expect(task.status).toBe('killed')
|
|
})
|
|
|
|
it('marks tasks killed when the background spawn itself fails', async () => {
|
|
const { bash } = await setup()
|
|
const listener = vi.fn()
|
|
bash.onTaskDone(listener)
|
|
const task = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
|
|
await task.done
|
|
expect(task.status).toBe('killed')
|
|
expect(listener).toHaveBeenCalledWith(task)
|
|
expect(bash.readOutput(task.id).delta).toContain('spawn failed')
|
|
})
|
|
|
|
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
|
|
const { bash } = await setup()
|
|
const task = bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
|
|
await task.done
|
|
expect(bash.readOutput(task.id).delta).toBe('out\n[stderr]\nerr\n')
|
|
})
|
|
|
|
it('readOutput reports stderr spill paths', async () => {
|
|
const { bash } = await setup({ maxOutputBytes: 100 })
|
|
const task = bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
|
|
await task.done
|
|
const read = bash.readOutput(task.id)
|
|
expect(read.lossy).toBe(true)
|
|
expect(read.stderrSpillPath).toBeDefined()
|
|
expect(read.delta).toContain('[stderr]')
|
|
})
|
|
|
|
it('disposing with already-finished tasks only kills the running ones', async () => {
|
|
const ctx = new Context()
|
|
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
|
|
const finished = bash.start(bash.resolve({ command: 'true' }))
|
|
await finished.done
|
|
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
|
|
await fiber.dispose()
|
|
await running.done
|
|
expect(finished.status).toBe('completed')
|
|
expect(running.signal).toBe('SIGTERM')
|
|
expect(bash.list()).toEqual([])
|
|
})
|
|
|
|
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
|
|
const ctx = new Context()
|
|
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
const listener = vi.fn()
|
|
bash.onTaskDone(listener)
|
|
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
|
const running = bash.get(task.id)!
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
// Grab the pid before dispose clears the registry.
|
|
const pid = (running as unknown as { running: { pid: number } }).running.pid
|
|
await fiber.dispose()
|
|
await waitGone(pid)
|
|
expect(bash.list()).toEqual([])
|
|
// Listener silenced by base-class teardown — no late notifications.
|
|
expect(listener).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('review fixes: lifecycle hardening', () => {
|
|
it('start honors a pre-aborted or later-aborted AbortSignal', async () => {
|
|
const { bash } = await setup()
|
|
const controller = new AbortController()
|
|
const task = bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
|
|
controller.abort()
|
|
await task.done
|
|
expect(task.status).toBe('killed')
|
|
expect(task.signal).toBe('SIGTERM')
|
|
})
|
|
|
|
it('a throwing onTaskDone listener does not reject task.done or starve later listeners', async () => {
|
|
const { bash } = await setup()
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
const second = vi.fn()
|
|
try {
|
|
bash.onTaskDone(() => { throw new Error('listener bug') })
|
|
bash.onTaskDone(second)
|
|
const task = bash.start(bash.resolve({ command: 'true' }))
|
|
await expect(task.done).resolves.toBeUndefined()
|
|
expect(second).toHaveBeenCalledWith(task)
|
|
expect(errorSpy).toHaveBeenCalled()
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
|
|
const ctx = new Context()
|
|
const fiber = await ctx.plugin(LocalBashExecutor, {})
|
|
const bash = ctx.bash as LocalBashExecutor
|
|
bash.internals = { spillDir, graceMs: 200 }
|
|
|
|
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
const pid = (task as unknown as { running: { pid: number } }).running.pid
|
|
|
|
await fiber.dispose()
|
|
// Disposal itself waited: the pid must already be gone, no grace left.
|
|
expect(() => process.kill(pid, 0)).toThrow()
|
|
expect(task.status).toBe('killed')
|
|
})
|
|
})
|