refactor(code-runtime): remove subprocess backend
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
|
||||
@@ -311,7 +310,6 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
|
||||
* @param pending - the id-keyed map each posted call parks its handles in.
|
||||
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
|
||||
* @param errorClasses - per-namespace constructors shared with program globals.
|
||||
* @param maxFrameBytes - optional serialized transport cap checked before posting.
|
||||
* @returns one namespace object per declaration, in declaration order.
|
||||
*/
|
||||
export function makeNamespaces(
|
||||
@@ -320,7 +318,6 @@ export function makeNamespaces(
|
||||
pending: Map<number, PendingCall>,
|
||||
nextId: { value: number },
|
||||
errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
|
||||
maxFrameBytes?: number,
|
||||
): Record<string, unknown>[] {
|
||||
return data.namespaces.map(({ global, names }) => {
|
||||
const errorClass = errorClasses.get(global)
|
||||
@@ -338,11 +335,6 @@ export function makeNamespaces(
|
||||
if (detached === undefined) {
|
||||
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
|
||||
}
|
||||
const call = { type: 'call' as const, id: nextId.value, global, name, args: encodeWorkerJson(detached) }
|
||||
if (maxFrameBytes !== undefined
|
||||
&& jsonValueBytesUpTo(call as unknown as CodeJsonValue, maxFrameBytes) === undefined) {
|
||||
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments exceed maxFrameBytes'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId.value++
|
||||
pending.set(id, {
|
||||
@@ -352,7 +344,7 @@ export function makeNamespaces(
|
||||
},
|
||||
})
|
||||
try {
|
||||
port.postMessage(call)
|
||||
port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) })
|
||||
} catch (error: unknown) {
|
||||
pending.delete(id)
|
||||
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
|
||||
@@ -372,14 +364,12 @@ export function makeNamespaces(
|
||||
* @param port - host message port or test double.
|
||||
* @param data - the boot payload the host sent.
|
||||
* @param streams - stdout/stderr objects captured as program logs.
|
||||
* @param maxFrameBytes - optional serialized transport cap checked before posting.
|
||||
* @returns after posting the done message.
|
||||
*/
|
||||
export async function runWorkerMain(
|
||||
port: BootstrapPort,
|
||||
data: WorkerBootData,
|
||||
streams: { stdout: PatchableStream; stderr: PatchableStream },
|
||||
maxFrameBytes?: number,
|
||||
): Promise<void> {
|
||||
const logs = new LogBuffer(
|
||||
data.maxOutputBytes,
|
||||
@@ -394,7 +384,7 @@ export async function runWorkerMain(
|
||||
|
||||
const nextId = { value: 1 }
|
||||
const errorClasses = makeBindingErrorClasses(data)
|
||||
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses, maxFrameBytes)
|
||||
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
|
||||
const errorClassParameters: string[] = []
|
||||
const errorClassValues: BindingErrorConstructor[] = []
|
||||
for (const namespace of data.namespaces) {
|
||||
@@ -430,8 +420,5 @@ export async function runWorkerMain(
|
||||
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
|
||||
}
|
||||
}
|
||||
port.postMessage(maxFrameBytes !== undefined
|
||||
&& jsonValueBytesUpTo(done as unknown as CodeJsonValue, maxFrameBytes) === undefined
|
||||
? { type: 'output-limit' }
|
||||
: done)
|
||||
port.postMessage(done)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
/** Shared host mechanics for local and subprocess-hosted TypeScript worker runtimes. */
|
||||
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import type { Readable } from 'node:stream'
|
||||
import type {
|
||||
CodeBindingNamespace,
|
||||
CodeJsonValue,
|
||||
CodeRunFailure,
|
||||
CodeRunRequest,
|
||||
CodeRunResult,
|
||||
} from '@deepseek-ai/dsh-code-runtime'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
|
||||
import type { WorkerJsonWire } from './worker-json.ts'
|
||||
|
||||
/** Smallest cap that can represent an empty log array and failure message. */
|
||||
export const MIN_RUNTIME_OUTPUT_BYTES = 4
|
||||
|
||||
/**
|
||||
* Resolve after a worker pipe emits queued data or closes during termination.
|
||||
* @param stream - captured worker or child-process pipe.
|
||||
* @returns after no more queued bytes can arrive.
|
||||
*/
|
||||
export function waitForRuntimePipeDrain(stream: Readable): Promise<void> {
|
||||
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const done = (): void => {
|
||||
stream.off('end', done)
|
||||
stream.off('close', done)
|
||||
stream.off('error', done)
|
||||
resolve()
|
||||
}
|
||||
stream.once('end', done)
|
||||
stream.once('close', done)
|
||||
stream.once('error', done)
|
||||
/* v8 ignore next -- termination can win the adjacent listener-registration race. */
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
const RESERVED_WORDS = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
||||
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
|
||||
'private', 'protected', 'public', 'arguments', 'eval',
|
||||
])
|
||||
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
|
||||
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
|
||||
|
||||
/** One validated binding call received from an isolated worker. */
|
||||
export interface RuntimeBindingCall {
|
||||
/** Correlation id supplied by the isolated worker. */
|
||||
readonly id: number
|
||||
/** Injected namespace global. */
|
||||
readonly global: string
|
||||
/** Declared namespace function. */
|
||||
readonly name: string
|
||||
/** Untrusted lossless-JSON wire payload. */
|
||||
readonly args: unknown
|
||||
}
|
||||
|
||||
/** One host reply to an isolated worker binding call. */
|
||||
export type RuntimeBindingReply =
|
||||
| { readonly type: 'reply'; readonly id: number; readonly ok: true; readonly value: WorkerJsonWire }
|
||||
| { readonly type: 'reply'; readonly id: number; readonly ok: false; readonly message: string }
|
||||
|
||||
/**
|
||||
* Render an unknown thrown value without assuming it is an Error.
|
||||
* @param error - thrown or rejected value.
|
||||
* @returns the caller-facing diagnostic text.
|
||||
*/
|
||||
export function runtimeErrorMessage(error: unknown): string {
|
||||
try {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
} catch {
|
||||
return 'binding rejected with an unrenderable value'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip erasable TypeScript while preserving the program's body coordinates.
|
||||
* @param program - model-written async-function body.
|
||||
* @returns JavaScript source with the wrapper removed.
|
||||
*/
|
||||
export function stripRuntimeProgram(program: string): string {
|
||||
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + program + STRIP_WRAP.suffix)
|
||||
return stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate binding globals and typed-error declarations shared by worker runtimes.
|
||||
* @param request - code-runtime request carrying the namespaces.
|
||||
* @param implementationName - package name used in seam-misuse diagnostics.
|
||||
* @returns namespaces indexed by their injected global.
|
||||
*/
|
||||
export function validateRuntimeBindings(
|
||||
request: CodeRunRequest,
|
||||
implementationName: string,
|
||||
): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`${implementationName}: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
if (namespace.global === 'console' || bindings.has(namespace.global)) {
|
||||
throw new Error(`${implementationName}: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace)
|
||||
}
|
||||
|
||||
const errorClassNames = new Set<string>()
|
||||
for (const namespace of request.bindings) {
|
||||
const descriptor = namespace.errorClass
|
||||
if (descriptor === undefined) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`${implementationName}: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`${implementationName}: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
|
||||
throw new Error(`${implementationName}: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one untrusted worker call through a declared host binding.
|
||||
* @param call - parsed call envelope from the isolated worker.
|
||||
* @param bindings - namespaces returned by {@link validateRuntimeBindings}.
|
||||
* @returns a lossless-JSON success or stable rejection reply.
|
||||
*/
|
||||
export async function invokeRuntimeBinding(
|
||||
call: RuntimeBindingCall,
|
||||
bindings: ReadonlyMap<string, CodeBindingNamespace>,
|
||||
): Promise<RuntimeBindingReply> {
|
||||
const functions = bindings.get(call.global)?.functions
|
||||
const fn = functions !== undefined && Object.hasOwn(functions, call.name) ? functions[call.name] : undefined
|
||||
if (typeof fn !== 'function') {
|
||||
return { type: 'reply', id: call.id, ok: false, message: `unknown binding ${JSON.stringify(`${call.global}.${call.name}`)}` }
|
||||
}
|
||||
const args = decodeWorkerJson(call.args)
|
||||
if (args === undefined) {
|
||||
return { type: 'reply', id: call.id, ok: false, message: 'binding arguments must be lossless JSON' }
|
||||
}
|
||||
try {
|
||||
const resolved = await fn(args)
|
||||
let value: CodeJsonValue | undefined
|
||||
try {
|
||||
value = snapshotCodeJsonValue(resolved)
|
||||
} catch {
|
||||
value = undefined
|
||||
}
|
||||
if (value === undefined) {
|
||||
return { type: 'reply', id: call.id, ok: false, message: 'binding resolution must be lossless JSON' }
|
||||
}
|
||||
return { type: 'reply', id: call.id, ok: true, value: encodeWorkerJson(value) }
|
||||
} catch (error: unknown) {
|
||||
return { type: 'reply', id: call.id, ok: false, message: runtimeErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
export class RuntimeOutputLedger {
|
||||
private bytes = 2
|
||||
private entries = 0
|
||||
|
||||
/** @param maxBytes - hard cap for logs plus completion or failure payload. */
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/**
|
||||
* Admit one exact log entry.
|
||||
* @param text - candidate log entry.
|
||||
* @param sink - ordered retained log list.
|
||||
* @returns false when the hard cap was crossed.
|
||||
*/
|
||||
admit(text: string, sink: string[]): boolean {
|
||||
const separatorBytes = this.entries > 0 ? 1 : 0
|
||||
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
|
||||
if (stringBytes === undefined) return false
|
||||
this.bytes += stringBytes + separatorBytes
|
||||
this.entries += 1
|
||||
sink.push(text)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a successful completion against the combined cap.
|
||||
* @param logs - retained ordered logs.
|
||||
* @param value - optional lossless-JSON completion.
|
||||
* @returns the completion or output-limit result.
|
||||
*/
|
||||
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
|
||||
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, ...value !== undefined ? { value } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize one failure diagnostic against the combined cap.
|
||||
* @param logs - retained ordered logs.
|
||||
* @param error - structured runtime failure.
|
||||
* @returns the failure or output-limit result.
|
||||
*/
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an explicit output-limit failure with a fitting log prefix.
|
||||
* @param logs - ordered logs observed before the limit.
|
||||
* @returns bounded output-limit result.
|
||||
*/
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
const messageBytes = fullMessage.length + 2
|
||||
const retained: string[] = []
|
||||
let retainedBytes = 2
|
||||
const logBudget = this.maxBytes - messageBytes
|
||||
for (const text of logs) {
|
||||
const separatorBytes = retained.length > 0 ? 1 : 0
|
||||
const availableBytes = logBudget - retainedBytes - separatorBytes
|
||||
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
|
||||
if (stringBytes !== undefined) {
|
||||
retained.push(text)
|
||||
retainedBytes += stringBytes + separatorBytes
|
||||
continue
|
||||
}
|
||||
const prefix = truncateJsonStringBytes(text, availableBytes)
|
||||
if (prefix.length > 0) {
|
||||
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
|
||||
/* v8 ignore next -- truncateJsonStringBytes guarantees the same bound. */
|
||||
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
|
||||
retained.push(prefix)
|
||||
retainedBytes += prefixBytes + separatorBytes
|
||||
}
|
||||
break
|
||||
}
|
||||
const message = truncateJsonStringBytes(fullMessage, this.maxBytes - retainedBytes)
|
||||
return { logs: retained, error: { kind: 'output-limit', message } }
|
||||
}
|
||||
}
|
||||
|
||||
export { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
|
||||
export { jsonStringBytesUpTo, jsonValueBytesUpTo } from './output-json.ts'
|
||||
export { runWorkerMain } from './bootstrap.ts'
|
||||
export type { WorkerJsonWire } from './worker-json.ts'
|
||||
@@ -286,23 +286,6 @@ describe('makeNamespaces', () => {
|
||||
expect(nextId.value).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects an oversized transport frame before posting or allocating a call id', async () => {
|
||||
const port = new FakePort()
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const nextId = { value: 1 }
|
||||
const data = { namespaces: [toolNamespace(['x'])] }
|
||||
const [tools] = makeNamespaces(
|
||||
data, port, pending, nextId, makeBindingErrorClasses(data), 64,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
|
||||
await expect(tools.x?.({ text: 'x'.repeat(64) })).rejects.toMatchObject({
|
||||
name: 'ToolCallError', toolName: 'x', message: 'binding arguments exceed maxFrameBytes',
|
||||
})
|
||||
expect(port.sent).toEqual([])
|
||||
expect(pending.size).toBe(0)
|
||||
expect(nextId.value).toBe(1)
|
||||
})
|
||||
|
||||
it('uses ordinary Error for non-tools namespace failures', async () => {
|
||||
const deniedPort = new FakePort()
|
||||
deniedPort.respond = message => message.type === 'call'
|
||||
@@ -360,16 +343,6 @@ describe('runWorkerMain', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports output-limit before posting a completion that expands past the transport cap', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, {
|
||||
maxOutputBytes: 1_000,
|
||||
code: 'return Array.from({ length: 100 }, () => [])',
|
||||
namespaces: [],
|
||||
}, fakeStreams(), 100)
|
||||
expect(port.sent.at(-1)).toEqual({ type: 'output-limit' })
|
||||
})
|
||||
|
||||
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())
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Build the plugin, reusable runtime host, and worker as separate bundles. The
|
||||
* sibling `worker.cjs` is loaded by file and must be CommonJS for pkg's VFS
|
||||
* Worker hook. Separate builds inline shared implementation instead of
|
||||
* emitting an unlisted chunk outside the exact `files` whitelist.
|
||||
* Build the index and worker as separate single-entry bundles. The sibling `worker.cjs` is loaded
|
||||
* by file and must be CommonJS for pkg's VFS Worker hook. A multi-entry build emits an unlisted
|
||||
* shared chunk omitted by the package's exact `files` whitelist; separate builds inline it.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
@@ -17,16 +16,6 @@ export default defineConfig([
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/runtime-host.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/worker.js'],
|
||||
outDir: 'lib',
|
||||
|
||||
Reference in New Issue
Block a user