feat(code-runtime): complete sandboxed Node execution and Session fixtures
This commit is contained in:
1 parent
f6904f4615
commit
8e19ec4962
82 files changed
+2580
-348
No files matched your search
@@ -0,0 +1,354 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { CodeBindingFunction, CodeRunRequest } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { Config } from '../src/index.ts'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
import { mountRuntime } from './setup.ts'
|
||||
|
||||
const request: CodeRunRequest = { program: 'return 1', bindings: [] }
|
||||
const NO_INITIAL_FRAME = Symbol('no initial frame')
|
||||
|
||||
async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-access' = 'danger-full-access') {
|
||||
const ctx = new Context()
|
||||
const runtime = await mountRuntime(ctx, config, { mode, workspaceRoot: process.cwd() })
|
||||
const control = new Duplex({
|
||||
read() {},
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
if (childControl.destroyed) { callback(new Error('peer closed')); return }
|
||||
childControl.push(chunk)
|
||||
callback()
|
||||
},
|
||||
})
|
||||
const childControl = new Duplex({
|
||||
read() {},
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
if (control.destroyed) { callback(new Error('peer closed')); return }
|
||||
control.push(chunk)
|
||||
callback()
|
||||
},
|
||||
})
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const direct = Promise.withResolvers<SubprocessOutcome>()
|
||||
const messages: unknown[] = []
|
||||
const handle: SubprocessHandle = {
|
||||
stdin: undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
control,
|
||||
collected: {},
|
||||
done: direct.promise,
|
||||
terminate: vi.fn(() => {
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
direct.resolve({ exitCode: 0, signal: null })
|
||||
}),
|
||||
waitForExit: vi.fn(async () => true),
|
||||
}
|
||||
const writes = new Set<Promise<void>>()
|
||||
let receive: (message: unknown) => void = () => {}
|
||||
const peer = new JsonChannel(childControl, 1024 * 1024, (message) => {
|
||||
messages.push(message)
|
||||
receive(message)
|
||||
}, () => {})
|
||||
const emit = (message: unknown): void => {
|
||||
const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
|
||||
writes.add(write)
|
||||
}
|
||||
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
|
||||
onTestFinished(async () => {
|
||||
peer.close()
|
||||
control.destroy()
|
||||
stdout.destroy()
|
||||
stderr.destroy()
|
||||
direct.resolve({ exitCode: 0, signal: null })
|
||||
await Promise.all(writes)
|
||||
})
|
||||
const start = (input: CodeRunRequest = request, first: unknown = { type: 'ready' }) => {
|
||||
const result = runtime.run(runtime.resolve(input))
|
||||
if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
|
||||
return result
|
||||
}
|
||||
const onBoot = (callback: () => void): void => {
|
||||
receive = (message) => {
|
||||
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback()
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx, runtime, handle, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot,
|
||||
receive: (callback: typeof receive) => { receive = callback },
|
||||
}
|
||||
}
|
||||
|
||||
function call(id: number, value: string = '') {
|
||||
return { type: 'call', id, global: 'tools', name: 'test', args: encodeCodeJsonWire(value) }
|
||||
}
|
||||
|
||||
function withBinding(fn: CodeBindingFunction): CodeRunRequest {
|
||||
return { ...request, bindings: [{ global: 'tools', functions: { test: fn } }] }
|
||||
}
|
||||
|
||||
function confinement(argv: string[]): ConfinedArgv {
|
||||
return { argv, enforcement: 'partial', denialSignatures: ['EACCES'], runnerFailureRules: [{ fatalSignatures: ['sandbox-fatal:'] }] }
|
||||
}
|
||||
|
||||
describe('Node runtime host failures', () => {
|
||||
it.each<[Config, string]>([
|
||||
[{ timeoutMs: 0 }, 'timeoutMs'],
|
||||
[{ maxPendingCalls: -1 }, 'maxPendingCalls'],
|
||||
[{ maxOldGenerationSizeMb: Infinity }, 'maxOldGenerationSizeMb'],
|
||||
[{ timeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'timeoutMs'],
|
||||
[{ maxTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'maxTimeoutMs'],
|
||||
[{ graceMs: MAX_TIMER_DELAY_MS + 1 }, 'graceMs'],
|
||||
[{ maxOutputBytes: 3 }, 'maxOutputBytes'],
|
||||
[{ maxOutputBytes: 4.5 }, 'maxOutputBytes'],
|
||||
[{ maxMessageBytes: 1.5 }, 'maxMessageBytes'],
|
||||
[{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'],
|
||||
[{ maxPendingCalls: 1.5 }, 'maxPendingCalls'],
|
||||
[{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'],
|
||||
[{ nodeExecutable: '' }, 'nodeExecutable'],
|
||||
[{ bootstrapPath: 'relative-bootstrap.js' }, 'bootstrapPath'],
|
||||
])('rejects deployment configuration %j', async (config, field) => {
|
||||
await expect(mountRuntime(new Context(), config)).rejects.toThrow(field)
|
||||
})
|
||||
|
||||
it('reports the deployment mode and rejects unsupported resolved inputs before spawning', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
expect(h.runtime.sandboxMode).toBe('read-only')
|
||||
expect(() => h.runtime.resolve({ ...request, cwd: 'relative' })).toThrow('cwd must be absolute')
|
||||
await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: 1 })).rejects.toThrow('resolved sandbox policy')
|
||||
const spec = h.runtime.resolve(request)
|
||||
await expect(h.runtime.run({ ...spec, timeoutMs: Infinity })).rejects.toThrow('resolved cwd and timeout')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
await h.ctx.fiber.dispose()
|
||||
expect(() => h.runtime.resolve(request)).toThrow('resolve after disposal')
|
||||
await expect(h.runtime.run(spec)).rejects.toThrow('run after disposal')
|
||||
})
|
||||
|
||||
it('fails a required unavailable sandbox before spawning', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(() => { throw new SandboxUnavailableError('read-only') })
|
||||
expect((await h.start()).error?.kind).toBe('sandbox-unavailable')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps partial enforcement separate from a successful program', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
|
||||
expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
|
||||
})
|
||||
|
||||
it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
|
||||
const result = await h.start()
|
||||
expect(result.error).toEqual({ kind: 'exception', message })
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('distinguishes fatal sandbox startup output from a program denial', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
h.onBoot(() => {
|
||||
h.stderr.write('sandbox-fatal: runner could not initialize')
|
||||
h.direct.resolve({ exitCode: 1, signal: null })
|
||||
})
|
||||
const result = await h.start()
|
||||
expect(result.error?.kind).toBe('sandbox-unavailable')
|
||||
expect(result.sandbox?.denied).toBe(false)
|
||||
})
|
||||
|
||||
it('reports bootstrap assets that cannot map into the execution world', async () => {
|
||||
const h = await setup()
|
||||
vi.spyOn(h.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
expect((await h.start()).error?.message).toContain('bootstrap is unavailable')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up a provider that fails to supply its requested control pipe', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockReturnValue({ ...h.handle, control: undefined })
|
||||
expect((await h.start()).error?.message).toContain('did not supply the requested control')
|
||||
expect(h.handle.terminate).toHaveBeenCalledOnce()
|
||||
expect(h.handle.waitForExit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports an executable lookup failure without allocating a process', async () => {
|
||||
const h = await setup()
|
||||
h.resolveExecutable.mockRejectedValue(new Error('Node executable missing'))
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node executable missing' })
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors an already-aborted run without beginning executable lookup', async () => {
|
||||
const h = await setup()
|
||||
const result = await h.start({ ...request, signal: AbortSignal.abort('already stopped') }, NO_INITIAL_FRAME)
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'already stopped' })
|
||||
expect(h.resolveExecutable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not launch after cancellation races executable lookup completion', async () => {
|
||||
const h = await setup()
|
||||
const controller = new AbortController()
|
||||
h.resolveExecutable.mockImplementation(async () => {
|
||||
controller.abort('lookup canceled')
|
||||
return process.execPath
|
||||
})
|
||||
expect((await h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)).error?.kind).toBe('abort')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an early control EOF using the direct process result', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockImplementation(() => {
|
||||
queueMicrotask(() => {
|
||||
h.control.push(null)
|
||||
h.direct.resolve({ exitCode: 7, signal: null })
|
||||
})
|
||||
return h.handle
|
||||
})
|
||||
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
|
||||
kind: 'worker-exit', message: 'Node process exited before completing (7)',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports startup transport loss when the direct process outcome rejects', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockImplementation(() => {
|
||||
queueMicrotask(() => {
|
||||
h.control.emit('error', new Error('control transport closed'))
|
||||
h.direct.reject(new Error('runner connection broken'))
|
||||
})
|
||||
return h.handle
|
||||
})
|
||||
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({ kind: 'worker-exit', message: 'runner connection broken' })
|
||||
})
|
||||
|
||||
it('reports control loss after readiness as a substrate failure', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.emit('error', new Error('control transport closed')) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
|
||||
})
|
||||
|
||||
it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) })
|
||||
expect((await h.start()).error?.kind).toBe('protocol')
|
||||
})
|
||||
|
||||
it('rejects traffic before readiness without invoking host bindings', async () => {
|
||||
const h = await setup()
|
||||
const binding = vi.fn(async () => null)
|
||||
const result = await h.start(withBinding(binding), call(1))
|
||||
expect(result.error?.message).toContain('before bootstrap readiness')
|
||||
expect(binding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[unknown, string]>([
|
||||
[null, 'invalid control frame'],
|
||||
[{ type: 'log', text: 1 }, 'invalid log frame'],
|
||||
[{ type: 'done', error: null }, 'invalid terminal error'],
|
||||
[{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'],
|
||||
[{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'],
|
||||
[{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'],
|
||||
[{ ...call(1), name: 'constructor' }, 'undeclared binding'],
|
||||
[{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'],
|
||||
[{ type: 'unknown' }, 'unknown control message'],
|
||||
])('rejects malformed program frame %j', async (frame, message) => {
|
||||
const h = await setup()
|
||||
const binding = vi.fn(async () => null)
|
||||
h.onBoot(() => { h.emit(frame) })
|
||||
const result = await h.start(withBinding(binding))
|
||||
expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) })
|
||||
expect(binding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a repeated call id instead of dispatching it twice', async () => {
|
||||
const h = await setup()
|
||||
const binding = vi.fn(async () => null)
|
||||
h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) })
|
||||
expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol')
|
||||
expect(binding).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each<Config>([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => {
|
||||
const h = await setup(config)
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
const binding = vi.fn(async () => await release.promise)
|
||||
h.onBoot(() => { h.emit(call(1, 'a'.repeat(300))); h.emit(call(2, 'b'.repeat(300))) })
|
||||
expect((await h.start(withBinding(binding))).error?.message).toContain('pending binding calls exceed')
|
||||
expect(binding).toHaveBeenCalledOnce()
|
||||
release.resolve(null)
|
||||
await setImmediate()
|
||||
})
|
||||
|
||||
it('rejects an untransferable completion instead of returning a substituted value', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) })
|
||||
expect((await h.start()).error?.kind).toBe('invalid-output')
|
||||
})
|
||||
|
||||
it('returns non-lossless binding resolutions as program-visible binding failures', async () => {
|
||||
const h = await setup()
|
||||
h.receive((message) => {
|
||||
if (typeof message !== 'object' || message === null || !('type' in message)) return
|
||||
if (message.type === 'boot') h.emit(call(1))
|
||||
if (message.type === 'reply') h.emit({ type: 'done' })
|
||||
})
|
||||
expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined()
|
||||
expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') })
|
||||
})
|
||||
|
||||
it('does not publish a late binding reply after the program has settled', async () => {
|
||||
const h = await setup()
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
|
||||
expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined()
|
||||
release.resolve(null)
|
||||
await setImmediate()
|
||||
expect(h.messages).not.toContainEqual(expect.objectContaining({ type: 'reply' }))
|
||||
expect(h.control.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('reports failed managed cleanup even when the program returns successfully', async () => {
|
||||
const h = await setup()
|
||||
vi.mocked(h.handle.waitForExit).mockRejectedValue(new Error('range cannot be observed'))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' })
|
||||
})
|
||||
|
||||
it('fails when the configured frame budget cannot carry bootstrap data', async () => {
|
||||
const h = await setup({ maxMessageBytes: 32 })
|
||||
expect((await h.start()).error?.message).toContain('control output exceeds 32 queued bytes')
|
||||
})
|
||||
|
||||
it('turns an oversized binding reply into a protocol failure', async () => {
|
||||
const h = await setup({ maxMessageBytes: 256 })
|
||||
h.onBoot(() => { h.emit(call(1)) })
|
||||
const result = await h.start(withBinding(async () => 'x'.repeat(512)))
|
||||
expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' })
|
||||
})
|
||||
|
||||
it('retains admitted logs when the program reports its own output limit', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => {
|
||||
h.emit({ type: 'log', text: 'retained' })
|
||||
h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } })
|
||||
})
|
||||
const result = await h.start()
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(result.logs).toEqual(['retained'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { bootstrapArgs } from '../src/launch.ts'
|
||||
|
||||
it('uses an explicit installed bootstrap without pretending it maps the host file', () => {
|
||||
const fs = { processPathFromHostPath: () => { throw new Error('must not map') } } as unknown as FileSystem
|
||||
expect(bootstrapArgs(fs, { bootstrapPath: '/remote/process.js' }, 2048)).toEqual(['/remote/process.js', '2048'])
|
||||
})
|
||||
|
||||
it('fails when the execution world cannot read the source bootstrap', () => {
|
||||
const fs = { processPathFromHostPath: () => undefined } as unknown as FileSystem
|
||||
expect(() => bootstrapArgs(fs, {}, 2048)).toThrow('unavailable in the subprocess execution world')
|
||||
})
|
||||
|
||||
it('selects the private packaged bootstrap through the existing executable', () => {
|
||||
const prior = Object.getOwnPropertyDescriptor(process, 'pkg')
|
||||
try {
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
const fs = { processPathFromHostPath: () => { throw new Error('pkg does not expose a host bootstrap file') } } as unknown as FileSystem
|
||||
expect(bootstrapArgs(fs, {}, 2048)).toEqual(['2048'])
|
||||
} finally {
|
||||
if (prior === undefined) Reflect.deleteProperty(process, 'pkg')
|
||||
else Object.defineProperty(process, 'pkg', prior)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { OutputLedger } from '../src/output-ledger.ts'
|
||||
|
||||
it('accounts for exact JSON escaping, separators and optional completion values', () => {
|
||||
const ledger = new OutputLedger(20)
|
||||
const logs: string[] = []
|
||||
expect(ledger.admit('first', logs)).toBe(true)
|
||||
expect(ledger.admit('second', logs)).toBe(true)
|
||||
expect(ledger.admit('third', logs)).toBe(false)
|
||||
expect(ledger.success(logs)).toEqual({ logs: ['first', 'second'] })
|
||||
expect(ledger.success(logs, 1)).toEqual({ logs: ['first', 'second'], value: 1 })
|
||||
expect(ledger.success(logs, 'too long').error?.kind).toBe('output-limit')
|
||||
})
|
||||
|
||||
it('retains a bounded prefix when logs or a failure diagnostic exceed the limit', () => {
|
||||
for (const maxBytes of [4, 8, 40, 80]) {
|
||||
const ledger = new OutputLedger(maxBytes)
|
||||
const result = ledger.limit(['a', 'b', '你好🙂'.repeat(50)])
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
const bytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
|
||||
expect(bytes).toBeLessThanOrEqual(maxBytes)
|
||||
}
|
||||
const ledger = new OutputLedger(80)
|
||||
expect(ledger.failure([], { kind: 'exception', message: 'short' })).toEqual({ logs: [], error: { kind: 'exception', message: 'short' } })
|
||||
expect(ledger.failure([], { kind: 'exception', message: 'x'.repeat(100) }).error?.kind).toBe('output-limit')
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { drainOutput } from '../src/output-stream.ts'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('waits for queued output and accepts an already ended or absent stream', async () => {
|
||||
const stream = new PassThrough()
|
||||
const chunks: string[] = []
|
||||
stream.on('data', chunk => chunks.push(String(chunk)))
|
||||
const pending = drainOutput(stream, 1000)
|
||||
stream.end('last output')
|
||||
expect(await pending).toBe(true)
|
||||
expect(chunks).toEqual(['last output'])
|
||||
expect(await drainOutput(stream, 1000)).toBe(true)
|
||||
expect(await drainOutput(undefined, 1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds an output descriptor retained after the process exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stream = new PassThrough()
|
||||
const pending = drainOutput(stream, 100)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(await pending).toBe(false)
|
||||
stream.destroy()
|
||||
expect(await drainOutput(stream, 100)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['close', 'error'])('reports incomplete output on %s', async (event) => {
|
||||
const stream = new PassThrough()
|
||||
const pending = drainOutput(stream, 1000)
|
||||
stream.emit(event, new Error('pipe failed'))
|
||||
expect(await pending).toBe(false)
|
||||
stream.destroy()
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Duplex } from 'node:stream'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
@@ -25,7 +25,9 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep
|
||||
const completed = Promise.withResolvers<unknown>()
|
||||
child.once('error', error => completed.reject(error))
|
||||
child.once('exit', (code) => { if (code !== 0) completed.reject(new Error(`child exit ${code}: ${stderr}`)) })
|
||||
const channel = new JsonChannel(child.stdio[7] as Duplex, 100_000, (raw) => {
|
||||
const control = Array.from(child.stdio)[7]
|
||||
if (!(control instanceof Duplex)) { child.kill(); await finished; throw new Error('missing child control channel') }
|
||||
const channel = new JsonChannel(control, 100_000, (raw) => {
|
||||
const message = raw as { type: string; id?: number; args?: unknown; value?: unknown; error?: unknown }
|
||||
if (message.type === 'ready') {
|
||||
void channel.send({ type: 'boot', data: {
|
||||
|
||||
@@ -100,12 +100,14 @@ describe('Node program process', () => {
|
||||
|
||||
it('disposes active programs and rejects later execution', async () => {
|
||||
const { ctx, run, runtime } = await setup()
|
||||
const spec = runtime.resolve({ program: '', bindings: [] })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const active = run({ program: 'await tools.enter({}); await new Promise(() => {})', bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) })
|
||||
await entered.promise
|
||||
await ctx.fiber.dispose()
|
||||
expect((await active).error?.kind).toBe('abort')
|
||||
await expect(run({ program: '', bindings: [] })).rejects.toThrow('disposal')
|
||||
await expect(runtime.run(spec)).rejects.toThrow('disposal')
|
||||
expect(() => runtime.resolve({ program: '', bindings: [] })).toThrow('disposal')
|
||||
expect(runtime.isolation).toBe('process')
|
||||
})
|
||||
|
||||
@@ -140,3 +142,14 @@ describe('Node program process', () => {
|
||||
await expect(readFile(join(outside, 'denied.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves empty console entries and bounds native output overflow', async () => {
|
||||
const { run } = await setup({ maxOutputBytes: 100 })
|
||||
const lines = await run({ program: 'console.log("a"); console.log(""); console.log("b")', bindings: [] })
|
||||
expect(lines.logs).toEqual(['a', '', 'b'])
|
||||
const overflow = await run({ program: 'const fs=await import("node:fs"); fs.writeSync(1,"HEAD-"+"x".repeat(100000));', bindings: [] })
|
||||
expect(overflow.error?.kind).toBe('output-limit')
|
||||
expect(overflow.logs.join('')).toContain('HEAD-')
|
||||
const bytes = Buffer.byteLength(JSON.stringify(overflow.logs)) + Buffer.byteLength(JSON.stringify(overflow.error?.message))
|
||||
expect(bytes).toBeLessThanOrEqual(100)
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { onTestFinished } from 'vitest'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
@@ -9,6 +11,8 @@ import NodeRuntime from '../src/index.ts'
|
||||
import type { Config } from '../src/index.ts'
|
||||
|
||||
export async function mountRuntime(ctx: Context, config: Config = {}, policy: { mode?: SandboxMode; workspaceRoot?: string } = {}) {
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
if (!ctx.get('sessions')) await ctx.plugin(SessionStore)
|
||||
if (!ctx.get('fs')) await ctx.plugin(FileSystem)
|
||||
if (!ctx.get('subprocess')) await ctx.plugin(Subprocess)
|
||||
if (!ctx.get('sandbox')) await ctx.plugin(Sandbox, {})
|
||||
|
||||
Reference in new issue
Block a user