fix(ptc-runtime-node): validate Script evaluation and process lifecycle
This commit is contained in:
1 parent
16f0e2fc69
commit
86fcf49a74
19 files changed
+390
-123
No files matched your search
@@ -36,8 +36,8 @@
|
||||
en: 50e6cd38e298da31
|
||||
zh: c8b252b3acf59147
|
||||
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control:
|
||||
en: 53e24b6ba2614d4c
|
||||
zh: 0fe4d5ace9d2ef75
|
||||
en: 6c85846d1c8bfe5e
|
||||
zh: c4dcb38181acdd32
|
||||
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps:
|
||||
en: 735eb081e90834a6
|
||||
zh: a657d9ef17be9871
|
||||
|
||||
@@ -123,7 +123,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
|
||||
|
||||
The host strips erasable types and, when starting a process, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
|
||||
|
||||
Length-framed JSON travels separately from stdout/stderr. Every evaluation carries its identity, cwd, syntax and binding declarations through the same run protocol. The host bounds frames and queued writes, validates call identity and binding lookup before dispatch, and refuses invalid traffic. The child keeps the channel open between evaluations until the host closes it. Later binding replies are ignored after an evaluation's terminal frame; its callbacks cannot emit further binding calls or captured logs. `JsonChannel` owns write tracking and drainage; decoded replies settle binding calls directly. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
|
||||
Length-framed JSON travels separately from stdout/stderr. Every evaluation carries its identity, cwd, syntax and binding declarations through the same run protocol. The host bounds frames and queued writes, validates call identity and binding lookup before dispatch, and refuses invalid traffic. The child keeps the channel open between evaluations until the host closes it. Host shutdown before the first evaluation is a normal close; malformed startup traffic reports its original protocol error even if closing interrupts the readiness write. Later binding replies are ignored after an evaluation's terminal frame; its callbacks cannot emit further binding calls or captured logs. `JsonChannel` owns write tracking and drainage; decoded replies settle binding calls directly. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
|
||||
|
||||
### Source and built bootstraps
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
|
||||
|
||||
Host 擦除可擦除类型;需要启动进程时,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
|
||||
|
||||
带长度分帧的 JSON 与 stdout/stderr 分开传输。每次执行都通过同一种 run 协议携带标识、cwd、语法和绑定声明。Host 限制帧与排队写入,在分派前验证调用身份和绑定查找,并拒绝无效通信。子进程在执行间保持通道打开,直到 Host 关闭通道。提交某次执行的终态帧后,子进程忽略该次执行后续的绑定回复,其回调也不能再发送绑定调用或捕获的日志。`JsonChannel` 统一跟踪写入并等待排空;解码后的回复直接结算绑定调用。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
带长度分帧的 JSON 与 stdout/stderr 分开传输。每次执行都通过同一种 run 协议携带标识、cwd、语法和绑定声明。Host 限制帧与排队写入,在分派前验证调用身份和绑定查找,并拒绝无效通信。子进程在执行间保持通道打开,直到 Host 关闭通道。首次执行前的 Host 关闭属于正常结束;启动通信格式无效时,即使关闭通道中断了就绪帧写入,也保留原始协议错误。提交某次执行的终态帧后,子进程忽略该次执行后续的绑定回复,其回调也不能再发送绑定调用或捕获的日志。`JsonChannel` 统一跟踪写入并等待排空;解码后的回复直接结算绑定调用。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
|
||||
### 源代码与构建后 bootstrap
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
import { Parser } from 'acorn'
|
||||
import type { AnyNode, BlockStatement, Pattern, Position, Program, Statement, VariableDeclarator } from 'acorn'
|
||||
import type { AnyNode, BlockStatement, ExpressionStatement, Identifier, Pattern, Position, Program, Statement, VariableDeclarator } from 'acorn'
|
||||
import * as walk from 'acorn-walk'
|
||||
|
||||
interface TransformState {
|
||||
@@ -48,10 +48,11 @@ const noop = (): void => {}
|
||||
const base = walk.base as Required<walk.RecursiveVisitors<TransformState>>
|
||||
const visitorsWithoutAncestors: walk.RecursiveVisitors<TransformState> = {
|
||||
ClassDeclaration(node, state, c) {
|
||||
if (node.id === null) throw new SyntaxError('Script declarations must be named')
|
||||
// Anonymous declarations are invalid inside the parsed async body.
|
||||
const name = (node.id as Identifier).name
|
||||
if (isTopLevelDeclaration(state)) {
|
||||
state.prepend(node, `${node.id.name}=`)
|
||||
state.hoistedDeclarationStatements.push(`let ${node.id.name}; `)
|
||||
state.prepend(node, `${name}=`)
|
||||
state.hoistedDeclarationStatements.push(`let ${name}; `)
|
||||
}
|
||||
|
||||
base.ClassDeclaration(node, state, c)
|
||||
@@ -63,9 +64,10 @@ const visitorsWithoutAncestors: walk.RecursiveVisitors<TransformState> = {
|
||||
base.ForOfStatement(node, state, c)
|
||||
},
|
||||
FunctionDeclaration(node, state) {
|
||||
if (node.id === null) throw new SyntaxError('Script declarations must be named')
|
||||
state.prepend(node, `this.${node.id.name} = ${node.id.name}; `)
|
||||
state.hoistedDeclarationStatements.push(`var ${node.id.name}; `)
|
||||
// Anonymous declarations are invalid inside the parsed async body.
|
||||
const name = (node.id as Identifier).name
|
||||
state.prepend(node, `this.${name} = ${name}; `)
|
||||
state.hoistedDeclarationStatements.push(`var ${name}; `)
|
||||
},
|
||||
FunctionExpression: noop,
|
||||
ArrowFunctionExpression: noop,
|
||||
@@ -138,8 +140,10 @@ const visitorsWithoutAncestors: walk.RecursiveVisitors<TransformState> = {
|
||||
case 'RestElement':
|
||||
registerVariableDeclarationIdentifiers(node.argument)
|
||||
break
|
||||
/* v8 ignore next 4 -- Acorn declaration bindings exclude member targets and other node tags. */
|
||||
case 'MemberExpression':
|
||||
throw new SyntaxError('A declaration cannot bind a member expression')
|
||||
/* v8 ignore next -- The parsed Pattern union is exhaustive above. */
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +179,7 @@ const visitors: walk.RecursiveVisitors<TransformState> = Object.fromEntries(
|
||||
)
|
||||
|
||||
/** Keep the native-Node bootstrap independent of workspace packages' built exports. */
|
||||
/* v8 ignore next 3 -- Exhaustive declaration-tag assertion; parsed bindings cannot reach it. */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected declaration pattern: ${String(value)}`)
|
||||
}
|
||||
@@ -218,10 +223,10 @@ export function processTopLevelAwait(src: string): string | null {
|
||||
}
|
||||
throw new SyntaxError(message)
|
||||
}
|
||||
const statement = root.body[0]
|
||||
if (statement?.type !== 'ExpressionStatement' || statement.expression.type !== 'CallExpression'
|
||||
|| statement.expression.callee.type !== 'ArrowFunctionExpression' || statement.expression.callee.body.type !== 'BlockStatement') return null
|
||||
const body = statement.expression.callee.body
|
||||
// The fixed prefix starts an expression statement and an arrow with a block body.
|
||||
const expression = (root.body[0] as ExpressionStatement).expression
|
||||
if (expression.type !== 'CallExpression' || expression.callee.type !== 'ArrowFunctionExpression') return null
|
||||
const body = expression.callee.body as BlockStatement
|
||||
const state: TransformState = {
|
||||
body,
|
||||
ancestors: [],
|
||||
@@ -230,7 +235,6 @@ export function processTopLevelAwait(src: string): string | null {
|
||||
for (let i = from; i < to; i++) {
|
||||
wrappedArray[i] = ''
|
||||
}
|
||||
if (from === to) str += wrappedArray[from] as string
|
||||
wrappedArray[from] = str
|
||||
},
|
||||
prepend(node, str) {
|
||||
|
||||
@@ -33,8 +33,7 @@ function createScriptContext(): Context {
|
||||
const realm = new Script('globalThis').runInContext(context) as Record<string, unknown>
|
||||
for (const key of Reflect.ownKeys(globalThis)) {
|
||||
if (key === 'global' || Object.hasOwn(realm, key)) continue
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, key)
|
||||
if (descriptor) Object.defineProperty(globals, key, descriptor)
|
||||
Object.defineProperty(globals, key, Object.getOwnPropertyDescriptor(globalThis, key) as PropertyDescriptor)
|
||||
}
|
||||
globals.global = realm
|
||||
return context
|
||||
|
||||
@@ -176,6 +176,7 @@ function resolveMode(config: ResolvedConfig): ExecutionMode {
|
||||
stripTypes: stripTypeScriptTypes,
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- Loader validates the closed execution-mode union before selection. */
|
||||
default: return assertNever(config.executionMode)
|
||||
}
|
||||
}
|
||||
@@ -141,14 +141,14 @@ export async function runNodeMain(
|
||||
}
|
||||
})
|
||||
}, (error, kind) => {
|
||||
if (current === undefined) closed.reject(error)
|
||||
if (current?.active || kind === 'protocol' || current === undefined) fail()
|
||||
if (current === undefined && kind === 'protocol') closed.reject(error)
|
||||
if (current?.active || kind === 'protocol') fail()
|
||||
else closed.resolve()
|
||||
})
|
||||
stream.once('close', onClose)
|
||||
try {
|
||||
await channel.send({ type: 'ready' })
|
||||
await closed.promise
|
||||
// Host closure can arrive before the readiness write receipt.
|
||||
await Promise.race([closed.promise, channel.send({ type: 'ready' }).then(() => closed.promise)])
|
||||
await channel.drain()
|
||||
} finally {
|
||||
stream.off('close', onClose)
|
||||
|
||||
@@ -442,3 +442,9 @@ describe('runProgram', () => {
|
||||
expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an asynchronous REPL failure through the standalone bootstrap', async () => {
|
||||
const port = new FakePort()
|
||||
await runProgram(port, { ...BOOT, programFormat: 'repl', code: 'setImmediate(() => { throw new Error("async bootstrap failure") }); await new Promise(() => {})', namespaces: [] }, fakeStreams())
|
||||
expect(port.done()).toHaveProperty('error.message', expect.stringContaining('async bootstrap failure'))
|
||||
})
|
||||
@@ -16,7 +16,8 @@ const built = ['lib/index.js', 'lib/process.js'].every(file => existsSync(join(p
|
||||
&& existsSync(join(pkgDir, '../ptc-runtime/lib/index.js'))
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a TypeScript program with a binding through lib/index.js and its lib/process.js entry', async () => {
|
||||
it.each(['per-call', 'vm-script'] as const)('runs TypeScript and bindings through published exports in %s mode', async (executionMode) => {
|
||||
const program = 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }; ' + (executionMode === 'per-call' ? 'return { doubled, failure };' : '({ doubled, failure })')
|
||||
const script = `
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { NodePtcRuntime } = await import('@deepseek-ai/dsh-ptc-runtime-node')
|
||||
@@ -27,9 +28,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
}
|
||||
const { default: SandboxPolicy } = await import('@deepseek-ai/dsh-sandbox-policy')
|
||||
await ctx.plugin(SandboxPolicy, { mode: 'read-only' })
|
||||
await ctx.plugin(NodePtcRuntime, {})
|
||||
await ctx.plugin(NodePtcRuntime, ${JSON.stringify({ executionMode })})
|
||||
const result = await ctx.ptcRuntime.run(ctx.ptcRuntime.resolve({
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
|
||||
program: ${JSON.stringify(program)},
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
functions: {
|
||||
@@ -42,7 +43,7 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
await ctx.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
`
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
const resultProcess = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
@@ -50,8 +51,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
expect(resultProcess.timedOut).toBe(false)
|
||||
expect(resultProcess.signal).toBeUndefined()
|
||||
expect(resultProcess.exitCode, resultProcess.stderr).toBe(0)
|
||||
const lastLine = resultProcess.stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({
|
||||
|
||||
@@ -39,9 +39,30 @@ it('clears retained state when a provider is reloaded with another execution mod
|
||||
expect((await resumed.run(resumed.resolve({ program: 'typeof retained', bindings: [], stateOwner: owner }))).value).toBe('undefined')
|
||||
})
|
||||
|
||||
it.each<NonNullable<Config['executionMode']>>(['per-call', 'persistent-in-memory'])('resolves omitted cwd from the configured workspace in %s mode', async (executionMode) => {
|
||||
it.each<NonNullable<Config['executionMode']>>(['per-call', 'persistent-in-memory', 'vm-script'])('resolves omitted cwd from the configured workspace in %s mode', async (executionMode) => {
|
||||
const workspaceRoot = join(process.cwd(), 'configured-workspace')
|
||||
const { runtime } = await mountRuntime(new Context(), { executionMode }, { workspaceRoot })
|
||||
expect(runtime.resolve({ program: '', bindings: [] }).cwd).toBe(workspaceRoot)
|
||||
expect(runtime.resolve({ program: '', bindings: [], cwd: process.cwd() }).cwd).toBe(process.cwd())
|
||||
})
|
||||
|
||||
it.each(['per-call', 'persistent-in-memory'] as const)('rejects Script retention with %s syntax', async (executionMode) => {
|
||||
await expect(mountRuntime(new Context(), { executionMode, scriptPersistence: true })).rejects.toThrow('scriptPersistence')
|
||||
})
|
||||
|
||||
it.each([false, true])('advertises Script syntax and its selected retention %s', async (scriptPersistence) => {
|
||||
const { runtime } = await mountRuntime(new Context(), { executionMode: 'vm-script', scriptPersistence })
|
||||
expect(runtime.programFormat).toBe('script')
|
||||
expect(runtime.retainsState).toBe(scriptPersistence)
|
||||
expect(runtime.executionInstructions).toContain('The final expression is awaited if it is a Promise')
|
||||
expect(runtime.executionInstructions).toContain('uncaught callback exception or unhandled rejection terminates the process')
|
||||
expect(runtime.executionInstructions).toContain('instanceof Array or Error may be false')
|
||||
expect(runtime.executionInstructions.includes('permanently uninitialized')).toBe(scriptPersistence)
|
||||
expect(runtime.executionInstructions.includes('Each call runs in a fresh Node process')).toBe(!scriptPersistence)
|
||||
const stateOwner = {}
|
||||
const run = (program: string) => runtime.run(runtime.resolve({ program, bindings: [], stateOwner }))
|
||||
expect(await run('let retained: number = 17; retained')).toMatchObject({ logs: [], value: 17 })
|
||||
expect(await run('typeof retained')).toMatchObject({ value: scriptPersistence ? 'number' : 'undefined' })
|
||||
await runtime.release(stateOwner)
|
||||
expect(await run('typeof retained')).toMatchObject({ value: 'undefined' })
|
||||
})
|
||||
@@ -63,10 +63,11 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces
|
||||
messages.push(message)
|
||||
receive(message)
|
||||
}, () => {})
|
||||
const emit = (message: unknown): void => {
|
||||
const emitRaw = (message: unknown): void => {
|
||||
const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) })
|
||||
writes.add(write)
|
||||
}
|
||||
const emit = (payload: unknown): void => { emitRaw({ id: 1, payload }) }
|
||||
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath)
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle)
|
||||
onTestFinished(async () => {
|
||||
@@ -79,17 +80,17 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces
|
||||
})
|
||||
const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => {
|
||||
const result = runtime.run(runtime.resolve(input))
|
||||
if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) })
|
||||
if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emitRaw(first) })
|
||||
return result
|
||||
}
|
||||
const onBoot = (callback: () => void): void => {
|
||||
const onRun = (callback: () => void): void => {
|
||||
receive = (message) => {
|
||||
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback()
|
||||
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'run') callback()
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages,
|
||||
spawn, resolveExecutable, emit, start, onBoot,
|
||||
spawn, resolveExecutable, emit, emitRaw, start, onRun,
|
||||
receive: (callback: typeof receive) => { receive = callback },
|
||||
}
|
||||
}
|
||||
@@ -149,14 +150,14 @@ describe('Node runtime host failures', () => {
|
||||
it('keeps partial enforcement separate from a successful program', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
|
||||
h.onRun(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
|
||||
expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } })
|
||||
})
|
||||
|
||||
it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
|
||||
h.onRun(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) })
|
||||
const result = await h.start()
|
||||
expect(result.error).toEqual({ kind: 'exception', message })
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' })
|
||||
@@ -165,7 +166,7 @@ describe('Node runtime host failures', () => {
|
||||
it('distinguishes fatal sandbox startup output from a program denial', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv]))
|
||||
h.onBoot(() => {
|
||||
h.onRun(() => {
|
||||
h.stderr.write('sandbox-fatal: runner could not initialize')
|
||||
h.direct.resolve({ exitCode: 1, signal: null })
|
||||
})
|
||||
@@ -206,7 +207,7 @@ describe('Node runtime host failures', () => {
|
||||
it('keeps a null-deadline run active past the numeric ceiling until cancellation', async () => {
|
||||
const h = await setup({ timeoutMs: 20, maxTimeoutMs: 40 })
|
||||
const booted = Promise.withResolvers<undefined>()
|
||||
h.onBoot(() => { booted.resolve(undefined) })
|
||||
h.onRun(() => { booted.resolve(undefined) })
|
||||
const controller = new AbortController()
|
||||
let active: ReturnType<typeof h.start> | undefined
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
@@ -274,7 +275,7 @@ describe('Node runtime host failures', () => {
|
||||
return h.handle
|
||||
})
|
||||
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
|
||||
kind: 'worker-exit', message: 'Node process exited before completing (7)',
|
||||
kind: 'worker-exit', message: 'Node process exited (7, signal null)',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -292,13 +293,13 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('reports control loss after readiness as a substrate failure', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.emit('error', new Error('control transport closed')) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
|
||||
h.onRun(() => { void setImmediate().then(() => { h.control.emit('error', new Error('control transport closed')) }) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node process exited (0, signal null)' })
|
||||
})
|
||||
|
||||
it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) })
|
||||
h.onRun(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) })
|
||||
expect((await h.start()).error?.kind).toBe('protocol')
|
||||
})
|
||||
|
||||
@@ -306,33 +307,34 @@ describe('Node runtime host failures', () => {
|
||||
const h = await setup()
|
||||
const binding = vi.fn(async () => null)
|
||||
const result = await h.start(withBinding(binding), call(1))
|
||||
expect(result.error?.message).toContain('before bootstrap readiness')
|
||||
expect(result.error?.message).toContain('expected PTC readiness')
|
||||
expect(binding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[unknown, string]>([
|
||||
[null, 'invalid control frame'],
|
||||
[{ type: 'log', text: 1 }, 'invalid log frame'],
|
||||
[{ type: 'done', error: null }, 'invalid terminal error'],
|
||||
[{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'],
|
||||
[{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'],
|
||||
[{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'],
|
||||
[null, 'frame does not belong'],
|
||||
[{ type: 'log', text: 1 }, 'invalid PTC log'],
|
||||
[{ type: 'done', error: null }, 'invalid PTC failure'],
|
||||
[{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid PTC failure'],
|
||||
[{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid PTC failure'],
|
||||
[{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid PTC binding identity'],
|
||||
[{ ...call(1), name: 'constructor' }, 'undeclared binding'],
|
||||
[{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'],
|
||||
[{ type: 'unknown' }, 'unknown control message'],
|
||||
[{ type: 'unknown' }, 'unknown PTC message'],
|
||||
])('rejects malformed program frame %j', async (frame, message) => {
|
||||
const h = await setup()
|
||||
const binding = vi.fn(async () => null)
|
||||
h.onBoot(() => { h.emit(frame) })
|
||||
h.onRun(() => { h.emit(frame) })
|
||||
const result = await h.start(withBinding(binding))
|
||||
expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) as unknown })
|
||||
expect(result.error?.kind).toBe('protocol')
|
||||
expect(result.error?.message).toContain(message)
|
||||
expect(binding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a repeated call id instead of dispatching it twice', async () => {
|
||||
const h = await setup()
|
||||
const binding = vi.fn(async () => null)
|
||||
h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) })
|
||||
h.onRun(() => { h.emit(call(1)); h.emit(call(1)) })
|
||||
expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol')
|
||||
expect(binding).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -342,7 +344,7 @@ describe('Node runtime host failures', () => {
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
const binding = vi.fn(async () => await release.promise)
|
||||
h.onBoot(() => { h.emit(call(1, 'a'.repeat(300))); h.emit(call(2, 'b'.repeat(300))) })
|
||||
h.onRun(() => { h.emit(call(1, 'a'.repeat(300))); h.emit(call(2, 'b'.repeat(300))) })
|
||||
expect((await h.start(withBinding(binding))).error?.message).toContain('pending binding calls exceed')
|
||||
expect(binding).toHaveBeenCalledOnce()
|
||||
release.resolve(null)
|
||||
@@ -351,7 +353,7 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('rejects an untransferable completion instead of returning a substituted value', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) })
|
||||
h.onRun(() => { h.emit({ type: 'done', value: { invalid: true } }) })
|
||||
expect((await h.start()).error?.kind).toBe('invalid-output')
|
||||
})
|
||||
|
||||
@@ -359,18 +361,19 @@ describe('Node runtime host failures', () => {
|
||||
const h = await setup()
|
||||
h.receive((message) => {
|
||||
if (typeof message !== 'object' || message === null || !('type' in message)) return
|
||||
if (message.type === 'boot') h.emit(call(1))
|
||||
if (message.type === 'run') h.emit(call(1))
|
||||
if (message.type === 'reply') h.emit({ type: 'done' })
|
||||
})
|
||||
expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined()
|
||||
expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') as unknown })
|
||||
expect(h.messages).toContainEqual(expect.objectContaining({ type: 'reply', id: 1, ok: false }))
|
||||
expect(h.messages.at(-1)).toHaveProperty('message', expect.stringContaining('lossless JSON'))
|
||||
})
|
||||
|
||||
it('does not publish a late binding reply after the program has settled', async () => {
|
||||
const h = await setup()
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
|
||||
h.onRun(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
|
||||
expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined()
|
||||
release.resolve(null)
|
||||
await setImmediate()
|
||||
@@ -381,7 +384,7 @@ describe('Node runtime host failures', () => {
|
||||
it('reports failed managed cleanup even when the program returns successfully', async () => {
|
||||
const h = await setup()
|
||||
vi.mocked(h.waitForExit).mockRejectedValue(new Error('range cannot be observed'))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
|
||||
h.onRun(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' })
|
||||
})
|
||||
|
||||
@@ -392,14 +395,14 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('turns an oversized binding reply into a protocol failure', async () => {
|
||||
const h = await setup({ maxMessageBytes: 256 })
|
||||
h.onBoot(() => { h.emit(call(1)) })
|
||||
h.onRun(() => { h.emit(call(1)) })
|
||||
const result = await h.start(withBinding(async () => 'x'.repeat(512)))
|
||||
expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' })
|
||||
})
|
||||
|
||||
it('retains admitted logs when the program reports its own output limit', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => {
|
||||
h.onRun(() => {
|
||||
h.emit({ type: 'log', text: 'retained' })
|
||||
h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } })
|
||||
})
|
||||
@@ -413,13 +416,13 @@ describe('Node runtime host failures', () => {
|
||||
const cleaning = Promise.withResolvers<undefined>()
|
||||
vi.mocked(h.terminate).mockImplementation(() => { h.direct.resolve({ exitCode: 0, signal: null }) })
|
||||
vi.mocked(h.waitForExit).mockImplementation(async () => { cleaning.resolve(undefined); return true })
|
||||
h.onBoot(() => { h.emit(failure === undefined ? { type: 'done' } : { type: 'done', error: failure }) })
|
||||
h.onRun(() => { h.emit(failure === undefined ? { type: 'done' } : { type: 'done', error: failure }) })
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
const pending = h.start()
|
||||
await cleaning.promise
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect((await pending).error).toEqual(failure ?? { kind: 'worker-exit', message: 'Node process output did not close cleanly' })
|
||||
expect((await pending).error).toEqual({ kind: 'worker-exit', message: 'managed process exited but its output did not drain cleanly' })
|
||||
expect(h.stdout.destroyed).toBe(true)
|
||||
expect(h.stderr.destroyed).toBe(true)
|
||||
} finally { vi.useRealTimers() }
|
||||
@@ -427,7 +430,7 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('decodes fragmented native output and preserves incomplete final UTF-8 bytes', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => {
|
||||
h.onRun(() => {
|
||||
for (const stream of [h.stdout, h.stderr]) {
|
||||
stream.write(Buffer.from([0xef, 0xbb, 0xbf]))
|
||||
stream.write(Buffer.from([0xe2]))
|
||||
@@ -450,7 +453,7 @@ describe('Node runtime host failures', () => {
|
||||
h.stdout.end()
|
||||
h.direct.resolve({ exitCode: 0, signal: null })
|
||||
})
|
||||
h.onBoot(() => { h.stdout.write('x'.repeat(1024)) })
|
||||
h.onRun(() => { h.stdout.write('x'.repeat(1024)) })
|
||||
const result = await h.start()
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(result.logs.join('')).toMatch(/^x+$/)
|
||||
@@ -459,24 +462,24 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it.each(['stdout', 'stderr'] as const)('reports a broken raw %s pipe', async (name) => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h[name].emit('error', new Error(`${name} closed`)) })
|
||||
h.onRun(() => { h[name].emit('error', new Error(`${name} closed`)) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: `${name} closed` })
|
||||
})
|
||||
|
||||
it('retains stderr when a ready process exits without a completion frame', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => {
|
||||
h.onRun(() => {
|
||||
h.stderr.write('native fatal detail')
|
||||
h.direct.resolve({ exitCode: 9, signal: null })
|
||||
})
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node process exited before completing (9): native fatal detail' })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node process exited (9, signal null): native fatal detail' })
|
||||
})
|
||||
|
||||
it.each([true, false])('attributes a failed confined spawn only with runner evidence (%s)', async (runnerFailed) => {
|
||||
const h = await setup({}, 'read-only')
|
||||
const runner = '/sandbox-runner'
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([runner, ...argv]))
|
||||
h.onBoot(() => {
|
||||
h.onRun(() => {
|
||||
h.direct.reject(Object.assign(new Error('spawn rejected'), runnerFailed ? { code: 'ENOENT', path: runner, syscall: `spawn ${runner}` } : {}))
|
||||
})
|
||||
expect((await h.start()).error).toEqual({ kind: runnerFailed ? 'sandbox-unavailable' : 'worker-exit', message: 'spawn rejected' })
|
||||
@@ -489,7 +492,7 @@ describe('Node runtime host failures', () => {
|
||||
vi.stubEnv('TMP', 'fixture-tmp-second')
|
||||
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
|
||||
vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
|
||||
h.onBoot(() => { h.emit({ type: 'done' }) })
|
||||
h.onRun(() => { h.emit({ type: 'done' }) })
|
||||
expect((await h.start()).error).toBeUndefined()
|
||||
const env = h.spawn.mock.calls[0]?.[0].env ?? {}
|
||||
expect(Object.hasOwn(env, 'TEMP')).toBe(false)
|
||||
@@ -501,7 +504,7 @@ describe('Node runtime host failures', () => {
|
||||
|
||||
it('selects the remote embedded bootstrap without leaking ambient environment', async () => {
|
||||
const h = await setup({ launch: { kind: 'embedded', executable: process.execPath } })
|
||||
h.onBoot(() => { h.emit({ type: 'done' }) })
|
||||
h.onRun(() => { h.emit({ type: 'done' }) })
|
||||
expect((await h.start()).error).toBeUndefined()
|
||||
const spec = h.spawn.mock.calls[0]?.[0]
|
||||
expect(spec?.env?.DSH_PTC_RUNTIME_NODE).toBe('1')
|
||||
@@ -516,7 +519,7 @@ it('keeps failed per-call cleanup registered until process observation succeeds'
|
||||
const h = await setup()
|
||||
const session = h.ctx.sessions.create(SessionId('per-call-cleanup-retry'))
|
||||
h.waitForExit.mockRejectedValue(new Error('range cannot be observed'))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
|
||||
h.onRun(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) })
|
||||
const result = await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
setSandboxMode(session, 'read-only')
|
||||
@@ -542,7 +545,7 @@ it('preserves execution context for bindings delivered by an external control st
|
||||
const booted = Promise.withResolvers<undefined>()
|
||||
h.receive((raw) => {
|
||||
const message = raw as { type: string; ok?: boolean; value?: unknown }
|
||||
if (message.type === 'boot') booted.resolve(undefined)
|
||||
if (message.type === 'run') booted.resolve(undefined)
|
||||
if (message.type === 'reply') h.emit({ type: 'done', value: message.value })
|
||||
})
|
||||
const context = new AsyncLocalStorage<string>()
|
||||
|
||||
@@ -8,7 +8,10 @@ const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const built = ['lib/index.js', 'lib/persistent-process.js'].every(file => existsSync(join(pkgDir, file)))
|
||||
|
||||
describe.skipIf(!built)('persistent PTC published exports', () => {
|
||||
it('retains variables through the built provider and sibling process entry', async () => {
|
||||
it.each([
|
||||
{ executionMode: 'persistent-in-memory' },
|
||||
{ executionMode: 'vm-script', scriptPersistence: true },
|
||||
])('retains variables through published exports with $executionMode', async (config) => {
|
||||
const script = `
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { default: NodeRuntime } = await import('@deepseek-ai/dsh-ptc-runtime-node')
|
||||
@@ -17,7 +20,7 @@ describe.skipIf(!built)('persistent PTC published exports', () => {
|
||||
for (const name of ['session-projection', 'fs-local', 'subprocess-local', 'sandbox-local', 'sandbox-policy']) {
|
||||
await ctx.plugin((await import('@deepseek-ai/dsh-' + name)).default)
|
||||
}
|
||||
await ctx.plugin(NodeRuntime, { executionMode: 'persistent-in-memory' })
|
||||
await ctx.plugin(NodeRuntime, ${JSON.stringify(config)})
|
||||
const stateOwner = {}
|
||||
const run = program => ctx.ptcRuntime.run(ctx.ptcRuntime.resolve({program, bindings: [], stateOwner}))
|
||||
const first = await run('let count: number = 40; const path = await import("node:path");')
|
||||
@@ -33,8 +36,8 @@ describe.skipIf(!built)('persistent PTC published exports', () => {
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '')).toMatchObject({
|
||||
first: { logs: [] }, second: { logs: ['b'], value: 42 }, fresh: { logs: [], value: 'undefined' },
|
||||
})
|
||||
const observed = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '') as { first: { error?: unknown } }
|
||||
expect(observed.first.error).toBeUndefined()
|
||||
expect(observed).toMatchObject({ second: { logs: ['b'], value: 42 }, fresh: { logs: [], value: 'undefined' } })
|
||||
})
|
||||
})
|
||||
@@ -174,11 +174,17 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a retained call when its provider unloads during earlier policy cleanup', async () => {
|
||||
const h = await setup()
|
||||
it.each(['persistent-in-memory', 'per-call'] as const)('rejects admission when %s unloads during earlier policy cleanup', async (executionMode) => {
|
||||
const h = await setup({ executionMode })
|
||||
const session = h.ctx.sessions.create(SessionId('unloading-admission'))
|
||||
h.onBoot(() => { h.emit(frame({ type: 'done' })) })
|
||||
await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
|
||||
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
h.onBoot(() => {
|
||||
if (executionMode === 'persistent-in-memory') h.emit(frame({ type: 'done' }))
|
||||
entered.resolve()
|
||||
})
|
||||
const first = h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) })
|
||||
await entered.promise
|
||||
if (executionMode === 'persistent-in-memory') await first
|
||||
const cleaning = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<boolean>()
|
||||
h.waitForExit.mockImplementation(() => { cleaning.resolve(undefined); return cleanup.promise })
|
||||
@@ -187,11 +193,12 @@ it('rejects a retained call when its provider unloads during earlier policy clea
|
||||
const pending = h.runtime.run(h.runtime.resolve({
|
||||
...request, stateOwner: {}, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }),
|
||||
}))
|
||||
const rejected = expect(pending).rejects.toThrow('persistent PTC: run after disposal')
|
||||
const prefix = executionMode === 'per-call' ? 'ptc-runtime-node' : 'persistent PTC'
|
||||
const rejected = expect(pending).rejects.toThrow(`${prefix}: run after disposal`)
|
||||
const closing = h.fiber.dispose()
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(() => h.runtime.resolve(request)).toThrow('persistent PTC: resolve after disposal')
|
||||
expect(() => h.runtime.resolve(request)).toThrow(`${prefix}: resolve after disposal`)
|
||||
})
|
||||
expect(h.spawn).toHaveBeenCalledOnce()
|
||||
cleanup.resolve(true)
|
||||
@@ -201,7 +208,7 @@ it('rejects a retained call when its provider unloads during earlier policy clea
|
||||
expect(h.spawn).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
cleanup.resolve(true)
|
||||
await Promise.allSettled([pending, rejected, closing])
|
||||
await Promise.allSettled([first, pending, rejected, closing])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -412,8 +419,8 @@ describe('persistent PTC host protocol', () => {
|
||||
it('rejects invalid resolved inputs and deployment bounds', async () => {
|
||||
const h = await setup()
|
||||
expect(h.runtime.executionInstructions).toContain('Variables, functions, and imported modules remain in memory')
|
||||
await expect(h.runtime.run({ ...request, cwd: '.', timeoutMs: null })).rejects.toThrow('resolved inputs')
|
||||
await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: Infinity })).rejects.toThrow('resolved inputs')
|
||||
await expect(h.runtime.run({ ...request, cwd: '.', timeoutMs: null })).rejects.toThrow('resolved sandbox policy')
|
||||
await expect(h.runtime.run({ ...h.runtime.resolve(request), timeoutMs: Infinity })).rejects.toThrow('resolved cwd and timeout')
|
||||
for (const config of [
|
||||
{ timeoutMs: 0 }, { maxMessageBytes: 0x1_0000_0000 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 },
|
||||
{ launch: { kind: 'node-script', executable: '' } },
|
||||
@@ -539,7 +546,7 @@ it('reserves the owner while parse-output overflow releases its environment', as
|
||||
const pending = h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' }))
|
||||
try {
|
||||
await cleanup.promise
|
||||
await expect(h.runtime.run(h.runtime.resolve(request))).rejects.toThrow('cleanup is incomplete')
|
||||
await expect(h.runtime.run(h.runtime.resolve(request))).rejects.toThrow('does not accept concurrent')
|
||||
expect(h.spawn).toHaveBeenCalledOnce()
|
||||
stopped.resolve(true)
|
||||
expect((await pending).error?.kind).toBe('output-limit')
|
||||
@@ -588,7 +595,7 @@ it('releases a confirmed exited process even when its output pipe did not drain'
|
||||
const warn = vi.spyOn(h.ctx.logger, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
await expect(h.runtime.release(request.stateOwner!)).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith('persistent Node process exited but its output did not drain cleanly')
|
||||
expect(warn).toHaveBeenCalledWith('Node process exited but its output did not drain cleanly')
|
||||
await expect(h.runtime.release(request.stateOwner!)).resolves.toBeUndefined()
|
||||
} finally { warn.mockRestore() }
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { runPersistentMain, type PersistentProcess } from '../src/persistent-process.ts'
|
||||
@@ -150,3 +151,11 @@ it('shuts down an idle REPL without creating an evaluation', async () => {
|
||||
await h.close()
|
||||
expect(h.seen).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a REPL asynchronous diagnostic after its evaluation has completed', async () => {
|
||||
const h = await setup()
|
||||
await h.run('setImmediate(() => { throw new Error("completed callback") }); 1')
|
||||
await setImmediate()
|
||||
expect(decodePtcJsonWire((await h.run('2')).value)).toBe(2)
|
||||
expect(h.seen.filter(message => message.type === 'done')).toHaveLength(2)
|
||||
})
|
||||
@@ -8,11 +8,11 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { Config } from '../src/index.ts'
|
||||
import { mountRuntime } from './setup.ts'
|
||||
|
||||
async function setup(config: Config = {}) {
|
||||
async function setupRuntime(config: Config = {}) {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-persistent-ptc-'))
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(cwd, { recursive: true, force: true }) })
|
||||
const { runtime, fiber } = await mountRuntime(ctx, { ...config, executionMode: 'persistent-in-memory' })
|
||||
const { runtime, fiber } = await mountRuntime(ctx, { executionMode: 'persistent-in-memory', ...config })
|
||||
const owner = {}
|
||||
const run = (program: string, options: Partial<PtcRunRequest> = {}) =>
|
||||
runtime.run(runtime.resolve({ program, bindings: [], cwd, stateOwner: owner, ...options }))
|
||||
@@ -23,7 +23,11 @@ function bindings(value: PtcBindingFunction) {
|
||||
return [{ global: 'tools', functions: { value }, errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }]
|
||||
}
|
||||
|
||||
describe('persistent Node REPL', () => {
|
||||
describe.each([
|
||||
{ executionMode: 'persistent-in-memory', scriptPersistence: false, format: 'repl' },
|
||||
{ executionMode: 'vm-script', scriptPersistence: true, format: 'script' },
|
||||
] as const)('retained Node $format', ({ format, ...mode }) => {
|
||||
const setup = (config: Config = {}) => setupRuntime({ ...mode, ...config })
|
||||
it('keeps a snapshot of caller-owned root arrays and rejects changed grants', async () => {
|
||||
const { run, runtime, owner, cwd } = await setup()
|
||||
const roots = [cwd]
|
||||
@@ -51,22 +55,10 @@ describe('persistent Node REPL', () => {
|
||||
|
||||
it('retains declarations, closures, object identity, and imports across evaluations', async () => {
|
||||
const { run, cwd } = await setup()
|
||||
expect(await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).toMatchObject({ logs: [] })
|
||||
expect((await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).error).toBeUndefined()
|
||||
expect(await run('console.log("second call"); ({ count: increment(), same: box === alias, dir: path.basename(process.cwd()), env: Object.keys(process.env) })')).toMatchObject({ logs: ['second call'], value: { count: 2, same: true, dir: cwd.split(/[\\/]/).at(-1), env: [] } })
|
||||
})
|
||||
|
||||
it('retains implicit REPL globals and does not attribute a completed callback exception to a later evaluation', async () => {
|
||||
const { run } = await setup()
|
||||
expect((await run('implicitValue = 7')).value).toBe(7)
|
||||
await run(`
|
||||
let fireLate; let acknowledge;
|
||||
const fired = new Promise(resolve => { fireLate = resolve });
|
||||
const acknowledged = new Promise(resolve => { acknowledge = resolve });
|
||||
void fired.then(() => setImmediate(() => { acknowledge(); throw new Error('completed callback failure') }));
|
||||
`)
|
||||
expect(await run('fireLate(); await acknowledged; implicitValue')).toMatchObject({ logs: [], value: 7 })
|
||||
expect(await run('implicitValue')).toMatchObject({ logs: [], value: 7 })
|
||||
})
|
||||
|
||||
it('uses current host bindings through retained functions and saved method references', async () => {
|
||||
const { run } = await setup()
|
||||
@@ -115,14 +107,6 @@ describe('persistent Node REPL', () => {
|
||||
expect(plain.value).toEqual({ name: 'Error', member: false })
|
||||
})
|
||||
|
||||
it('settles an asynchronous exception once while a later evaluation awaits a tool', async () => {
|
||||
const { run } = await setup()
|
||||
const failed = await run('let retained = 17; let resume; const paused = new Promise(resolve => { resume = resolve }); setImmediate(() => { throw new Error("async failure") }); await paused; retained')
|
||||
expect(failed.error?.message).toContain('async failure')
|
||||
const next = await run('resume(); await tools.value({})', { bindings: bindings(async () => { await setImmediate(); return 42 }) })
|
||||
expect(next).toMatchObject({ logs: [], value: 42 })
|
||||
expect((await run('retained')).value).toBe(17)
|
||||
})
|
||||
|
||||
it('rejects a completed evaluation callback without consuming the next real binding identity', async () => {
|
||||
const { run } = await setup()
|
||||
@@ -240,7 +224,7 @@ describe('persistent Node REPL', () => {
|
||||
it('resolves confinement and validates options before starting a process', async () => {
|
||||
const { run, runtime } = await setup()
|
||||
expect(runtime.sandboxMode).toBe('danger-full-access')
|
||||
expect(runtime.programFormat).toBe('repl')
|
||||
expect(runtime.programFormat).toBe(format)
|
||||
expect(runtime.timeout).toEqual({ defaultMs: 120_000, maxMs: 600_000 })
|
||||
expect(runtime.resolve({ program: '1', bindings: [], sandboxPolicy: { mode: 'read-only', workspaceRoot: process.cwd() } }).sandboxPolicy?.mode).toBe('read-only')
|
||||
expect(() => runtime.resolve({ program: '1', bindings: [], cwd: 'relative' })).toThrow('absolute')
|
||||
@@ -277,7 +261,7 @@ describe('persistent Node REPL', () => {
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
await expect(setup()).rejects.toThrow('requires a node-script launch')
|
||||
const { runtime } = await setup({ launch: { kind: 'node-script', executable: process.execPath, bootstrapPath: join(process.cwd(), 'persistent-process.js') } })
|
||||
expect(runtime.programFormat).toBe('repl')
|
||||
expect(runtime.programFormat).toBe(format)
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process, 'pkg')
|
||||
else Object.defineProperty(process, 'pkg', previous)
|
||||
@@ -292,7 +276,7 @@ describe('persistent Node REPL', () => {
|
||||
})
|
||||
|
||||
it.each(['_', '_error'])('keeps %s assignments and subsequent tools in the active evaluation', async (name) => {
|
||||
const { run } = await setup()
|
||||
const { run } = await setupRuntime()
|
||||
const called = vi.fn(async () => 7)
|
||||
expect(await run(`${name} = 42; await tools.value({}); ${name}`, { bindings: bindings(called) })).toMatchObject({ logs: [], value: 42 })
|
||||
expect(called).toHaveBeenCalledOnce()
|
||||
@@ -300,7 +284,7 @@ it.each(['_', '_error'])('keeps %s assignments and subsequent tools in the activ
|
||||
})
|
||||
|
||||
it('rejects a new run until cancellation cleanup has completed for its owner', async () => {
|
||||
const { run, ctx } = await setup()
|
||||
const { run, ctx } = await setupRuntime()
|
||||
const entered = Promise.withResolvers<true>()
|
||||
const cleanup = Promise.withResolvers<true>()
|
||||
const released = Promise.withResolvers<boolean>()
|
||||
@@ -324,3 +308,25 @@ it('rejects a new run until cancellation cleanup has completed for its owner', a
|
||||
expect((await run('42')).value).toBe(42)
|
||||
} finally { released.resolve(true); await pending; intercepted.mockRestore() }
|
||||
})
|
||||
|
||||
it('retains implicit REPL globals and does not attribute a completed callback exception to a later evaluation', async () => {
|
||||
const { run } = await setupRuntime()
|
||||
expect((await run('implicitValue = 7')).value).toBe(7)
|
||||
await run(`
|
||||
let fireLate; let acknowledge;
|
||||
const fired = new Promise(resolve => { fireLate = resolve });
|
||||
const acknowledged = new Promise(resolve => { acknowledge = resolve });
|
||||
void fired.then(() => setImmediate(() => { acknowledge(); throw new Error('completed callback failure') }));
|
||||
`)
|
||||
expect(await run('fireLate(); await acknowledged; implicitValue')).toMatchObject({ logs: [], value: 7 })
|
||||
expect(await run('implicitValue')).toMatchObject({ logs: [], value: 7 })
|
||||
})
|
||||
|
||||
it('settles an asynchronous exception once while a later evaluation awaits a tool', async () => {
|
||||
const { run } = await setupRuntime()
|
||||
const failed = await run('let retained = 17; let resume; const paused = new Promise(resolve => { resume = resolve }); setImmediate(() => { throw new Error("async failure") }); await paused; retained')
|
||||
expect(failed.error?.message).toContain('async failure')
|
||||
const next = await run('resume(); await tools.value({})', { bindings: bindings(async () => { await setImmediate(); return 42 }) })
|
||||
expect(next).toMatchObject({ logs: [], value: 42 })
|
||||
expect((await run('retained')).value).toBe(17)
|
||||
})
|
||||
@@ -68,6 +68,7 @@ it('keeps the control pipe open for a late binding reply until the host closes i
|
||||
await nextTurn()
|
||||
expect(child.destroyed).toBe(false)
|
||||
await peer.send({ type: 'reply', id: callId, ok: true, value: encodePtcJsonWire(42) })
|
||||
await peer.send({ type: 'boot', data: { code: 'throw new Error("must not rerun")', namespaces: [], maxOutputBytes: 1024 } })
|
||||
await nextTurn()
|
||||
expect(messages).toEqual(['ready', 'call', 'done'])
|
||||
} finally {
|
||||
@@ -89,12 +90,12 @@ it('rejects an unexpected first control frame', async () => {
|
||||
await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot')
|
||||
})
|
||||
|
||||
it('records an I/O failure before the boot frame', async () => {
|
||||
it('accepts host shutdown before the first evaluation', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {})
|
||||
await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error)
|
||||
expect(state.exitCode).toBe(1)
|
||||
await runNodeMain(child, 4096, state)
|
||||
expect(state.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains program writes that exceed queued control output', async () => {
|
||||
@@ -141,3 +142,23 @@ it('retains a malformed host frame failure after the terminal frame', async () =
|
||||
expect(state.exitCode).toBe(1)
|
||||
} finally { peer.close(); await main }
|
||||
})
|
||||
|
||||
it('rejects malformed transport bytes before the first evaluation', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const peer = new JsonChannel(host, 4096, () => { host.write(Buffer.from([0, 0, 0, 1, 0xff])) }, () => {})
|
||||
onTestFinished(() => { peer.close() })
|
||||
await expect(runNodeMain(child, 4096, processState())).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('reports a missing directory operation on a run frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const terminal = Promise.withResolvers<unknown>()
|
||||
const peer = new JsonChannel(host, 4096, (raw) => {
|
||||
const frame = raw as { type?: string; payload?: unknown }
|
||||
if (frame.type === 'ready') void peer.send({ type: 'run', id: 1, cwd: '/', data: { code: '1', namespaces: [], maxOutputBytes: 2048 } })
|
||||
if (frame.payload) { terminal.resolve(frame.payload); host.end() }
|
||||
}, () => {})
|
||||
onTestFinished(() => { peer.close() })
|
||||
await runNodeMain(child, 4096, processState())
|
||||
expect(await terminal.promise).toHaveProperty('error.message', expect.stringContaining('cannot change its working directory'))
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { copyFile, cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Duplex } from 'node:stream'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
@@ -14,6 +14,11 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep
|
||||
for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) {
|
||||
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
|
||||
}
|
||||
await cp(new URL('../src/evaluators/', import.meta.url), join(directory, 'evaluators'), { recursive: true })
|
||||
await mkdir(join(directory, 'node_modules'))
|
||||
for (const name of ['acorn', 'acorn-walk']) {
|
||||
await cp(dirname(fileURLToPath(import.meta.resolve(`${name}/package.json`))), join(directory, 'node_modules', name), { recursive: true })
|
||||
}
|
||||
const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', source], {
|
||||
env: { PLACEHOLDER_SECRET: 'fixture-only' },
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it } from 'vitest'
|
||||
import { mountRuntime } from './setup.ts'
|
||||
|
||||
it.each([false, true])('preserves binding error identity and imported realm semantics with retention %s', async (scriptPersistence) => {
|
||||
const { runtime } = await mountRuntime(new Context(), { executionMode: 'vm-script', scriptPersistence })
|
||||
const result = await runtime.run(runtime.resolve({
|
||||
program: `
|
||||
const array = await tools.array({});
|
||||
const {types} = await import('node:util');
|
||||
let failure;
|
||||
try { await tools.fail({}) } catch (error) {
|
||||
failure = [error instanceof ToolCallError, error instanceof Error, error.name, error.toolName, error.message, types.isNativeError(error)];
|
||||
}
|
||||
({array: [Array.isArray(array), array instanceof Array], failure})
|
||||
`,
|
||||
bindings: [{ global: 'tools', functions: { array: async () => [1, 2], fail: async () => { throw new Error('denied') } },
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }], stateOwner: {},
|
||||
}))
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({ array: [true, false], failure: [true, false, 'ToolCallError', 'fail', 'denied', true] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'setImmediate(() => { throw new Error("uncaught callback") }); await new Promise(() => {})',
|
||||
'void Promise.reject(new Error("unhandled rejection")); await new Promise(() => {})',
|
||||
])('discards retained Script state after an asynchronous process failure: %s', async (program) => {
|
||||
const { runtime } = await mountRuntime(new Context(), { executionMode: 'vm-script', scriptPersistence: true })
|
||||
const stateOwner = {}
|
||||
const run = (source: string) => runtime.run(runtime.resolve({ program: source, bindings: [], stateOwner }))
|
||||
expect((await run('let retained = 17; retained')).value).toBe(17)
|
||||
const failure = await run(program)
|
||||
expect(failure.error?.kind).toBe('worker-exit')
|
||||
expect(failure.error?.message).toContain('retained variables were cleared')
|
||||
expect((await run('typeof retained')).value).toBe('undefined')
|
||||
})
|
||||
|
||||
it('cancels a temporary Script after a binding handshake and starts the next call fresh', async () => {
|
||||
const { runtime } = await mountRuntime(new Context(), { executionMode: 'vm-script' })
|
||||
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const controller = new AbortController()
|
||||
const pending = runtime.run(runtime.resolve({
|
||||
program: 'let retained = 1; await tools.enter({}); await new Promise(() => {})',
|
||||
bindings: [{ global: 'tools', functions: { enter: async () => { entered.resolve(); return null } } }], signal: controller.signal,
|
||||
}))
|
||||
try {
|
||||
await entered.promise
|
||||
controller.abort('stop Script')
|
||||
expect((await pending).error).toEqual({ kind: 'abort', message: 'stop Script' })
|
||||
expect((await runtime.run(runtime.resolve({ program: 'typeof retained', bindings: [] }))).value).toBe('undefined')
|
||||
} finally { controller.abort(); await pending }
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Parser } from 'acorn'
|
||||
import { createProgramEvaluator } from '../src/evaluators/index.ts'
|
||||
import type { ProgramEvaluator, ProgramFormat } from '../src/evaluators/types.ts'
|
||||
import { processTopLevelAwait } from '../src/evaluators/vm-script-await.ts'
|
||||
|
||||
type EvaluationResult = Parameters<Parameters<ProgramEvaluator['evaluate']>[1]>[0]
|
||||
|
||||
function evaluator(format: ProgramFormat = 'script') {
|
||||
const instance = createProgramEvaluator(format, () => {})
|
||||
onTestFinished(() => { instance.close() })
|
||||
const run = (code: string) => new Promise<EvaluationResult>((resolve) => {
|
||||
try { instance.evaluate(code, resolve) } catch (error: unknown) { resolve({ ok: false, error }) }
|
||||
})
|
||||
const value = async (code: string): Promise<unknown> => {
|
||||
const result = await run(code)
|
||||
if (!result.ok) throw result.error
|
||||
return result.value
|
||||
}
|
||||
return { ...instance, run, value }
|
||||
}
|
||||
|
||||
it.each<[string, unknown]>([
|
||||
['1 + 2', 3],
|
||||
['Promise.resolve(4)', 4],
|
||||
['await(5)', 5],
|
||||
['await\nPromise.resolve(6)', 6],
|
||||
['(await Promise.resolve(7));;;', 7],
|
||||
['await Promise.resolve(); Promise.resolve(8)', 8],
|
||||
['await Promise.resolve(); let done = true;', undefined],
|
||||
['await Promise.resolve(); if (true) { 9 }', undefined],
|
||||
['#!script\nawait Promise.resolve(10) // trailing comment', 10],
|
||||
['let sum = 0; for await (const item of [1, 2]) sum += item; sum', 3],
|
||||
['for (var name in {a: 1}) { await Promise.resolve() }; name', 'a'],
|
||||
['for (var item of [11]) { await Promise.resolve() }; item', 11],
|
||||
['let first, second = await Promise.resolve(12); [typeof first, second]', ['undefined', 12]],
|
||||
['const {a = 13, ...rest} = await Promise.resolve({b: 14}); const [, x, ...tail] = [0, 15, 16]; [a, rest.b, x, tail[0]]', [13, 14, 15, 16]],
|
||||
['{ var local = await Promise.resolve(17) }; local', 17],
|
||||
['function before() { return 18 }; await Promise.resolve(); before()', 18],
|
||||
['class Box { [await Promise.resolve("value")]() { return 19 } }; new Box().value()', 19],
|
||||
['class Box { static { this.value = 20 }; method() { return 1 } }; await Promise.resolve(); Box.value', 20],
|
||||
['{ class Local {} }; await Promise.resolve(); typeof Local', 'undefined'],
|
||||
['const fn = async function () { return await Promise.resolve(21) }; fn()', 21],
|
||||
['const fn = async () => await Promise.resolve(22); fn()', 22],
|
||||
['const obj = { [await Promise.resolve("value")]: 23 }; obj.value', 23],
|
||||
['const {basename} = await import("node:path"); basename("/a/b")', 'b'],
|
||||
['global === globalThis && typeof Buffer === "function" && typeof setTimeout === "function"', true],
|
||||
])('evaluates Script completion: %s', async (code, expected) => {
|
||||
expect(await evaluator().value(code)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('retains native and transformed declarations, closures, and class identity', async () => {
|
||||
const e = evaluator()
|
||||
await e.value('let count = 1; const box = {count}; function next() { return ++count }; class Native {}')
|
||||
await e.value('const imported = await import("node:path"); class Awaited extends Native {}; function read() { return box };')
|
||||
expect(await e.value('[next(), read() === box, new Awaited() instanceof Native, imported.basename("/a/b")]')).toEqual([2, true, true, 'b'])
|
||||
expect(await e.run('let count = 9')).toHaveProperty('error.message', expect.stringContaining('already been declared'))
|
||||
})
|
||||
|
||||
it('leaves a failed native lexical initializer uninitialized until its context is replaced', async () => {
|
||||
const e = evaluator()
|
||||
expect(await e.run('const poisoned = (() => { throw new Error("initializer failed") })()')).toMatchObject({ ok: false })
|
||||
for (const code of ['poisoned', 'poisoned = 2', 'const poisoned = 3']) expect(await e.run(code)).toMatchObject({ ok: false })
|
||||
expect(await e.value('const replacement = 4; replacement')).toBe(4)
|
||||
expect(await evaluator().value('const poisoned = 5; poisoned')).toBe(5)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'return 1', 'await Promise.resolve(); return 1', 'await Promise.resolve(); const = 1',
|
||||
'await Promise.resolve();\nconst = 1', 'await Promise.resolve(); "unterminated',
|
||||
'await Promise.resolve(); using resource = null;', 'await using resource = null;',
|
||||
])('reports a complete-submission syntax error without transformer frames: %s', async (code) => {
|
||||
const result = await evaluator().run(code)
|
||||
expect(result).toHaveProperty('error.message', expect.stringContaining('SyntaxError'))
|
||||
if (!result.ok) expect(String(result.error)).not.toContain('vm-script-await')
|
||||
})
|
||||
|
||||
it('keeps block-scoped disposable bindings local to the awaited evaluation', async () => {
|
||||
const e = evaluator()
|
||||
expect(await e.value('let disposed = false; { using item = { [Symbol.dispose]() { disposed = true } }; await Promise.resolve(); }; disposed')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves thrown primitives and contains hostile error formatting', async () => {
|
||||
const e = evaluator()
|
||||
expect(await e.run('throw "failure"')).toEqual({ ok: false, error: 'failure' })
|
||||
expect(await e.run('const error = new Error("hidden"); Object.defineProperty(error, "stack", { get() { throw 1 } }); throw error'))
|
||||
.toMatchObject({ ok: false, error: { message: 'program threw an unrenderable value' } })
|
||||
expect(await e.run('const bare = new Error("bare failure"); bare.stack = undefined; throw bare'))
|
||||
.toMatchObject({ ok: false, error: { message: 'bare failure' } })
|
||||
})
|
||||
|
||||
it.each(['const invalid = ; await 1\n', 'await\\a\n', 'await = 1\n'])('leaves earlier or ambiguous syntax to native compilation: %s', (code) => {
|
||||
expect(processTopLevelAwait(code)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not transform nested await or a top-level return', () => {
|
||||
expect(processTopLevelAwait('async function nested() { await 1 }\n')).toBeNull()
|
||||
expect(processTopLevelAwait('return await 1\n')).toBeNull()
|
||||
expect(processTopLevelAwait('\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('runs function-body parameters and Promise rejection through the evaluator entry', async () => {
|
||||
const e = evaluator('function-body')
|
||||
e.globals.answer = 42
|
||||
expect(await e.value('return answer')).toBe(42)
|
||||
expect(await e.run('throw new Error("rejected")')).toMatchObject({ ok: false, error: { message: 'rejected' } })
|
||||
expect(await e.run('const =')).toHaveProperty('error', expect.any(SyntaxError))
|
||||
})
|
||||
|
||||
it('falls back to native errors for input escaping the async wrapper', () => {
|
||||
expect(processTopLevelAwait('}); (async () => {\n')).toBeNull()
|
||||
expect(processTopLevelAwait('}).call(null); (async () => {\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an unfinished expression at the end of submitted input', () => {
|
||||
expect(() => processTopLevelAwait('await null; (\n')).toThrow(SyntaxError)
|
||||
})
|
||||
|
||||
it('preserves parser infrastructure failures without rewriting them as syntax errors', async () => {
|
||||
const failure = new Error('parser unavailable')
|
||||
const parse = vi.spyOn(Parser, 'parse').mockImplementation(() => { throw failure })
|
||||
try {
|
||||
expect(() => processTopLevelAwait('await 1\n')).toThrow(failure)
|
||||
expect(await evaluator().run('await 1')).toHaveProperty('error.message', failure.stack)
|
||||
}
|
||||
finally { parse.mockRestore() }
|
||||
})
|
||||
Reference in new issue
Block a user