refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Worker-side execution logic, written as plain functions over an injected port so the unit
|
||||
* suite can run every line IN-PROCESS against a fake port (a real worker thread is a separate
|
||||
* V8 isolate the coverage provider cannot observe).
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker-thread/src/bootstrap
|
||||
*/
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
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'
|
||||
|
||||
const CapturedError = Error
|
||||
const capturedObjectCreate = Object.create
|
||||
const capturedObjectDefineProperty = Object.defineProperty
|
||||
|
||||
/** Define one public binding-error field without consulting mutable globals or descriptor prototypes. */
|
||||
function defineBindingErrorField(error: Error, key: string, value: string): void {
|
||||
const attributes = capturedObjectCreate(null) as PropertyDescriptor
|
||||
attributes.enumerable = true
|
||||
attributes.value = value
|
||||
capturedObjectDefineProperty(error, key, attributes)
|
||||
}
|
||||
|
||||
/** The port API the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
||||
export interface BootstrapPort {
|
||||
postMessage(message: WorkerToHost): void
|
||||
on(event: 'message', listener: (message: ReplyMessage) => void): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A writable stream's `write` slot, as the bootstrap patches it (see
|
||||
* {@link captureStreamWrites}). Method-typed so the real
|
||||
* `process.stdout`/`process.stderr` (narrower chunk parameters) remain
|
||||
* assignable.
|
||||
*/
|
||||
export interface PatchableStream {
|
||||
write(chunk: unknown, ...rest: unknown[]): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered text capture under the shared outer JSON-byte budget, delivered to
|
||||
* a sink as each item lands (the real sink streams text over the port eagerly,
|
||||
* so captured output survives a mid-run termination). It includes the log
|
||||
* array syntax and string escaping in its accounting. Once exhausted it emits
|
||||
* the fitting prefix and reports the limit once; the host turns that condition
|
||||
* into an explicit `output-limit` run failure.
|
||||
*/
|
||||
export class LogBuffer {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
private truncated = false
|
||||
// Explicit fields, not constructor parameter properties: this module loads
|
||||
// under Node's native strip-only mode, which rejects non-erasable syntax —
|
||||
// and parameter properties are non-erasable.
|
||||
private readonly sink: (text: string) => void
|
||||
private readonly onLimit: () => void
|
||||
private readonly maxBytes: number
|
||||
|
||||
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
|
||||
this.maxBytes = maxBytes
|
||||
this.sink = sink
|
||||
this.onLimit = onLimit
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit text to the sink, charging it against the budget (drops + marks once exhausted).
|
||||
* @param text - the captured text to deliver.
|
||||
*/
|
||||
push(text: string): void {
|
||||
if (this.truncated) return
|
||||
const separatorBytes = this.entries > 0 ? 1 : 0
|
||||
const availableBytes = this.maxBytes - this.bytes - separatorBytes
|
||||
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
|
||||
if (stringBytes === undefined) {
|
||||
this.truncated = true
|
||||
const prefix = truncateJsonStringBytes(text, availableBytes)
|
||||
if (prefix.length > 0) {
|
||||
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
|
||||
/* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */
|
||||
if (prefixBytes === undefined) throw new CapturedError('worker output ledger produced an oversized log prefix')
|
||||
this.bytes += prefixBytes + separatorBytes
|
||||
this.entries += 1
|
||||
this.sink(prefix)
|
||||
}
|
||||
this.onLimit()
|
||||
return
|
||||
}
|
||||
this.bytes += stringBytes + separatorBytes
|
||||
this.entries += 1
|
||||
this.sink(text)
|
||||
}
|
||||
|
||||
/** Remaining exact JSON-byte budget for the completion value or failure message. */
|
||||
remainingOutputBytes(): number {
|
||||
return this.maxBytes - this.bytes
|
||||
}
|
||||
}
|
||||
|
||||
/** The five console methods the shim captures, in the seam's level vocabulary. */
|
||||
const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const
|
||||
|
||||
/**
|
||||
* A `console` replacement whose five leveled methods render their arguments
|
||||
* `util.inspect`-style (matching real console formatting closely enough for
|
||||
* a model to recognize its own output) into the buffer. Only these five
|
||||
* exist — the program gets a deliberately small console, not Node's full
|
||||
* console API.
|
||||
* @param logs - the buffer every rendered line is pushed into.
|
||||
* @returns the five-method console object handed to the program.
|
||||
*/
|
||||
export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> {
|
||||
const render = (args: unknown[]): string =>
|
||||
args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
|
||||
const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
|
||||
for (const level of CONSOLE_LEVELS) {
|
||||
shim[level] = (...args: unknown[]) => { logs.push(render(args)) }
|
||||
}
|
||||
return shim
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect a stream's `write` into the log buffer (the program-visible
|
||||
* `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
|
||||
* alongside console output instead of racing down a pipe. It preserves Node's optional callback
|
||||
* contract: the callback runs asynchronously after admission, even when the log budget drops
|
||||
* the write.
|
||||
*
|
||||
* @param logs - the buffer captured writes are pushed into.
|
||||
* @param stream - the stream whose `write` slot is patched.
|
||||
* @returns the restore function (the in-process tests un-patch; the real
|
||||
* worker never needs to).
|
||||
*/
|
||||
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void {
|
||||
// The slot's VALUE is stored for restore and reassigned — never invoked
|
||||
// detached, so the unbound-method concern does not apply.
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
const original = stream.write
|
||||
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
|
||||
logs.push(typeof chunk === 'string' ? chunk : String(chunk))
|
||||
// Node's optional-encoding shape: the callback is whichever of the next
|
||||
// two positions holds a function (a non-function there is the encoding).
|
||||
const callback = [rest[0], rest[1]].find(
|
||||
(arg): arg is (error?: Error | null) => void => typeof arg === 'function',
|
||||
)
|
||||
if (callback) queueMicrotask(() => { callback(null) })
|
||||
return true
|
||||
}
|
||||
return () => { stream.write = original }
|
||||
}
|
||||
|
||||
/** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
|
||||
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
|
||||
|
||||
/**
|
||||
* Prepare the program's completion value for the done message. Only lossless
|
||||
* JSON crosses, and a value that does not fit the remaining combined outer
|
||||
* budget reports `output-limit`; the host revalidates hostile traffic and
|
||||
* remains authoritative for native pipe writes the worker cannot observe.
|
||||
*
|
||||
* @param value - the program's completion value.
|
||||
* @param remainingOutputBytes - exact bytes left after captured logs.
|
||||
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
|
||||
* @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
|
||||
*/
|
||||
export function prepareCompletion(
|
||||
value: unknown,
|
||||
remainingOutputBytes: number,
|
||||
maxOutputBytes: number = remainingOutputBytes,
|
||||
): Omit<DoneMessage, 'type'> {
|
||||
if (value === undefined) return {}
|
||||
let snapshot: ReturnType<typeof snapshotCodeJsonValue>
|
||||
try {
|
||||
snapshot = snapshotCodeJsonValue(value)
|
||||
} catch {
|
||||
snapshot = undefined
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
return prepareFailure(
|
||||
'invalid-output',
|
||||
'program completion must be lossless JSON',
|
||||
remainingOutputBytes,
|
||||
maxOutputBytes,
|
||||
)
|
||||
}
|
||||
if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
|
||||
return outputLimit(maxOutputBytes)
|
||||
}
|
||||
return { value: encodeWorkerJson(snapshot) }
|
||||
}
|
||||
|
||||
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
|
||||
function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
|
||||
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
|
||||
}
|
||||
|
||||
/** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */
|
||||
function prepareFailure(
|
||||
kind: 'exception' | 'invalid-output',
|
||||
message: string,
|
||||
remainingOutputBytes: number,
|
||||
maxOutputBytes: number,
|
||||
): Omit<DoneMessage, 'type'> {
|
||||
if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
|
||||
return { error: { kind, message } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a thrown program value without sending an unbounded stack or
|
||||
* string across the worker port.
|
||||
* @param error - the value thrown by the program.
|
||||
* @param remainingOutputBytes - exact bytes left after captured logs.
|
||||
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
|
||||
* @returns a bounded exception or fixed output-limit fragment.
|
||||
*/
|
||||
export function prepareException(
|
||||
error: unknown,
|
||||
remainingOutputBytes: number,
|
||||
maxOutputBytes: number = remainingOutputBytes,
|
||||
): Omit<DoneMessage, 'type'> {
|
||||
let message: string
|
||||
try {
|
||||
const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error
|
||||
message = typeof detail === 'string' ? detail : String(detail)
|
||||
} catch {
|
||||
message = 'program threw an unrenderable value'
|
||||
}
|
||||
return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
|
||||
}
|
||||
|
||||
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
|
||||
export interface PendingCall {
|
||||
resolve(value: unknown): void
|
||||
reject(error: Error): void
|
||||
}
|
||||
|
||||
/** Constructor type for one program-visible binding rejection class. */
|
||||
export type BindingErrorConstructor = new (memberName: string, message: string) => Error
|
||||
|
||||
/**
|
||||
* Materialize the real error constructor declared by one namespace.
|
||||
* @param descriptor - program-global class name and member-name property.
|
||||
* @returns the constructor injected into the program and used for rejections.
|
||||
*/
|
||||
function makeBindingErrorClass(
|
||||
descriptor: { name: string; memberNameProperty: string },
|
||||
): BindingErrorConstructor {
|
||||
return class BindingCallError extends CapturedError {
|
||||
constructor(memberName: string, message: string) {
|
||||
super(message)
|
||||
defineBindingErrorField(this, 'name', descriptor.name)
|
||||
defineBindingErrorField(this, descriptor.memberNameProperty, memberName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the namespace-specific rejection for one failed binding call. */
|
||||
function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error {
|
||||
return errorClass ? new errorClass(memberName, message) : new CapturedError(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build each declared error class once so calls and `instanceof` share constructor identity.
|
||||
* @param data - binding namespace declarations from the boot payload.
|
||||
* @returns constructors keyed by their owning namespace global.
|
||||
*/
|
||||
export function makeBindingErrorClasses(
|
||||
data: Pick<WorkerBootData, 'namespaces'>,
|
||||
): Map<string, BindingErrorConstructor> {
|
||||
const classes = new Map<string, BindingErrorConstructor>()
|
||||
for (const namespace of data.namespaces) {
|
||||
if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
/**
|
||||
* Route host replies into the pending-call map: each reply settles its call
|
||||
* at most once, and a reply for an unknown id (stray, or a duplicate answer
|
||||
* to an id already settled) is ignored. Shared wiring between
|
||||
* {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
|
||||
* standalone.
|
||||
* @param port - the port whose `message` events carry the replies.
|
||||
* @param pending - the id-keyed map of unsettled binding calls.
|
||||
*/
|
||||
export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
|
||||
port.on('message', (message: ReplyMessage) => {
|
||||
const entry = pending.get(message.id)
|
||||
if (!entry) return
|
||||
pending.delete(message.id)
|
||||
if (message.ok) {
|
||||
const value = decodeWorkerJson(message.value)
|
||||
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
|
||||
else entry.resolve(value)
|
||||
} else {
|
||||
entry.reject(new CapturedError(message.message))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the binding namespace objects the program sees: one null-prototype global per
|
||||
* namespace, each declared name an own enumerable async function that bridges over the port
|
||||
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
|
||||
* Lossy arguments reject before posting; clone failures and host failure
|
||||
* replies reject only the corresponding call.
|
||||
*
|
||||
* @param data - the boot payload's namespace declarations (globals + names).
|
||||
* @param port - the port binding calls are posted to.
|
||||
* @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.
|
||||
* @returns one namespace object per declaration, in declaration order.
|
||||
*/
|
||||
export function makeNamespaces(
|
||||
data: Pick<WorkerBootData, 'namespaces'>,
|
||||
port: BootstrapPort,
|
||||
pending: Map<number, PendingCall>,
|
||||
nextId: { value: number },
|
||||
errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
|
||||
): Record<string, unknown>[] {
|
||||
return data.namespaces.map(({ global, names }) => {
|
||||
const errorClass = errorClasses.get(global)
|
||||
const namespace = Object.create(null) as Record<string, unknown>
|
||||
for (const name of names) {
|
||||
Object.defineProperty(namespace, name, {
|
||||
enumerable: true,
|
||||
value: (args: unknown): Promise<unknown> => {
|
||||
let detached: ReturnType<typeof snapshotCodeJsonValue>
|
||||
try {
|
||||
detached = snapshotCodeJsonValue(args)
|
||||
} catch {
|
||||
detached = undefined
|
||||
}
|
||||
if (detached === undefined) {
|
||||
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId.value++
|
||||
pending.set(id, {
|
||||
resolve,
|
||||
reject: (error) => {
|
||||
reject(bindingFailure(errorClass, name, error.message))
|
||||
},
|
||||
})
|
||||
try {
|
||||
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)}`
|
||||
reject(bindingFailure(errorClass, name, message))
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
return namespace
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one strict async-function body, allowing top-level `await` and `return`, and post exactly
|
||||
* one terminal {@link DoneMessage}; a thrown program error becomes its `error` field.
|
||||
* @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.
|
||||
* @returns after posting the done message.
|
||||
*/
|
||||
export async function runWorkerMain(
|
||||
port: BootstrapPort,
|
||||
data: WorkerBootData,
|
||||
streams: { stdout: PatchableStream; stderr: PatchableStream },
|
||||
): Promise<void> {
|
||||
const logs = new LogBuffer(
|
||||
data.maxOutputBytes,
|
||||
(text) => { port.postMessage({ type: 'log', text }) },
|
||||
() => { port.postMessage({ type: 'output-limit' }) },
|
||||
)
|
||||
captureStreamWrites(logs, streams.stdout)
|
||||
captureStreamWrites(logs, streams.stderr)
|
||||
|
||||
const pending = new Map<number, PendingCall>()
|
||||
wireReplies(port, pending)
|
||||
|
||||
const nextId = { value: 1 }
|
||||
const errorClasses = makeBindingErrorClasses(data)
|
||||
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
|
||||
const errorClassParameters: string[] = []
|
||||
const errorClassValues: BindingErrorConstructor[] = []
|
||||
for (const namespace of data.namespaces) {
|
||||
if (!namespace.errorClass) continue
|
||||
errorClassParameters.push(namespace.errorClass.name)
|
||||
const errorClass = errorClasses.get(namespace.global)
|
||||
/* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */
|
||||
if (!errorClass) throw new CapturedError(`missing binding error class for ${namespace.global}`)
|
||||
errorClassValues.push(errorClass)
|
||||
}
|
||||
const consoleShim = makeConsoleShim(logs)
|
||||
|
||||
let done: DoneMessage
|
||||
try {
|
||||
// The async function constructor, reached through an instance because
|
||||
// `AsyncFunction` is not a global. The program body is strict-mode.
|
||||
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
|
||||
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
|
||||
const fn = new AsyncFunction(
|
||||
...data.namespaces.map(namespace => namespace.global),
|
||||
...errorClassParameters,
|
||||
'console',
|
||||
`'use strict';\n${data.code}`,
|
||||
)
|
||||
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
|
||||
done = {
|
||||
type: 'done',
|
||||
...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
done = {
|
||||
type: 'done',
|
||||
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
|
||||
}
|
||||
}
|
||||
port.postMessage(done)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* Worker-thread code runtime: a fresh worker runs each host-type-stripped TypeScript program
|
||||
* and bridges bindings over its message port. This is containment, not a security boundary:
|
||||
* model code has bash-equivalent trust despite an empty environment, a heap cap, measured
|
||||
* event-loop busy-time and wall-time budgets, and termination that also stops synchronous loops.
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker-thread
|
||||
*/
|
||||
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
|
||||
import type { WorkerJsonWire } from './worker-json.ts'
|
||||
|
||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Busy-time budget in milliseconds: the run fails with kind `'timeout'`
|
||||
* once the worker's MEASURED event-loop active time
|
||||
* (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
|
||||
* measured busy time — not wall time, not host-side pending-call
|
||||
* bookkeeping — is what makes the budget both fair (a program awaiting a
|
||||
* slow tool accrues nothing) and ungameable (a hot loop accrues whether
|
||||
* or not a decoy dispatch is in flight).
|
||||
*/
|
||||
computeMs?: number
|
||||
/**
|
||||
* Wall-clock ceiling in milliseconds; never pauses for anything. The
|
||||
* backstop for what busy-time cannot see (a program awaiting a promise
|
||||
* nobody will resolve). At most `2_147_483_647` (Node's maximum
|
||||
* `setTimeout` delay, about 24.9 days): a longer value is rejected at load
|
||||
* because `setTimeout` would clamp it to 1 ms.
|
||||
*/
|
||||
maxWallMs?: number
|
||||
/**
|
||||
* Hard cap for serialized log-array, completion-value, and failure-message payloads;
|
||||
* fixed result-envelope syntax is excluded.
|
||||
*/
|
||||
maxOutputBytes?: number
|
||||
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
}
|
||||
|
||||
/** {@link Config} after schemastery fills the defaults (every field present). */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* How often the host samples the worker's event-loop utilization for the
|
||||
* `computeMs` budget. An internal cadence, not config: the only effect of
|
||||
* the interval is budget-expiry granularity (a run can overshoot by up to
|
||||
* one interval), and nothing a deployment could tune here improves that
|
||||
* without burning host CPU.
|
||||
*/
|
||||
const ELU_POLL_INTERVAL_MS = 25
|
||||
|
||||
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/**
|
||||
* The seam's language-portable identifier subset (see
|
||||
* `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same
|
||||
* namespace list must be usable against every backend regardless of language.
|
||||
*/
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* The shell a program is wrapped in for the type-strip, matching the
|
||||
* grammatical context it will execute in (an async function body, where
|
||||
* top-level `return` and `await` are legal — a bare module parse would
|
||||
* reject the `return`). Strip mode is position-preserving (removed syntax
|
||||
* becomes whitespace, nothing shifts), so the wrapper survives the strip
|
||||
* byte-identical and the body slices back out with the model's own
|
||||
* line/column positions intact.
|
||||
*/
|
||||
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
|
||||
|
||||
/** One in-flight run's host-side state, tracked for disposal. */
|
||||
interface LiveRun {
|
||||
worker: Worker
|
||||
settle(failure: CodeRunFailure): void
|
||||
finished: Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable
|
||||
* directly on this repo's Node range via native type stripping — the file
|
||||
* is erasable-only with type-only relative imports); the built package
|
||||
* ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown
|
||||
* entry) because pkg's VFS Worker hook compiles string-path entries as
|
||||
* CommonJS.
|
||||
* The URL *pathname*'s extension says which world this module is in —
|
||||
* pathname, because dev-time module runners (vitest) may suffix
|
||||
* `import.meta.url` with a query string; relative resolution drops it. Worker
|
||||
* receives a filesystem string so pkg's VFS Worker hook can resolve it.
|
||||
*/
|
||||
/* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
|
||||
const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url))
|
||||
|
||||
/** Render an unknown thrown value as a message, `Error` or not. */
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Resolve after a worker pipe emits all queued data, or closes/errors during termination. */
|
||||
function waitForPipeDrain(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)
|
||||
// Close the event-registration race if termination finished between the
|
||||
// initial state check and the listeners above.
|
||||
/* v8 ignore next -- this race cannot be scheduled deterministically between the adjacent state check and listener registration. */
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
|
||||
* can post anything — `null`, primitives, objects with poisoned fields — so
|
||||
* the compile-time `WorkerToHost` type means nothing here: everything is
|
||||
* re-validated and REBUILT field by field (a forged extra field never rides
|
||||
* along; a non-number call id can never be echoed into a reply). Junk returns
|
||||
* `undefined` and is dropped — a throw in the host's `message` listener would
|
||||
* crash the host process.
|
||||
*/
|
||||
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined
|
||||
const m = raw as Record<string, unknown>
|
||||
switch (m.type) {
|
||||
case 'call': {
|
||||
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
|
||||
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args as WorkerJsonWire }
|
||||
}
|
||||
case 'log': {
|
||||
if (typeof m.text !== 'string') return undefined
|
||||
return { type: 'log', text: m.text }
|
||||
}
|
||||
case 'output-limit': return { type: 'output-limit' }
|
||||
case 'done': {
|
||||
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value as WorkerJsonWire } : {} }
|
||||
const error = m.error
|
||||
if (typeof error !== 'object' || error === null) return undefined
|
||||
const { kind, message } = error as Record<string, unknown>
|
||||
if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined
|
||||
return { type: 'done', error: { kind, message } }
|
||||
}
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/** Admit one exact log entry, or report that 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 absent-or-JSON completion against the combined cap. */
|
||||
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 a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
|
||||
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 its returned prefix fits the same budget. */
|
||||
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
|
||||
retained.push(prefix)
|
||||
retainedBytes += prefixBytes + separatorBytes
|
||||
}
|
||||
break
|
||||
}
|
||||
const availableMessageBytes = this.maxBytes - retainedBytes
|
||||
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
|
||||
return { logs: retained, error: { kind: 'output-limit', message } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
|
||||
* the `codeRuntime` service; every cap comes from validated config. See the
|
||||
* module doc for the containment model and the Service Definition's class JSDoc for
|
||||
* the contract this implements (error-as-field, hostile-peer port,
|
||||
* no cross-run state, dispose to quiescence).
|
||||
*/
|
||||
export class WorkerThreadCodeRuntime extends CodeRuntime {
|
||||
static Config: z<Config> = z.object({
|
||||
computeMs: z.number().default(60_000),
|
||||
maxWallMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
})
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'worker-thread'
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
private readonly live = new Set<LiveRun>()
|
||||
private disposed = false
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery filled the defaults; the cast records that. Positivity is a
|
||||
// semantic check the schema's plain number type does not carry.
|
||||
this.config = config as ResolvedConfig
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker-thread: config.${key} must be a positive number, got ${String(value)}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`)
|
||||
}
|
||||
// maxWallMs reaches setTimeout, which clamps any delay above
|
||||
// MAX_TIMER_DELAY_MS to 1 ms; the positivity check above accepts such a
|
||||
// value, so a 25-day ceiling would time the run out immediately.
|
||||
if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: config.maxWallMs must be at most ${MAX_TIMER_DELAY_MS} (Node clamps a longer setTimeout delay to 1ms), got ${String(this.config.maxWallMs)}`)
|
||||
}
|
||||
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose to quiescence: mark the service unusable, fail every in-flight
|
||||
* run as aborted, and AWAIT each worker's exit so no worker outlives the
|
||||
* fiber.
|
||||
*/
|
||||
private async teardown(): Promise<void> {
|
||||
this.disposed = true
|
||||
const runs = [...this.live]
|
||||
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
|
||||
await Promise.all(runs.map(run => run.finished))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one program in a fresh worker. Program outcomes — including a
|
||||
* type-strip syntax error, which never spawns a worker — resolve with
|
||||
* `result.error`; the method rejects only for Service Definition contract misuse (a disposed
|
||||
* runtime, an invalid binding namespace).
|
||||
* @param request - the program, its bindings, and the abort signal.
|
||||
* @returns the run's outcome per the seam contract.
|
||||
*/
|
||||
async run(request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
if (this.disposed) throw new Error('dsh-code-runtime-worker-thread: run() after disposal')
|
||||
const bindings = this.validateBindings(request)
|
||||
if (request.signal?.aborted) {
|
||||
return this.failureBeforeWorker({ kind: 'abort', message: String(request.signal.reason) })
|
||||
}
|
||||
|
||||
let code: string
|
||||
try {
|
||||
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
|
||||
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
|
||||
} catch (error: unknown) {
|
||||
// A program that does not survive the type-strip (syntax error,
|
||||
// non-erasable syntax like `enum`) is a program failure, reported the
|
||||
// same way a thrown exception would be — and no worker ever spawns.
|
||||
return this.failureBeforeWorker({ kind: 'exception', message: messageOf(error) })
|
||||
}
|
||||
|
||||
return await this.execute(request, code, bindings)
|
||||
}
|
||||
|
||||
/** Apply the outer-output ledger to failures that occur before a worker owns one. */
|
||||
private failureBeforeWorker(error: CodeRunFailure): CodeRunResult {
|
||||
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
|
||||
}
|
||||
|
||||
/** Reject malformed binding globals or typed-error declarations as Service Definition contract misuse. */
|
||||
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
|
||||
// `console` is THIS backend's log-capture slot; the dunder entries exist
|
||||
// for the Python side — its seeded/wrapped slots plus the `__debug__`
|
||||
// compile-time constant — refused here too so the namespace list stays
|
||||
// portable across backends. The seam declaration is the single home for
|
||||
// why each entry is reserved.
|
||||
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: reserved binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
if (bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: 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) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: reserved binding global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
const member = descriptor.memberNameProperty
|
||||
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
|
||||
throw new Error(`dsh-code-runtime-worker-thread: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/** Spawn the worker for one validated, type-stripped run and drive it to settlement. */
|
||||
private execute(
|
||||
request: CodeRunRequest,
|
||||
code: string,
|
||||
bindings: Map<string, CodeBindingNamespace>,
|
||||
): Promise<CodeRunResult> {
|
||||
const bootData: WorkerBootData = {
|
||||
code,
|
||||
namespaces: [...bindings].map(([global, namespace]) => ({
|
||||
global,
|
||||
names: Object.keys(namespace.functions),
|
||||
...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
|
||||
})),
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
}
|
||||
const worker = new Worker(WORKER_PATH, {
|
||||
workerData: bootData,
|
||||
// Model code gets NO ambient environment — stronger than the scrubbed
|
||||
// env the defensive-patterns rule requires for spawned commands.
|
||||
env: {},
|
||||
// Hermetic flags too: without this the worker inherits the host process's execArgv (a
|
||||
// test runner's or tsx's loader hooks), which a bare isolate with an empty environment
|
||||
// cannot satisfy.
|
||||
execArgv: [],
|
||||
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
|
||||
// Backstop capture: the bootstrap patches JS-level writes into its own
|
||||
// ordered buffer, so these pipes normally stay silent; anything that
|
||||
// still arrives (native-level writes) is appended after the done logs.
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
})
|
||||
|
||||
return new Promise<CodeRunResult>((resolve) => {
|
||||
let settled = false
|
||||
const answered = new Set<number>()
|
||||
const logs: string[] = []
|
||||
const strayLogs: string[] = []
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
let terminalOverride: CodeRunResult | undefined
|
||||
|
||||
// Pipe and message-port delivery are independent. Continue bounded pipe
|
||||
// capture after a terminal message while worker termination drains bytes
|
||||
// that were already queued; `finish` materializes the result only after
|
||||
// termination completes.
|
||||
const captureStray = (chunk: Buffer): void => {
|
||||
/* v8 ignore next -- a second post-overflow chunk races immediate worker termination; the first overflow path is covered. */
|
||||
if (terminalOverride !== undefined) return
|
||||
const text = chunk.toString('utf8')
|
||||
if (!output.admit(text, strayLogs)) {
|
||||
const limited = output.limit([...logs, ...strayLogs, text])
|
||||
terminalOverride = limited
|
||||
finish(limited)
|
||||
}
|
||||
}
|
||||
worker.stdout.on('data', captureStray)
|
||||
worker.stderr.on('data', captureStray)
|
||||
|
||||
// Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker;
|
||||
// logs captured before timeout, abort, or failure remain in the result.
|
||||
let finishResolve!: () => void
|
||||
const finished = new Promise<void>((done) => { finishResolve = done })
|
||||
const finish = (finalize: CodeRunResult | (() => CodeRunResult)): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearInterval(eluTimer)
|
||||
clearTimeout(wallTimer)
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
this.live.delete(live)
|
||||
// Let the poll phase deliver pipe bytes already queued independently
|
||||
// of the terminal port message before termination closes the streams.
|
||||
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(worker.stdout)
|
||||
const stderrDrained = waitForPipeDrain(worker.stderr)
|
||||
await Promise.all([worker.terminate(), stdoutDrained, stderrDrained])
|
||||
const result = terminalOverride ?? (typeof finalize === 'function' ? finalize() : finalize)
|
||||
finishResolve()
|
||||
resolve(result)
|
||||
})
|
||||
}
|
||||
|
||||
const onDone = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'done') return
|
||||
if (message.error) {
|
||||
const error = message.error
|
||||
finish(() => output.failure([...logs, ...strayLogs], error))
|
||||
return
|
||||
}
|
||||
if (message.value === undefined) {
|
||||
finish(() => output.success([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
const value = decodeWorkerJson(message.value)
|
||||
if (value === undefined) {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
|
||||
} else {
|
||||
finish(() => output.success([...logs, ...strayLogs], value))
|
||||
}
|
||||
}
|
||||
|
||||
const onCall = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'call' || settled) return
|
||||
// Hostile-peer rules: a duplicate id is ignored, an unknown name is
|
||||
// answered with a failure, and a binding throw/reject becomes the
|
||||
// program-side rejection — contained here, never a host crash.
|
||||
if (answered.has(message.id)) return
|
||||
answered.add(message.id)
|
||||
const reply = (payload: ReplyMessage): void => {
|
||||
if (settled) return
|
||||
// Canonical resolutions were snapshotted as lossless JSON before
|
||||
// this point, so this payload is structured-cloneable by contract.
|
||||
worker.postMessage(payload)
|
||||
}
|
||||
const record = bindings.get(message.global)?.functions
|
||||
// Own-property lookup only: a forged name like 'constructor' or
|
||||
// 'hasOwnProperty' must not walk the record's prototype chain and
|
||||
// reach a callable the consumer never declared.
|
||||
const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
|
||||
if (typeof fn !== 'function') {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
|
||||
return
|
||||
}
|
||||
const args = decodeWorkerJson(message.args)
|
||||
if (args === undefined) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await fn(args)
|
||||
let value: CodeJsonValue | undefined
|
||||
try {
|
||||
value = snapshotJsonValue(resolved)
|
||||
} catch {
|
||||
value = undefined
|
||||
}
|
||||
if (value === undefined) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
|
||||
} else {
|
||||
reply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
worker.on('message', (raw: unknown) => {
|
||||
// Parse before touching: the peer can post ANY shape, and a throw in
|
||||
// this listener would crash the host process. Junk drops silently.
|
||||
const message = parseWorkerMessage(raw)
|
||||
if (!message) return
|
||||
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
|
||||
const limited = output.limit([...logs, ...strayLogs, message.text])
|
||||
finish(limited)
|
||||
return
|
||||
}
|
||||
if (message.type === 'output-limit' && !settled) {
|
||||
const limited = output.limit([...logs, ...strayLogs])
|
||||
finish(limited)
|
||||
return
|
||||
}
|
||||
onCall(message)
|
||||
onDone(message)
|
||||
})
|
||||
worker.on('error', (error: Error) => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
|
||||
})
|
||||
worker.on('exit', (exitCode: number) => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
|
||||
})
|
||||
|
||||
// The compute budget reads the worker's own measured busy time, so a
|
||||
// hot loop expires it no matter what dispatches are in flight, while a
|
||||
// program idling on a slow binding accrues nothing.
|
||||
const eluTimer = setInterval(() => {
|
||||
const elu = worker.performance.eventLoopUtilization()
|
||||
if (elu.active > this.config.computeMs) {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
|
||||
}
|
||||
}, ELU_POLL_INTERVAL_MS)
|
||||
const wallTimer = setTimeout(() => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
}, this.config.maxWallMs)
|
||||
const onAbort = (): void => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
}
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const live: LiveRun = {
|
||||
worker,
|
||||
finished,
|
||||
settle: (failure: CodeRunFailure) => { finish(() => output.failure([...logs, ...strayLogs], failure)) },
|
||||
}
|
||||
this.live.add(live)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkerThreadCodeRuntime
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-worker-thread`.
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker-thread/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker-thread'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'code-runtime-worker-thread-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this process-boundary implementation exposes no same-process event relation;
|
||||
* worker protocol and built-worker tests cover it.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,179 @@
|
||||
/** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker-thread/output-json */
|
||||
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown
|
||||
|
||||
const intrinsicReflectApply = Reflect.apply as (
|
||||
target: IntrinsicCallable,
|
||||
thisArgument: unknown,
|
||||
argumentsList: readonly unknown[],
|
||||
) => unknown
|
||||
const intrinsicArrayIsArray = Array.isArray
|
||||
const IntrinsicBuffer = Buffer
|
||||
const intrinsicBufferByteLength = Reflect.get(Buffer, 'byteLength') as IntrinsicCallable
|
||||
const intrinsicObjectCreate = Object.create
|
||||
const intrinsicObjectDefineProperty = Object.defineProperty
|
||||
const intrinsicObjectKeys = Object.keys
|
||||
const intrinsicString = String
|
||||
const intrinsicStringCharCodeAt = Reflect.get(String.prototype, 'charCodeAt') as IntrinsicCallable
|
||||
const intrinsicStringCodePointAt = Reflect.get(String.prototype, 'codePointAt') as IntrinsicCallable
|
||||
const intrinsicStringSlice = Reflect.get(String.prototype, 'slice') as IntrinsicCallable
|
||||
|
||||
/** Build a data descriptor that cannot inherit model-defined accessor fields. */
|
||||
function dataDescriptor(value: unknown): PropertyDescriptor {
|
||||
const descriptor = intrinsicObjectCreate(null) as PropertyDescriptor
|
||||
descriptor.value = value
|
||||
return descriptor
|
||||
}
|
||||
|
||||
/** Define an ordinary enumerable data slot without a prototype-bearing descriptor. */
|
||||
function defineEnumerableDataProperty(target: object, key: PropertyKey, value: unknown): void {
|
||||
const descriptor = dataDescriptor(value)
|
||||
descriptor.enumerable = true
|
||||
descriptor.configurable = true
|
||||
descriptor.writable = true
|
||||
intrinsicObjectDefineProperty(target, key, descriptor)
|
||||
}
|
||||
|
||||
/** UTF-8 byte length through the module-captured Node intrinsic. */
|
||||
function byteLength(text: string): number {
|
||||
return intrinsicReflectApply(intrinsicBufferByteLength, IntrinsicBuffer, [text, 'utf8']) as number
|
||||
}
|
||||
|
||||
/** Append without consulting a model-mutated `Array.prototype`. */
|
||||
function append<T>(target: T[], value: T): void {
|
||||
defineEnumerableDataProperty(target, target.length, value)
|
||||
}
|
||||
|
||||
/** Pop without consulting a model-mutated `Array.prototype`. */
|
||||
function takeLast<T>(target: T[]): T | undefined {
|
||||
if (target.length === 0) return undefined
|
||||
const index = target.length - 1
|
||||
const value = target[index]
|
||||
intrinsicObjectDefineProperty(target, 'length', dataDescriptor(index))
|
||||
return value
|
||||
}
|
||||
|
||||
/** One code-point-aligned character from a string. */
|
||||
function characterAt(text: string, index: number): string {
|
||||
const codePoint = intrinsicReflectApply(intrinsicStringCodePointAt, text, [index]) as number
|
||||
const width = codePoint > 0xffff ? 2 : 1
|
||||
return intrinsicReflectApply(intrinsicStringSlice, text, [index, index + width]) as string
|
||||
}
|
||||
|
||||
/** Serialized bytes contributed by one complete Unicode code point inside JSON quotes. */
|
||||
function serializedCharacterBytes(character: string): number {
|
||||
if (character.length === 2) return 4
|
||||
if (character === '"' || character === '\\') return 2
|
||||
const code = intrinsicReflectApply(intrinsicStringCharCodeAt, character, [0]) as number
|
||||
if (code >= 0xd800 && code <= 0xdfff) return 6
|
||||
if (code < 0x20) return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
|
||||
return byteLength(character)
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure one JSON string without materializing its complete escaped form.
|
||||
* @param text - the candidate string.
|
||||
* @param maxBytes - largest serialized size the caller can admit.
|
||||
* @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
|
||||
*/
|
||||
export function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined {
|
||||
if (maxBytes < 2) return undefined
|
||||
let bytes = 2
|
||||
for (let index = 0; index < text.length;) {
|
||||
const character = characterAt(text, index)
|
||||
bytes += serializedCharacterBytes(character)
|
||||
if (bytes > maxBytes) return undefined
|
||||
index += character.length
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure one lossless JSON value without allocating its serialized form.
|
||||
* @param value - already validated lossless JSON.
|
||||
* @param maxBytes - largest serialized size the caller can admit.
|
||||
* @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
|
||||
*/
|
||||
export function jsonValueBytesUpTo(value: CodeJsonValue, maxBytes: number): number | undefined {
|
||||
type Task =
|
||||
| { kind: 'value'; value: CodeJsonValue }
|
||||
| { kind: 'array'; value: CodeJsonValue[]; index: number }
|
||||
| { kind: 'object'; value: Record<string, CodeJsonValue>; keys: string[]; index: number }
|
||||
|
||||
let bytes = 0
|
||||
const add = (cost: number): boolean => {
|
||||
bytes += cost
|
||||
return bytes <= maxBytes
|
||||
}
|
||||
const tasks: Task[] = [{ kind: 'value', value }]
|
||||
for (let task = takeLast(tasks); task !== undefined; task = takeLast(tasks)) {
|
||||
if (task.kind === 'value') {
|
||||
const current = task.value
|
||||
if (current === null) {
|
||||
if (!add(4)) return undefined
|
||||
} else if (typeof current === 'string') {
|
||||
const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes)
|
||||
if (stringBytes === undefined) return undefined
|
||||
bytes += stringBytes
|
||||
} else if (typeof current === 'number') {
|
||||
if (!add(byteLength(intrinsicString(current)))) return undefined
|
||||
} else if (typeof current === 'boolean') {
|
||||
if (!add(current ? 4 : 5)) return undefined
|
||||
} else if (intrinsicArrayIsArray(current)) {
|
||||
if (!add(2)) return undefined
|
||||
if (current.length > 0) append(tasks, { kind: 'array', value: current, index: 0 })
|
||||
} else {
|
||||
if (!add(2)) return undefined
|
||||
const keys = intrinsicObjectKeys(current)
|
||||
if (keys.length > 0) append(tasks, { kind: 'object', value: current, keys, index: 0 })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.index > 0 && !add(1)) return undefined
|
||||
if (task.kind === 'array') {
|
||||
const item = task.value[task.index]
|
||||
if (item === undefined) return undefined
|
||||
if (task.index + 1 < task.value.length) append(tasks, { ...task, index: task.index + 1 })
|
||||
append(tasks, { kind: 'value', value: item })
|
||||
continue
|
||||
}
|
||||
|
||||
const key = task.keys[task.index]
|
||||
/* v8 ignore next -- an object frame is created and advanced only for an existing Object.keys entry. */
|
||||
if (key === undefined) return undefined
|
||||
const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
|
||||
if (keyBytes === undefined) return undefined
|
||||
if (!add(keyBytes + 1)) return undefined
|
||||
const item = task.value[key]
|
||||
if (item === undefined) return undefined
|
||||
if (task.index + 1 < task.keys.length) append(tasks, { ...task, index: task.index + 1 })
|
||||
append(tasks, { kind: 'value', value: item })
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the longest code-point-aligned prefix whose JSON string encoding,
|
||||
* including its surrounding quotes, fits `maxBytes`.
|
||||
*
|
||||
* @param text - the candidate string.
|
||||
* @param maxBytes - serialized JSON-string bytes available.
|
||||
* @returns the fitting prefix, or an empty string when even useful content cannot fit.
|
||||
*/
|
||||
export function truncateJsonStringBytes(text: string, maxBytes: number): string {
|
||||
if (maxBytes < 2) return ''
|
||||
let bytes = 2
|
||||
let end = 0
|
||||
for (let index = 0; index < text.length;) {
|
||||
const character = characterAt(text, index)
|
||||
const cost = serializedCharacterBytes(character)
|
||||
if (bytes + cost > maxBytes) break
|
||||
bytes += cost
|
||||
end += character.length
|
||||
index += character.length
|
||||
}
|
||||
return end === text.length ? text : intrinsicReflectApply(intrinsicStringSlice, text, [0, end]) as string
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Versionless, structured-clone wire protocol between co-shipped host and worker code. The host
|
||||
* treats inbound traffic as hostile because model code can forge `parentPort` messages; the
|
||||
* worker trusts host replies.
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker-thread/src/protocol
|
||||
*/
|
||||
|
||||
import type { WorkerJsonWire } from './worker-json.ts'
|
||||
|
||||
/** What the host hands the worker at spawn, via `workerData`. */
|
||||
export interface WorkerBootData {
|
||||
/** The type-stripped (plain JS) program body. */
|
||||
code: string
|
||||
/** Binding namespaces to materialize; functions themselves stay host-side. */
|
||||
namespaces: {
|
||||
global: string
|
||||
names: string[]
|
||||
errorClass?: { name: string; memberNameProperty: string }
|
||||
}[]
|
||||
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
|
||||
maxOutputBytes: number
|
||||
}
|
||||
|
||||
/** Worker → host: one bridged binding call. */
|
||||
interface CallMessage {
|
||||
type: 'call'
|
||||
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
|
||||
id: number
|
||||
/** The namespace global the call targets. */
|
||||
global: string
|
||||
/** The function name within the namespace. */
|
||||
name: string
|
||||
/** The single argument as a flat lossless-JSON wire value. */
|
||||
args: WorkerJsonWire
|
||||
}
|
||||
|
||||
/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
|
||||
interface LogMessage {
|
||||
type: 'log'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */
|
||||
interface OutputLimitMessage {
|
||||
type: 'output-limit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker → host: the program settled. `error` carries a program exception,
|
||||
* invalid completion, or output overflow (budgets, aborts, and substrate death
|
||||
* are observed host-side). `value` is present only on a clean completion that
|
||||
* produced one, as a flat wire value already lossless and admitted against
|
||||
* the remaining combined output cap. Logs are NOT carried here — they streamed
|
||||
* eagerly as {@link LogMessage}s.
|
||||
*/
|
||||
export interface DoneMessage {
|
||||
type: 'done'
|
||||
value?: WorkerJsonWire
|
||||
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
|
||||
}
|
||||
|
||||
/** Every message the worker sends. */
|
||||
export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
|
||||
|
||||
/** Host → worker: the answer to one {@link CallMessage}. */
|
||||
export type ReplyMessage =
|
||||
| { type: 'reply'; id: number; ok: true; value: WorkerJsonWire }
|
||||
| { type: 'reply'; id: number; ok: false; message: string }
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Lossless-JSON snapshots for the dependency-free source worker closure.
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker-thread/worker-json
|
||||
*/
|
||||
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */
|
||||
type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown
|
||||
|
||||
const intrinsicFunctionToString = Reflect.get(Function.prototype, 'toString') as IntrinsicCallable
|
||||
const intrinsicReflectApply = Reflect.get(Reflect, 'apply') as (
|
||||
target: IntrinsicCallable,
|
||||
thisArgument: unknown,
|
||||
argumentsList: readonly unknown[],
|
||||
) => unknown
|
||||
const IntrinsicError = Error
|
||||
const IntrinsicSet = Set
|
||||
const intrinsicArrayIsArray = Array.isArray
|
||||
const intrinsicArrayPrototype = Array.prototype
|
||||
const intrinsicNumberIsFinite = Number.isFinite
|
||||
const intrinsicNumberIsSafeInteger = Number.isSafeInteger
|
||||
const intrinsicObjectCreate = Object.create
|
||||
const intrinsicObjectDefineProperty = Object.defineProperty
|
||||
const intrinsicObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor
|
||||
const intrinsicObjectGetPrototypeOf = Object.getPrototypeOf
|
||||
const intrinsicObjectHasOwn = Object.hasOwn
|
||||
const intrinsicObjectIs = Object.is
|
||||
const intrinsicObjectKeys = Object.keys
|
||||
const intrinsicObjectPrototype = Object.prototype
|
||||
const intrinsicObjectPropertyIsEnumerable = Reflect.get(intrinsicObjectPrototype, 'propertyIsEnumerable') as IntrinsicCallable
|
||||
const intrinsicReflectOwnKeys = Reflect.ownKeys
|
||||
const intrinsicSetAdd = Reflect.get(Set.prototype, 'add') as IntrinsicCallable
|
||||
const intrinsicSetDelete = Reflect.get(Set.prototype, 'delete') as IntrinsicCallable
|
||||
const intrinsicSetHas = Reflect.get(Set.prototype, 'has') as IntrinsicCallable
|
||||
|
||||
/** Build a data descriptor that cannot inherit model-defined accessor fields. */
|
||||
function dataDescriptor(value: unknown): PropertyDescriptor {
|
||||
const descriptor = intrinsicObjectCreate(null) as PropertyDescriptor
|
||||
descriptor.value = value
|
||||
return descriptor
|
||||
}
|
||||
|
||||
/** Define an ordinary enumerable data slot without a prototype-bearing descriptor. */
|
||||
function defineEnumerableDataProperty(target: object, key: PropertyKey, value: unknown): void {
|
||||
const descriptor = dataDescriptor(value)
|
||||
descriptor.enumerable = true
|
||||
descriptor.configurable = true
|
||||
descriptor.writable = true
|
||||
intrinsicObjectDefineProperty(target, key, descriptor)
|
||||
}
|
||||
|
||||
/** Append without consulting a model-mutated `Array.prototype`. */
|
||||
function append<T>(target: T[], value: T): void {
|
||||
defineEnumerableDataProperty(target, target.length, value)
|
||||
}
|
||||
|
||||
/** Pop without consulting a model-mutated `Array.prototype`. */
|
||||
function takeLast<T>(target: T[]): T | undefined {
|
||||
if (target.length === 0) return undefined
|
||||
const index = target.length - 1
|
||||
const value = target[index]
|
||||
intrinsicObjectDefineProperty(target, 'length', dataDescriptor(index))
|
||||
return value
|
||||
}
|
||||
|
||||
/** Whether one captured-intrinsic Set contains a value. */
|
||||
function setHas<T>(target: Set<T>, value: T): boolean {
|
||||
return intrinsicReflectApply(intrinsicSetHas, target, [value]) as boolean
|
||||
}
|
||||
|
||||
/** Add to one captured-intrinsic Set. */
|
||||
function setAdd<T>(target: Set<T>, value: T): void {
|
||||
intrinsicReflectApply(intrinsicSetAdd, target, [value])
|
||||
}
|
||||
|
||||
/** Delete from one captured-intrinsic Set. */
|
||||
function setDelete<T>(target: Set<T>, value: T): void {
|
||||
intrinsicReflectApply(intrinsicSetDelete, target, [value])
|
||||
}
|
||||
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = intrinsicObjectGetOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
if (typeof constructor !== 'function') return false
|
||||
try {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& intrinsicReflectApply(intrinsicFunctionToString, constructor, []) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a candidate is a foreign realm's intrinsic `Object.prototype`. */
|
||||
function isForeignIntrinsicObjectPrototype(value: object): boolean {
|
||||
return intrinsicObjectGetPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
|
||||
}
|
||||
|
||||
/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
|
||||
function hasPlainArrayPrototype(value: unknown[]): boolean {
|
||||
const prototype: unknown = intrinsicObjectGetPrototypeOf(value)
|
||||
if (prototype === intrinsicArrayPrototype) return true
|
||||
if (!intrinsicArrayIsArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
|
||||
const objectPrototype: unknown = intrinsicObjectGetPrototypeOf(prototype)
|
||||
return typeof objectPrototype === 'object'
|
||||
&& objectPrototype !== null
|
||||
&& isForeignIntrinsicObjectPrototype(objectPrototype)
|
||||
}
|
||||
|
||||
/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
|
||||
function hasPlainObjectPrototype(value: object): boolean {
|
||||
const prototype: unknown = intrinsicObjectGetPrototypeOf(value)
|
||||
return prototype === null
|
||||
|| prototype === intrinsicObjectPrototype
|
||||
|| typeof prototype === 'object' && isForeignIntrinsicObjectPrototype(prototype)
|
||||
}
|
||||
|
||||
/** Return every JSON-visible object key, or reject own data JSON would discard. */
|
||||
function enumerableStringKeys(value: object): string[] | undefined {
|
||||
const keys = intrinsicReflectOwnKeys(value)
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
const key = keys[index]
|
||||
if (typeof key !== 'string' || !intrinsicReflectApply(intrinsicObjectPropertyIsEnumerable, value, [key])) return undefined
|
||||
}
|
||||
return keys as string[]
|
||||
}
|
||||
|
||||
type SnapshotDestination =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'array'; target: CodeJsonValue[]; index: number }
|
||||
| { kind: 'object'; target: Record<string, CodeJsonValue>; key: string }
|
||||
|
||||
type SnapshotTask =
|
||||
| { kind: 'visit'; value: unknown; destination: SnapshotDestination }
|
||||
| { kind: 'array-item'; source: unknown[]; index: number; target: CodeJsonValue[] }
|
||||
| { kind: 'object-property'; source: Record<string, unknown>; key: string; target: Record<string, CodeJsonValue> }
|
||||
| { kind: 'leave'; source: object }
|
||||
|
||||
/**
|
||||
* Validate and detach one worker-boundary value without loading another
|
||||
* workspace package at runtime. This mirrors the session-owned canonical
|
||||
* JSON boundary while remaining safe to import from the unbuilt worker.
|
||||
* Its iterative traversal adds no JavaScript call-stack depth limit.
|
||||
*
|
||||
* @param value - the candidate completion value.
|
||||
* @returns a detached lossless-JSON snapshot, or `undefined` when invalid.
|
||||
*/
|
||||
export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined {
|
||||
const active = new IntrinsicSet<object>()
|
||||
let root: CodeJsonValue | undefined
|
||||
const assign = (destination: SnapshotDestination, item: CodeJsonValue): void => {
|
||||
if (destination.kind === 'root') {
|
||||
root = item
|
||||
} else if (destination.kind === 'array') {
|
||||
defineEnumerableDataProperty(destination.target, destination.index, item)
|
||||
} else {
|
||||
defineEnumerableDataProperty(destination.target, destination.key, item)
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: SnapshotTask[] = [{ kind: 'visit', value, destination: { kind: 'root' } }]
|
||||
for (let task = takeLast(tasks); task !== undefined; task = takeLast(tasks)) {
|
||||
if (task.kind === 'leave') {
|
||||
setDelete(active, task.source)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'array-item') {
|
||||
if (!intrinsicObjectHasOwn(task.source, task.index)) return undefined
|
||||
append(tasks, {
|
||||
kind: 'visit',
|
||||
value: task.source[task.index],
|
||||
destination: { kind: 'array', target: task.target, index: task.index },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'object-property') {
|
||||
append(tasks, {
|
||||
kind: 'visit',
|
||||
value: task.source[task.key],
|
||||
destination: { kind: 'object', target: task.target, key: task.key },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const candidate = task.value
|
||||
if (candidate === null) {
|
||||
assign(task.destination, null)
|
||||
continue
|
||||
}
|
||||
if (typeof candidate === 'boolean' || typeof candidate === 'string') {
|
||||
assign(task.destination, candidate)
|
||||
continue
|
||||
}
|
||||
if (typeof candidate === 'number') {
|
||||
if (!intrinsicNumberIsFinite(candidate) || intrinsicObjectIs(candidate, -0)) return undefined
|
||||
assign(task.destination, candidate)
|
||||
continue
|
||||
}
|
||||
if (typeof candidate !== 'object') return undefined
|
||||
if (setHas(active, candidate)) return undefined
|
||||
|
||||
if (intrinsicArrayIsArray(candidate)) {
|
||||
if (!hasPlainArrayPrototype(candidate)) return undefined
|
||||
const length = candidate.length
|
||||
if (intrinsicReflectOwnKeys(candidate).length !== length + 1) return undefined
|
||||
const target: CodeJsonValue[] = []
|
||||
assign(task.destination, target)
|
||||
setAdd(active, candidate)
|
||||
append(tasks, { kind: 'leave', source: candidate })
|
||||
for (let index = length - 1; index >= 0; index--) {
|
||||
append(tasks, { kind: 'array-item', source: candidate, index, target })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hasPlainObjectPrototype(candidate)) return undefined
|
||||
const keys = enumerableStringKeys(candidate)
|
||||
if (keys === undefined) return undefined
|
||||
const target: Record<string, CodeJsonValue> = {}
|
||||
assign(task.destination, target)
|
||||
setAdd(active, candidate)
|
||||
append(tasks, { kind: 'leave', source: candidate })
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
||||
if (key === undefined) return undefined
|
||||
append(tasks, { kind: 'object-property', source: candidate as Record<string, unknown>, key, target })
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
interface ArrayWireToken {
|
||||
kind: 'array'
|
||||
length: number
|
||||
}
|
||||
|
||||
interface ObjectWireToken {
|
||||
kind: 'object'
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
type WorkerJsonToken = null | boolean | number | string | ArrayWireToken | ObjectWireToken
|
||||
|
||||
/**
|
||||
* A pre-order, bounded-depth transport for one lossless JSON value. Container
|
||||
* markers and scalar leaves share one flat token array, so `worker_threads`
|
||||
* never has to structured-clone the value's application nesting.
|
||||
*/
|
||||
export type WorkerJsonWire = WorkerJsonToken[]
|
||||
|
||||
/**
|
||||
* Flatten one validated JSON value for the worker-thread message port.
|
||||
* @param value - the lossless JSON value to transport.
|
||||
* @returns a pre-order token stream whose own nesting is bounded.
|
||||
*/
|
||||
export function encodeWorkerJson(value: CodeJsonValue): WorkerJsonWire {
|
||||
const wire: WorkerJsonWire = []
|
||||
const pending: CodeJsonValue[] = [value]
|
||||
for (let current = takeLast(pending); current !== undefined; current = takeLast(pending)) {
|
||||
if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') {
|
||||
append(wire, current)
|
||||
continue
|
||||
}
|
||||
if (intrinsicArrayIsArray(current)) {
|
||||
append(wire, { kind: 'array', length: current.length })
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
const item = current[index]
|
||||
if (item === undefined) throw new IntrinsicError('cannot encode a sparse JSON array')
|
||||
append(pending, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const keys = intrinsicObjectKeys(current)
|
||||
append(wire, { kind: 'object', keys })
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
||||
if (key === undefined) throw new IntrinsicError('cannot encode a missing JSON object key')
|
||||
const item = current[key]
|
||||
if (item === undefined) throw new IntrinsicError('cannot encode an undefined JSON object property')
|
||||
append(pending, item)
|
||||
}
|
||||
}
|
||||
return wire
|
||||
}
|
||||
|
||||
type DecodeFrame =
|
||||
| { kind: 'array'; target: CodeJsonValue[]; length: number; index: number }
|
||||
| { kind: 'object'; target: Record<string, CodeJsonValue>; keys: string[]; index: number }
|
||||
|
||||
/** Whether an array contains exactly its dense indexed slots and `length`. */
|
||||
function isDenseArray(value: unknown[]): boolean {
|
||||
if (!hasPlainArrayPrototype(value) || intrinsicReflectOwnKeys(value).length !== value.length + 1) return false
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!intrinsicObjectHasOwn(value, index)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether one exact string-key list contains a key, without consulting its prototype. */
|
||||
function keysContain(keys: string[], expected: string): boolean {
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
if (keys[index] === expected) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Return one exact container marker, or reject any extra/missing fields. */
|
||||
function containerToken(value: object): ArrayWireToken | ObjectWireToken | undefined {
|
||||
if (intrinsicArrayIsArray(value) || !hasPlainObjectPrototype(value)) return undefined
|
||||
const keys = enumerableStringKeys(value)
|
||||
if (keys === undefined) return undefined
|
||||
const token = value as Record<string, unknown>
|
||||
if (token.kind === 'array') {
|
||||
if (keys.length !== 2 || !keysContain(keys, 'kind') || !keysContain(keys, 'length')) return undefined
|
||||
const length = token.length
|
||||
return typeof length === 'number' && intrinsicNumberIsSafeInteger(length) && length >= 0
|
||||
? { kind: 'array', length }
|
||||
: undefined
|
||||
}
|
||||
if (token.kind === 'object') {
|
||||
if (keys.length !== 2 || !keysContain(keys, 'kind') || !keysContain(keys, 'keys')) return undefined
|
||||
const objectKeys = token.keys
|
||||
if (!intrinsicArrayIsArray(objectKeys) || !isDenseArray(objectKeys)) return undefined
|
||||
const unique = new IntrinsicSet<string>()
|
||||
const normalizedKeys: string[] = []
|
||||
const objectKeyValues = objectKeys as unknown[]
|
||||
for (let index = 0; index < objectKeyValues.length; index++) {
|
||||
const key = objectKeyValues[index]
|
||||
if (typeof key !== 'string' || setHas(unique, key)) return undefined
|
||||
setAdd(unique, key)
|
||||
append(normalizedKeys, key)
|
||||
}
|
||||
return { kind: 'object', keys: normalizedKeys }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild one lossless JSON value from the flat worker-thread wire format.
|
||||
* Malformed or incomplete traffic returns `undefined`; traversal is iterative
|
||||
* and therefore independent of the transported value's application depth.
|
||||
* @param input - untrusted message-port payload.
|
||||
* @returns the detached JSON value, or `undefined` when the wire is invalid.
|
||||
*/
|
||||
export function decodeWorkerJson(input: unknown): CodeJsonValue | undefined {
|
||||
try {
|
||||
if (!intrinsicArrayIsArray(input) || !isDenseArray(input) || input.length === 0) return undefined
|
||||
const wire = input as unknown[]
|
||||
const frames: DecodeFrame[] = []
|
||||
let root: CodeJsonValue | undefined
|
||||
let rootAssigned = false
|
||||
|
||||
const attach = (value: CodeJsonValue): boolean => {
|
||||
const parent = frames[frames.length - 1]
|
||||
if (!parent) {
|
||||
if (rootAssigned) return false
|
||||
root = value
|
||||
rootAssigned = true
|
||||
return true
|
||||
}
|
||||
/* v8 ignore next -- completed frames are popped before another token can attach. */
|
||||
if (parent.index >= (parent.kind === 'array' ? parent.length : parent.keys.length)) return false
|
||||
if (parent.kind === 'array') {
|
||||
append(parent.target, value)
|
||||
} else {
|
||||
const key = parent.keys[parent.index]
|
||||
/* v8 ignore next -- object frames are built from validated keys and their exact length. */
|
||||
if (key === undefined) return false
|
||||
defineEnumerableDataProperty(parent.target, key, value)
|
||||
}
|
||||
parent.index += 1
|
||||
return true
|
||||
}
|
||||
|
||||
for (let tokenIndex = 0; tokenIndex < wire.length; tokenIndex++) {
|
||||
const token = wire[tokenIndex]
|
||||
let value: CodeJsonValue
|
||||
let frame: DecodeFrame | undefined
|
||||
if (token === null || typeof token === 'boolean' || typeof token === 'string') {
|
||||
value = token
|
||||
} else if (typeof token === 'number') {
|
||||
if (!intrinsicNumberIsFinite(token) || intrinsicObjectIs(token, -0)) return undefined
|
||||
value = token
|
||||
} else {
|
||||
if (typeof token !== 'object') return undefined
|
||||
const marker = containerToken(token)
|
||||
if (!marker) return undefined
|
||||
const remainingTokens = wire.length - tokenIndex - 1
|
||||
if (marker.kind === 'array') {
|
||||
if (marker.length > remainingTokens) return undefined
|
||||
const target: CodeJsonValue[] = []
|
||||
value = target
|
||||
if (marker.length > 0) frame = { kind: 'array', target, length: marker.length, index: 0 }
|
||||
} else {
|
||||
if (marker.keys.length > remainingTokens) return undefined
|
||||
const target: Record<string, CodeJsonValue> = {}
|
||||
value = target
|
||||
if (marker.keys.length > 0) frame = { kind: 'object', target, keys: marker.keys, index: 0 }
|
||||
}
|
||||
}
|
||||
if (!attach(value)) return undefined
|
||||
if (frame) append(frames, frame)
|
||||
while (frames.length > 0) {
|
||||
const current = frames[frames.length - 1]
|
||||
/* v8 ignore next -- the loop condition guarantees a final frame. */
|
||||
if (current === undefined) break
|
||||
if (current.index < (current.kind === 'array' ? current.length : current.keys.length)) break
|
||||
takeLast(frames)
|
||||
}
|
||||
}
|
||||
return frames.length === 0 ? root : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Spawn-only worker entrypoint over {@link runWorkerMain}. Executable logic stays in
|
||||
* `bootstrap.ts` for in-process coverage; real-worker tests cover this glue.
|
||||
* @module @deepseek-ai/dsh-code-runtime-worker-thread/src/worker
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { runWorkerMain } from './bootstrap.ts'
|
||||
import type { WorkerBootData } from './protocol.ts'
|
||||
|
||||
// A worker always has a parent port; guard loudly rather than run detached.
|
||||
if (!parentPort) throw new Error('dsh-code-runtime-worker-thread: worker entry loaded outside a worker thread')
|
||||
|
||||
void runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })
|
||||
Reference in New Issue
Block a user