feat(code-runtime): execute Node programs through confined processes
This commit is contained in:
1 parent
f95f7ec8dc
commit
75ed8da3e0
54 files changed
+1933
-2277
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateBindings } from '../src/bindings.ts'
|
||||
|
||||
describe('program binding validation', () => {
|
||||
it.each(['$name', '', 'while', 'lambda', 'console'])('rejects unusable global %s', (global) => {
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global, functions: {} }] })).toThrow()
|
||||
})
|
||||
it('rejects duplicated globals and accepts arbitrary own member names', () => {
|
||||
const functions = Object.create(null) as Record<string, () => Promise<string>>
|
||||
functions.__proto__ = async () => 'own'
|
||||
expect(validateBindings({ program: '', bindings: [{ global: 'tools', functions }] }).get('tools')?.functions).toBe(functions)
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global: 'tools', functions }, { global: 'tools', functions }] })).toThrow('duplicate')
|
||||
})
|
||||
it.each(['$name', 'while', 'console', 'tools'])('rejects invalid error class name %s', (name) => {
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global: 'tools', functions: {}, errorClass: { name, memberNameProperty: 'member' } }] })).toThrow()
|
||||
})
|
||||
it.each(['', 'name', '__member__'])('rejects invalid error member %s', (memberNameProperty) => {
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global: 'tools', functions: {}, errorClass: { name: 'ToolError', memberNameProperty } }] })).toThrow()
|
||||
})
|
||||
it('rejects two injected constructors with the same name', () => {
|
||||
expect(() => validateBindings({ program: '', bindings: ['first', 'second'].map(global => ({ global, functions: {}, errorClass: { name: 'ToolError', memberNameProperty: 'member' } })) })).toThrow('duplicate')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram, wireReplies } from '../src/bootstrap.ts'
|
||||
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
|
||||
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
import type { ReplyMessage, ProgramToHost } from '../src/protocol.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
|
||||
/**
|
||||
* An in-process stand-in for the worker's parentPort: the test plays the
|
||||
@@ -12,12 +12,12 @@ import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
* isolate (real-worker behavior is pinned by runtime.spec.ts).
|
||||
*/
|
||||
class FakePort implements BootstrapPort {
|
||||
sent: WorkerToHost[] = []
|
||||
sent: ProgramToHost[] = []
|
||||
private readonly emitter = new EventEmitter()
|
||||
/** Host-scripted responder; return undefined to leave the call pending. */
|
||||
respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
|
||||
respond: (message: ProgramToHost) => ReplyMessage | undefined = () => undefined
|
||||
|
||||
postMessage(message: WorkerToHost): void {
|
||||
postMessage(message: ProgramToHost): void {
|
||||
this.sent.push(message)
|
||||
const reply = this.respond(message)
|
||||
if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
|
||||
@@ -35,13 +35,13 @@ class FakePort implements BootstrapPort {
|
||||
return this.sent.filter(message => message.type === 'log').map(message => message.text)
|
||||
}
|
||||
|
||||
done(): WorkerToHost | undefined {
|
||||
done(): ProgramToHost | undefined {
|
||||
return this.sent.find(message => message.type === 'done')
|
||||
}
|
||||
|
||||
doneValue(): unknown {
|
||||
const done = this.done()
|
||||
return done?.type === 'done' && done.value !== undefined ? decodeWorkerJson(done.value) : undefined
|
||||
return done?.type === 'done' && done.value !== undefined ? decodeCodeJsonWire(done.value) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('captureStreamWrites', () => {
|
||||
describe('prepareCompletion', () => {
|
||||
it('omits undefined and passes lossless JSON values exactly', () => {
|
||||
expect(prepareCompletion(undefined, 100)).toEqual({})
|
||||
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeWorkerJson({ a: [1, 'two'] }) })
|
||||
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeCodeJsonWire({ a: [1, 'two'] }) })
|
||||
})
|
||||
|
||||
it('turns every lossy completion shape into invalid-output', () => {
|
||||
@@ -162,7 +162,7 @@ describe('prepareCompletion', () => {
|
||||
})
|
||||
|
||||
it('measures the exact JSON serialization at and over the boundary', () => {
|
||||
expect(prepareCompletion('€', 5)).toEqual({ value: encodeWorkerJson('€') })
|
||||
expect(prepareCompletion('€', 5)).toEqual({ value: encodeCodeJsonWire('€') })
|
||||
expect(prepareCompletion('€', 4)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
@@ -216,7 +216,7 @@ describe('makeNamespaces', () => {
|
||||
it('exposes prototype-colliding names as ordinary own properties', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(`${message.name}-ok`) }
|
||||
? { type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(`${message.name}-ok`) }
|
||||
: undefined
|
||||
const pending = new Map<number, PendingCall>()
|
||||
wireReplies(port, pending)
|
||||
@@ -311,15 +311,15 @@ describe('makeNamespaces', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWorkerMain', () => {
|
||||
describe('runProgram', () => {
|
||||
it('runs a program end-to-end: bindings, console, return value', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = (message) => {
|
||||
if (message.type !== 'call') return undefined
|
||||
const args = decodeWorkerJson(message.args) as { n: number }
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(args.n * 2) }
|
||||
const args = decodeCodeJsonWire(message.args) as { n: number }
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(args.n * 2) }
|
||||
}
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
|
||||
namespaces: [{ global: 'tools', names: ['double'] }],
|
||||
@@ -330,7 +330,7 @@ describe('runWorkerMain', () => {
|
||||
|
||||
it('reports worker-side log capture overflow before completing', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
maxOutputBytes: 4,
|
||||
code: 'console.log("12345"); return null',
|
||||
namespaces: [],
|
||||
@@ -345,7 +345,7 @@ describe('runWorkerMain', () => {
|
||||
|
||||
it('reports a thrown program error on the done message', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
|
||||
await runProgram(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
|
||||
const done = port.done()
|
||||
expect(done?.type).toBe('done')
|
||||
expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
|
||||
@@ -355,17 +355,17 @@ describe('runWorkerMain', () => {
|
||||
|
||||
it('renders non-Error throws and stack-less Errors on the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
|
||||
await runProgram(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
|
||||
|
||||
const barePort = new FakePort()
|
||||
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
|
||||
await runProgram(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
|
||||
})
|
||||
|
||||
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, {
|
||||
await runProgram(rawPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw "x".repeat(1_000_000)',
|
||||
namespaces: [],
|
||||
@@ -376,7 +376,7 @@ describe('runWorkerMain', () => {
|
||||
})
|
||||
|
||||
const stackPort = new FakePort()
|
||||
await runWorkerMain(stackPort, {
|
||||
await runProgram(stackPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw new Error("x".repeat(1_000_000))',
|
||||
namespaces: [],
|
||||
@@ -390,7 +390,7 @@ describe('runWorkerMain', () => {
|
||||
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
namespaces: [toolNamespace(['x'])],
|
||||
@@ -403,7 +403,7 @@ describe('runWorkerMain', () => {
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
|
||||
namespaces: [{
|
||||
@@ -420,10 +420,10 @@ describe('runWorkerMain', () => {
|
||||
port.respond = (message) => {
|
||||
if (message.type !== 'call') return undefined
|
||||
// Deliver a stray reply first; the real one follows.
|
||||
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeWorkerJson('stray') })
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson('real') }
|
||||
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeCodeJsonWire('stray') })
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire('real') }
|
||||
}
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'return await tools.x({})',
|
||||
namespaces: [{ global: 'tools', names: ['x'] }],
|
||||
@@ -434,7 +434,7 @@ describe('runWorkerMain', () => {
|
||||
it('captures raw stream writes through the patched process streams', async () => {
|
||||
const port = new FakePort()
|
||||
const streams = fakeStreams()
|
||||
await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
|
||||
await runProgram(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
|
||||
streams.stdout.write('never seen — already restored? no: patch persists in worker')
|
||||
// The patch stays installed for the worker's lifetime; writes during the
|
||||
// program landed in order. Here the program wrote nothing via streams, so
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/** Host budget decisions use controlled clocks and ELU samples; worker execution and binding transport stay real. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { NodeCodeRuntime } from '@deepseek-ai/dsh-code-runtime-node'
|
||||
import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
const meter = vi.hoisted(() => ({ sample: vi.fn() }))
|
||||
|
||||
vi.mock('node:worker_threads', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:worker_threads')>()
|
||||
return {
|
||||
...original,
|
||||
Worker: class extends original.Worker {
|
||||
constructor(...args: ConstructorParameters<typeof original.Worker>) {
|
||||
super(...args)
|
||||
this.performance.eventLoopUtilization = meter.sample
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('worker budgets with controlled ELU samples and real binding transport', () => {
|
||||
let ctx: Context
|
||||
let controller: AbortController
|
||||
let run: Promise<CodeRunResult> | undefined
|
||||
let release: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = new Context()
|
||||
controller = new AbortController()
|
||||
run = undefined
|
||||
release = undefined
|
||||
meter.sample.mockReset().mockReturnValue({ active: 10, idle: 0, utilization: 1 })
|
||||
// Worker bootstrap and scheduling contribute to ELU active time; only the
|
||||
// measured input and host deadlines are controlled, not worker execution.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const owned = { ctx, controller, run, release }
|
||||
try {
|
||||
owned.controller.abort('test cleanup')
|
||||
owned.release?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
try {
|
||||
await owned.run
|
||||
} finally {
|
||||
await owned.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function pendingBinding(): Promise<void> {
|
||||
await ctx.plugin(NodeCodeRuntime, { computeMs: 1_000, maxWallMs: 30_000 })
|
||||
let entered!: () => void
|
||||
const ready = new Promise<void>((resolve) => { entered = resolve })
|
||||
const binding = new Promise<string>((resolve) => { release = () => { resolve('slow-done') } })
|
||||
run = ctx.codeRuntime.run({
|
||||
program: 'return await tools.slow({})',
|
||||
bindings: [{ global: 'tools', functions: { slow: () => { entered(); return binding } } }],
|
||||
signal: controller.signal,
|
||||
})
|
||||
await Promise.race([
|
||||
ready,
|
||||
run.then((result) => { throw new Error('Worker settled before binding entry: ' + JSON.stringify(result)) }),
|
||||
])
|
||||
}
|
||||
|
||||
it('does not charge a binding wait longer than the compute budget', async () => {
|
||||
await pendingBinding()
|
||||
const settled = vi.fn()
|
||||
void run!.then(settled, settled)
|
||||
meter.sample.mockReturnValue({ active: 10, idle: 1_500, utilization: 10 / 1_510 })
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(meter.sample).toHaveBeenCalled()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
release!()
|
||||
expect(await run).toEqual({ logs: [], value: 'slow-done' })
|
||||
})
|
||||
|
||||
it('expires active time even while a binding is pending', async () => {
|
||||
await pendingBinding()
|
||||
meter.sample.mockReturnValue({ active: 1_001, idle: 1_500, utilization: 1_001 / 2_501 })
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'compute budget exhausted (1000ms busy)' } })
|
||||
})
|
||||
|
||||
it('expires the wall ceiling while active time remains below the compute budget', async () => {
|
||||
await pendingBinding()
|
||||
meter.sample.mockReturnValue({ active: 10, idle: 30_000, utilization: 10 / 30_010 })
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'wall-clock ceiling reached (30000ms)' } })
|
||||
})
|
||||
})
|
||||
@@ -6,23 +6,29 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports the package by name through its exports map,
|
||||
* then exercises type stripping, sibling `worker.cjs` loading, bindings, and logs. Unit tests use
|
||||
* `src/worker.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent,
|
||||
* then exercises type stripping, sibling `process.js` loading, bindings, and logs. Unit tests use
|
||||
* `src/process.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent,
|
||||
* and CI runs it after the build.
|
||||
*/
|
||||
|
||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(pkgDir, file)))
|
||||
const built = ['lib/index.js', 'lib/process.js'].every(file => existsSync(join(pkgDir, file)))
|
||||
&& existsSync(join(pkgDir, '../code-runtime/lib/index.js'))
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => {
|
||||
it('runs a TypeScript program with a binding through lib/index.js and its lib/process.js entry', async () => {
|
||||
const script = `
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { NodeCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-node')
|
||||
const ctx = new Context()
|
||||
for (const name of ['session-projection', 'fs-local', 'subprocess-local', 'sandbox-local']) {
|
||||
const plugin = await import('@deepseek-ai/dsh-' + name)
|
||||
await ctx.plugin(plugin.default, {})
|
||||
}
|
||||
const { default: SandboxPolicy } = await import('@deepseek-ai/dsh-sandbox-policy')
|
||||
await ctx.plugin(SandboxPolicy, { mode: 'read-only' })
|
||||
await ctx.plugin(NodeCodeRuntime, {})
|
||||
const result = await ctx.codeRuntime.run({
|
||||
const result = await ctx.codeRuntime.run(ctx.codeRuntime.resolve({
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
@@ -32,9 +38,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
},
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
})
|
||||
}))
|
||||
await ctx.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
`
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
|
||||
function pair() {
|
||||
const left = new PassThrough()
|
||||
const right = new PassThrough()
|
||||
const a = Duplex.from({ readable: left, writable: right })
|
||||
const b = Duplex.from({ readable: right, writable: left })
|
||||
// Duplex.from forwards peer destruction as ABORT_ERR; channel owners observe their own failures.
|
||||
a.on('error', () => {})
|
||||
b.on('error', () => {})
|
||||
onTestFinished(() => { a.destroy(); b.destroy() })
|
||||
return { a, b }
|
||||
}
|
||||
|
||||
function frame(value: unknown): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(value))
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(body.length)
|
||||
return Buffer.concat([header, body])
|
||||
}
|
||||
|
||||
describe('bounded process frames', () => {
|
||||
it('reassembles split binary headers and UTF-8 bodies', async () => {
|
||||
const { a, b } = pair()
|
||||
const received = Promise.withResolvers<unknown>()
|
||||
const channel = new JsonChannel(a, 1000, value => received.resolve(value), error => received.reject(error))
|
||||
onTestFinished(() => channel.close())
|
||||
const bytes = frame({ text: '你好🙂' })
|
||||
for (const byte of bytes) b.write(Buffer.from([byte]))
|
||||
expect(await received.promise).toEqual({ text: '你好🙂' })
|
||||
})
|
||||
|
||||
it('accepts consecutive frames and writes complete responses', async () => {
|
||||
const { a, b } = pair()
|
||||
const received: unknown[] = []
|
||||
const done = Promise.withResolvers<undefined>()
|
||||
const channel = new JsonChannel(a, 1000, (value) => {
|
||||
received.push(value)
|
||||
if (received.length === 2) done.resolve(undefined)
|
||||
}, error => done.reject(error))
|
||||
const peer = new JsonChannel(b, 1000, () => {}, error => done.reject(error))
|
||||
onTestFinished(() => { channel.close(); peer.close() })
|
||||
await Promise.all([peer.send({ n: 1 }), peer.send({ n: 2 })])
|
||||
await done.promise
|
||||
expect(received).toEqual([{ n: 1 }, { n: 2 }])
|
||||
await peer.drain()
|
||||
})
|
||||
|
||||
it.each([0, 65])('rejects a declared %i-byte frame before accepting its body', async (length) => {
|
||||
const { a, b } = pair()
|
||||
const failure = Promise.withResolvers<Error>()
|
||||
const channel = new JsonChannel(a, 64, () => { throw new Error('must not dispatch') }, error => failure.resolve(error))
|
||||
onTestFinished(() => channel.close())
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(length)
|
||||
b.write(header)
|
||||
expect((await failure.promise).message).toContain('control frame')
|
||||
})
|
||||
|
||||
it('contains malformed JSON, invalid UTF-8 and receiver failures', async () => {
|
||||
for (const payload of [Buffer.from('{'), Buffer.from([0xff]), Buffer.from('{}')]) {
|
||||
const { a, b } = pair()
|
||||
const failure = Promise.withResolvers<Error>()
|
||||
const channel = new JsonChannel(a, 64, () => { throw new Error('receiver failed') }, error => failure.resolve(error))
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(payload.length)
|
||||
b.write(Buffer.concat([header, payload]))
|
||||
expect(await failure.promise).toBeInstanceOf(Error)
|
||||
channel.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects oversized writes and closed channels', async () => {
|
||||
const { a } = pair()
|
||||
const channel = new JsonChannel(a, 20, () => {}, () => {})
|
||||
await expect(channel.send({ text: 'x'.repeat(30) })).rejects.toThrow('queued bytes')
|
||||
channel.close()
|
||||
channel.close()
|
||||
await expect(channel.send({})).rejects.toThrow('closed')
|
||||
})
|
||||
|
||||
it('reports peer EOF while a program is active', async () => {
|
||||
const { a, b } = pair()
|
||||
const failure = Promise.withResolvers<Error>()
|
||||
const channel = new JsonChannel(a, 64, () => {}, error => failure.resolve(error))
|
||||
onTestFinished(() => channel.close())
|
||||
b.end()
|
||||
expect((await failure.promise).message).toContain('ended')
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a partial header and partial payload across distinct stream deliveries', () => {
|
||||
const { a } = pair()
|
||||
const values: unknown[] = []
|
||||
const channel = new JsonChannel(a, 64, (value) => { values.push(value) }, (error) => { throw error })
|
||||
const bytes = frame({ value: 1 })
|
||||
a.emit('data', bytes.subarray(0, 2))
|
||||
a.emit('data', bytes.subarray(2, 6))
|
||||
a.emit('data', bytes.subarray(6))
|
||||
expect(values).toEqual([{ value: 1 }])
|
||||
channel.close()
|
||||
})
|
||||
|
||||
it('contains a receiver throwing a non-Error value', () => {
|
||||
const { a } = pair()
|
||||
const failures: string[] = []
|
||||
const channel = new JsonChannel(a, 64, () => { throw 'receiver failure' }, (error) => { failures.push(error.message) })
|
||||
a.emit('data', frame({}))
|
||||
expect(failures).toEqual(['receiver failure'])
|
||||
channel.close()
|
||||
})
|
||||
|
||||
it('ignores callbacks already captured by an emission when an earlier listener closes the channel', () => {
|
||||
for (const event of ['data', 'end']) {
|
||||
const { a } = pair()
|
||||
let failures = 0
|
||||
const channel = new JsonChannel(a, 64, () => { throw new Error('closed channel dispatched') }, () => { failures += 1 })
|
||||
a.prependOnceListener(event, () => channel.close())
|
||||
a.emit(event, frame({}))
|
||||
a.emit('error', new Error('late closed stream error'))
|
||||
expect(failures).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects queued writes if the owner closes before they start', async () => {
|
||||
const { a } = pair()
|
||||
const channel = new JsonChannel(a, 64, () => {}, () => {})
|
||||
const pending = channel.send({ n: 1 })
|
||||
channel.close()
|
||||
await expect(pending).rejects.toThrow('closed')
|
||||
await channel.drain()
|
||||
})
|
||||
|
||||
it('bounds queued frames while a receiver is not reading', async () => {
|
||||
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} })
|
||||
const channel = new JsonChannel(stream, 32, () => {}, () => {})
|
||||
const pending = channel.send({ value: '1234567890' })
|
||||
await expect(channel.send({ value: '1234567890' })).rejects.toThrow('queued bytes')
|
||||
channel.close()
|
||||
await expect(pending).rejects.toThrow('closed')
|
||||
})
|
||||
|
||||
it.each(['error', 'close'])('settles a blocked write when the stream emits %s', async (event) => {
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } })
|
||||
let failure: Error | undefined
|
||||
const channel = new JsonChannel(stream, 64, () => {}, (error) => { failure = error })
|
||||
const pending = channel.send({ value: 1 })
|
||||
await entered.promise
|
||||
stream.emit(event, new Error('transport failed'))
|
||||
await expect(pending).rejects.toThrow(event === 'error' ? 'transport failed' : 'closed')
|
||||
if (event === 'error') expect(failure?.message).toBe('transport failed')
|
||||
channel.close()
|
||||
})
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from '../src/worker-json.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire, snapshotCodeJsonValue } from '../src/json-wire.ts'
|
||||
|
||||
describe('snapshotCodeJsonValue', () => {
|
||||
it('matches the canonical scalar boundary', () => {
|
||||
@@ -176,9 +176,9 @@ describe('flat worker JSON wire', () => {
|
||||
for (const value of values) {
|
||||
const snapshot = snapshotCodeJsonValue(value)
|
||||
expect(snapshot).not.toBeUndefined()
|
||||
expect(decodeWorkerJson(encodeWorkerJson(snapshot!))).toEqual(snapshot)
|
||||
expect(decodeCodeJsonWire(encodeCodeJsonWire(snapshot!))).toEqual(snapshot)
|
||||
}
|
||||
const decoded = decodeWorkerJson(encodeWorkerJson(snapshotCodeJsonValue(withPrototypeKey)!)) as Record<string, unknown>
|
||||
const decoded = decodeCodeJsonWire(encodeCodeJsonWire(snapshotCodeJsonValue(withPrototypeKey)!)) as Record<string, unknown>
|
||||
expect(Object.hasOwn(decoded, '__proto__')).toBe(true)
|
||||
expect(decoded.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
@@ -187,10 +187,10 @@ describe('flat worker JSON wire', () => {
|
||||
let value: unknown = 'leaf'
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
const snapshot = snapshotCodeJsonValue(value)!
|
||||
const wire = encodeWorkerJson(snapshot)
|
||||
const wire = encodeCodeJsonWire(snapshot)
|
||||
expect(wire).toHaveLength(5_001)
|
||||
|
||||
let cursor = decodeWorkerJson(wire)
|
||||
let cursor = decodeCodeJsonWire(wire)
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
@@ -246,12 +246,12 @@ describe('flat worker JSON wire', () => {
|
||||
[{ kind: 'object', keys: ['x'] }],
|
||||
[{ kind: 'object', keys: [], extra: true }],
|
||||
]) {
|
||||
expect(decodeWorkerJson(value)).toBeUndefined()
|
||||
expect(decodeCodeJsonWire(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid values passed through a forged static type', () => {
|
||||
expect(() => encodeWorkerJson([undefined] as never)).toThrow(/sparse JSON array/)
|
||||
expect(() => encodeWorkerJson({ value: undefined } as never)).toThrow(/undefined JSON object property/)
|
||||
expect(() => encodeCodeJsonWire([undefined] as never)).toThrow(/sparse JSON array/)
|
||||
expect(() => encodeCodeJsonWire({ value: undefined } as never)).toThrow(/undefined JSON object property/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { runNodeMain } from '../src/process.ts'
|
||||
import type { ProgramProcess } from '../src/process.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
|
||||
function endpoints() {
|
||||
const first = new PassThrough()
|
||||
const second = new PassThrough()
|
||||
const child = Duplex.from({ readable: first, writable: second })
|
||||
const host = Duplex.from({ readable: second, writable: first })
|
||||
child.on('error', () => {})
|
||||
host.on('error', () => {})
|
||||
onTestFinished(() => { child.destroy(); host.destroy() })
|
||||
return { child, host }
|
||||
}
|
||||
function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined } }
|
||||
|
||||
it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const messages: Record<string, unknown>[] = []
|
||||
const peer = new JsonChannel(host, 4096, (raw) => {
|
||||
const message = raw as Record<string, unknown>
|
||||
messages.push(message)
|
||||
if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } })
|
||||
if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(42) })
|
||||
}, () => {})
|
||||
onTestFinished(() => peer.close())
|
||||
await runNodeMain(child, 4096, state)
|
||||
expect(state.env).toEqual({})
|
||||
expect(state.exitCode).toBeUndefined()
|
||||
expect(decodeCodeJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42)
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => {
|
||||
const { child } = endpoints()
|
||||
await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit')
|
||||
})
|
||||
|
||||
it('rejects an unexpected first control frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const peer = new JsonChannel(host, 4096, () => { void peer.send({ type: 'reply' }) }, () => {})
|
||||
onTestFinished(() => peer.close())
|
||||
await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot')
|
||||
})
|
||||
|
||||
it('records an I/O failure before the boot frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {})
|
||||
await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error)
|
||||
expect(state.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('contains program writes that exceed queued control output', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const peer = new JsonChannel(host, 1024, (raw) => {
|
||||
if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { code: 'for(let i=0;i<30;i++) console.log("x".repeat(100));', namespaces: [], maxOutputBytes: 8000 } })
|
||||
}, () => {})
|
||||
onTestFinished(() => peer.close())
|
||||
await runNodeMain(child, 1024, state)
|
||||
expect(state.exitCode).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
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 { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
|
||||
it('boots an unbuilt source closure outside the workspace and exchanges tool replies', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-node-source-'))
|
||||
onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) })
|
||||
for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts']) {
|
||||
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
|
||||
}
|
||||
const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', source], {
|
||||
env: { PLACEHOLDER_SECRET: 'fixture-only' },
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ignore', 'ignore', 'ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const finished = new Promise<void>((resolve) => { child.once('close', () => { resolve() }) })
|
||||
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 message = raw as { type: string; id?: number; args?: unknown; value?: unknown; error?: unknown }
|
||||
if (message.type === 'ready') {
|
||||
void channel.send({ type: 'boot', data: {
|
||||
code: 'const answer = await tools.echo({ n: 21 }); return { answer, env: { ...process.env } }',
|
||||
namespaces: [{ global: 'tools', names: ['echo'] }],
|
||||
maxOutputBytes: 10_000,
|
||||
} }).catch(error => completed.reject(error))
|
||||
} else if (message.type === 'call') {
|
||||
expect(decodeCodeJsonWire(message.args)).toEqual({ n: 21 })
|
||||
void channel.send({ type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(42) }).catch(error => completed.reject(error))
|
||||
} else if (message.type === 'done') {
|
||||
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
|
||||
else completed.resolve(decodeCodeJsonWire(message.value))
|
||||
}
|
||||
}, error => completed.reject(error))
|
||||
onTestFinished(async () => { channel.close(); child.kill(); await finished })
|
||||
expect(await completed.promise).toEqual({ answer: 42, env: {} })
|
||||
await finished
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionProjections from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
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 } = {}) {
|
||||
if (!ctx.get('fs')) await ctx.plugin(FileSystem)
|
||||
if (!ctx.get('subprocess')) await ctx.plugin(Subprocess)
|
||||
if (!ctx.get('sandbox')) await ctx.plugin(Sandbox, {})
|
||||
if (!ctx.get('sessionProjections')) await ctx.plugin(SessionProjections)
|
||||
if (!ctx.get('sandboxPolicy')) await ctx.plugin(SandboxPolicy, { mode: 'danger-full-access', ...policy })
|
||||
await ctx.plugin(NodeRuntime, config)
|
||||
return ctx.codeRuntime as NodeRuntime
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { expect, it } from 'vitest'
|
||||
import { decodeWorkerJson } from '../src/worker-json.ts'
|
||||
|
||||
/**
|
||||
* Prove the unbuilt worker is a self-contained source closure. Copying it out
|
||||
* of the workspace makes any package runtime import fail even when local
|
||||
* `lib/` artifacts happen to exist.
|
||||
*/
|
||||
it('boots the source worker without workspace package outputs', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-code-source-worker-'))
|
||||
let worker: Worker | undefined
|
||||
try {
|
||||
const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts', 'output-json.ts']
|
||||
await Promise.all(files.map(async (file) => {
|
||||
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
|
||||
}))
|
||||
|
||||
worker = new Worker(join(directory, 'worker.ts'), {
|
||||
workerData: { code: 'return { answer: 42 }', namespaces: [], maxOutputBytes: 65_536 },
|
||||
env: {},
|
||||
execArgv: [],
|
||||
})
|
||||
const message = await new Promise<unknown>((resolve, reject) => {
|
||||
worker?.once('message', resolve)
|
||||
worker?.once('error', reject)
|
||||
})
|
||||
|
||||
expect(message).toMatchObject({ type: 'done' })
|
||||
const value = typeof message === 'object' && message !== null ? (message as { value?: unknown }).value : undefined
|
||||
expect(decodeWorkerJson(value)).toEqual({ answer: 42 })
|
||||
} finally {
|
||||
if (worker) await worker.terminate()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user