fix(ptc-runtime-node): correct Script await handling and guidance

This commit is contained in:
_Kerman committed 2026-10-21 00:37:56 +08:00
1 parent c3473d1ea6
commit e82dddc45c
10 files changed
+71 -61

No files matched your search

@@ -39,8 +39,8 @@
en: 53e24b6ba2614d4c
zh: 0fe4d5ace9d2ef75
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps:
en: 3e30860547f88777
zh: d7ba84b1f88c3fb2
en: 735eb081e90834a6
zh: a657d9ef17be9871
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map:
en: a797a875fbfb0598
zh: 0cc0b19f8241f6bc
@@ -54,8 +54,8 @@
en: 40294c1c31b74824
zh: d91e7b78c0619c7e
/deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work:
en: f4c6a8a3e0b1220f
zh: 03fb9482097eabe7
en: 5a2f3b56db7cb92a
zh: c3eb766cbefc5918
/deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work/dev-note:
en: 6373aba4e08f7205
zh: 2a8c33d1f2839df4
@@ -127,9 +127,9 @@ Length-framed JSON travels separately from stdout/stderr. Every evaluation carri
### Source and built bootstraps
Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built script execution uses `process.js` for every mode; `persistent-process.js` delegates to the same loop with a REPL default for hosts that omit program syntax. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file.
Source execution loads an erasable-only bootstrap closure without relying on sibling built exports; its evaluator modules and installed Acorn dependencies must be available in the execution world. Built script execution uses `process.js` for every mode; `persistent-process.js` delegates to the same loop with a REPL default for hosts that omit program syntax. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file.
Scripts accepted by the native compiler execute unchanged. When compilation requires top-level await support, Acorn parses an async wrapper around the source. The transform follows Node's REPL visitors, declaration hoisting and character-position edits, including its boxed final-expression result; the evaluator unwraps that result and awaits its value. A local AST walker keeps Acorn as the only added dependency. Dynamic `import()` expressions remain native and use the compiling Script's `importModuleDynamically` option. Both private process bundles include Acorn; source execution imports the package directly.
Source containing `await` is checked for top-level await before native compilation, which can otherwise treat `await` as an identifier. Acorn parses an async wrapper, and acorn-walk traverses it using Node's REPL visitors, declaration hoisting and character-position edits. Computed class-method keys are traversed while method bodies remain separate evaluations. The transform boxes the final expression; the evaluator unwraps and awaits its value. Source that needs no lowering uses native compilation; submissions without `await` retain native syntax diagnostics. Dynamic `import()` expressions remain native and use the compiling Script's `importModuleDynamically` option. Both private process bundles include Acorn and acorn-walk; source execution imports the installed packages.
### Source map
@@ -188,6 +188,8 @@ These limits qualify the execution guarantees and retained output.
- **Cleanup inherits subprocess observability** — escaped descendants on a fallback platform may remain outside the managed range; see the subprocess provider's stated limits.
- **Default execution is one-shot** — retained state requires `persistent-in-memory` mode or Script mode with `scriptPersistence: true`; no mode offers a yield/wait API.
- **Await lowering relaxes declaration semantics** — in transformed programs, top-level `let`, `const`, and `class` bindings are mutable and initialized to `undefined` before execution; the native temporal dead zone does not apply. Function declarations populate retained globals when execution reaches them, and a `var` declaration without an initializer resets its binding to `undefined`. Direct-eval declarations stay within that evaluation. Top-level `using` declarations are rejected; put disposable bindings inside a block. Runtime stack locations refer to the transformed source. Scripts that compile without lowering keep native semantics.
- **Failed native lexical initialization can reserve a name** — if a top-level `let`, `const`, or `class` initializer throws before initializing its binding, that name remains uninitialized in the retained Script context. Reading, assigning or redeclaring it fails; use a new name or release and recreate the environment.
- **Script values can cross realms** — binding results, binding rejection classes and imported modules can use constructors from outside the Script context. `instanceof Array` or `instanceof Error` can be false for those values; use `Array.isArray` and the declared binding error class.
- **In-memory environments retain one process per live owner** — idle owners keep their process and heap until explicit release, owner disposal or failure. Fan-out can retain many processes; idle expiration remains deferred.
- **Output caps reject rather than retain every byte** — spill can preserve only the bounded result delivered by this provider.
- **Bindings are bounded at transport admission** — control limits do not bound the memory a host binding allocates while producing its result.
@@ -127,9 +127,9 @@ Host 擦除可擦除类型;需要启动进程时,在配置的执行世界中
### 源代码与构建后 bootstrap
源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后脚本在所有模式下都使用 `process.js`;`persistent-process.js` 委托同一个循环,供未指定程序语法的宿主默认使用 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。
源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出;执行世界中必须提供求值器模块和已安装的 Acorn 依赖。构建后脚本在所有模式下都使用 `process.js`;`persistent-process.js` 委托同一个循环,供未指定程序语法的宿主默认使用 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。
原生编译器接受的脚本直接执行。当编译需要顶层 await 支持时,Acorn 解析包住源码的异步函数。转换沿用 Node REPL 的 visitor、声明提升和按字符位置修改源码的方式,包括对末尾表达式结果的包装;求值器解包后等待该值。局部 AST 遍历器使新增依赖仍只有 Acorn。动态 `import()` 表达式保持原生形式,使用编译该 Script 时的 `importModuleDynamically` 选项。两个私有进程 bundle 都包含 Acorn;源码执行直接导入该包。
包含 `await` 的源码会在原生编译前检查顶层 await,否则原生编译可能将 `await` 当作标识符。Acorn 解析包住源码的异步函数,acorn-walk 沿用 Node REPL 的 visitor、声明提升和按字符位置修改源码的方式进行遍历。类方法的计算属性名参与遍历,方法体仍独立求值。转换包装末尾表达式,求值器解包并等待其值。无需转换的源码使用原生编译;不含 `await` 的提交保留原生语法诊断。动态 `import()` 表达式保持原生形式,使用编译该 Script 时的 `importModuleDynamically` 选项。两个私有进程 bundle 都包含 Acorn 和 acorn-walk;源码执行导入已安装的包。
### 源码索引
@@ -188,6 +188,8 @@ Host 擦除可擦除类型;需要启动进程时,在配置的执行世界中
- **清理继承子进程的可观测范围**——使用 fallback 的平台上,逃逸的后代可能仍在受管范围之外;参见子进程提供方声明的限制。
- **默认执行是一次性的**:保留状态需要设置 `persistent-in-memory` 模式,或在 Script 模式下启用 `scriptPersistence: true`;所有模式都不提供 yield/wait API。
- **await 转换放宽声明语义**——转换后程序的顶层 `let`、`const` 和 `class` 绑定可修改,且在执行前初始化为 `undefined`,不再具有原生暂时性死区。执行到函数声明时才会为其设置保留的全局变量;没有初始化器的 `var` 声明会将其绑定重置为 `undefined`。直接 eval 中的声明仅在本次求值内有效。不接受顶层 `using` 声明;可释放绑定需放在块内。运行时堆栈位置指向转换后的源码。无需转换即可编译的脚本保持原生语义。
- **原生词法初始化失败可能占用名称**——顶层 `let`、`const` 或 `class` 的初始化器在完成绑定初始化前抛错时,该名称会在保留的 Script context 中持续处于未初始化状态。读取、赋值和重新声明都会失败;应使用新名称,或释放并重新创建环境。
- **Script 值可能跨 realm**——绑定结果、绑定拒绝类和导入模块可能使用 Script context 之外的构造器。这些值的 `instanceof Array` 或 `instanceof Error` 可能为 false;应使用 `Array.isArray` 和声明的绑定错误类。
- **内存保留环境为每个存活所有者保留一个进程**——空闲所有者保留进程及堆内存,直到显式释放、所有者销毁或执行失败。并行创建多个所有者会保留多个进程;空闲过期机制留待后续处理。
- **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。
- **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-ptc-runtime-node",
"description": "Node PTC execution with per-call sandboxed processes or in-memory REPL state",
"description": "Node PTC execution with async function, REPL and VM Script evaluators and optional retained state",
"version": "0.2.1-rc.2",
"publishConfig": {
"access": "public"
@@ -46,7 +46,8 @@
"dependencies": {
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"acorn": "^8.18.0"
"acorn": "^8.18.0",
"acorn-walk": "^8.3.5"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -2,7 +2,7 @@
* Node REPL top-level-await transformation adapted for complete Script submissions.
* Source: Node.js v26.6.0, lib/internal/repl/await.js:
* https://github.com/nodejs/node/blob/v26.6.0/lib/internal/repl/await.js
* Uses public Acorn and a local walker; incomplete input is a SyntaxError.
* Uses public Acorn and acorn-walk; incomplete input is a SyntaxError.
*/
/*! @license
* Copyright Node.js contributors. All rights reserved.
@@ -27,11 +27,7 @@
*/
import { Parser } from 'acorn'
import type { AnyNode, BlockStatement, Pattern, Position, Program, Statement, VariableDeclarator } from 'acorn'
type Walk = (node: AnyNode, state: TransformState) => void
type Visitor<K extends AnyNode['type'] = AnyNode['type']> = (
node: Extract<AnyNode, { type: K }>, state: TransformState, c: Walk,
) => void
import * as walk from 'acorn-walk'
interface TransformState {
body: BlockStatement
@@ -49,7 +45,8 @@ function isTopLevelDeclaration(state: TransformState): boolean {
}
const noop = (): void => {}
const visitorsWithoutAncestors: { [K in AnyNode['type']]?: Visitor<K> } = {
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')
if (isTopLevelDeclaration(state)) {
@@ -57,13 +54,13 @@ const visitorsWithoutAncestors: { [K in AnyNode['type']]?: Visitor<K> } = {
state.hoistedDeclarationStatements.push(`let ${node.id.name}; `)
}
visitChildren(node, state, c)
base.ClassDeclaration(node, state, c)
},
ForOfStatement(node, state, c) {
if (node.await) {
state.containsAwait = true
}
visitChildren(node, state, c)
base.ForOfStatement(node, state, c)
},
FunctionDeclaration(node, state) {
if (node.id === null) throw new SyntaxError('Script declarations must be named')
@@ -72,15 +69,18 @@ const visitorsWithoutAncestors: { [K in AnyNode['type']]?: Visitor<K> } = {
},
FunctionExpression: noop,
ArrowFunctionExpression: noop,
MethodDefinition: noop,
MethodDefinition(node, state, c) {
// Computed keys execute while the class is defined; method bodies do not.
if (node.computed) c(node.key, state)
},
StaticBlock: noop,
AwaitExpression(node, state, c) {
state.containsAwait = true
visitChildren(node, state, c)
base.AwaitExpression(node, state, c)
},
ReturnStatement(node, state, c) {
state.containsReturn = true
visitChildren(node, state, c)
base.ReturnStatement(node, state, c)
},
VariableDeclaration(node, state, c) {
const variableKind = node.kind
@@ -155,33 +155,24 @@ const visitorsWithoutAncestors: { [K in AnyNode['type']]?: Visitor<K> } = {
})
}
visitChildren(node, state, c)
base.VariableDeclaration(node, state, c)
},
}
/** Acorn's child nodes provide the recursive traversal without an acorn-walk dependency. */
function visitChildren(node: AnyNode, state: TransformState, c: Walk): void {
const visit = (value: unknown): void => {
if (value !== null && typeof value === 'object' && 'type' in value) c(value as AnyNode, state)
}
for (const value of Object.values(node as AnyNode & Record<string, unknown>)) {
if (Array.isArray(value)) value.forEach(visit)
else visit(value)
}
}
function recursive(node: AnyNode, state: TransformState): void {
// Indexing by node.type selects the visitor accepting this exact Acorn node type.
const callback = (visitorsWithoutAncestors[node.type] ?? visitChildren) as Visitor
const isNew = node !== state.ancestors[state.ancestors.length - 1]
if (isNew) {
state.ancestors.push(node)
}
callback(node, state, recursive)
if (isNew) {
state.ancestors.pop()
}
}
type Visitor = (node: AnyNode, state: TransformState, c: walk.WalkerCallback<TransformState>) => void
const visitors: walk.RecursiveVisitors<TransformState> = Object.fromEntries(
Object.entries({ ...base, ...visitorsWithoutAncestors }).map(([type, visitor]) => {
// acorn-walk selects the callback by the matching node or aggregate type.
const callback = visitor as Visitor
const withAncestors: Visitor = (node, state, c) => {
const isNew = node !== state.ancestors[state.ancestors.length - 1]
if (isNew) state.ancestors.push(node)
callback(node, state, c)
if (isNew) state.ancestors.pop()
}
return [type, withAncestors]
}),
)
/** Keep the native-Node bootstrap independent of workspace packages' built exports. */
function assertNever(value: never): never {
@@ -205,7 +196,6 @@ export function processTopLevelAwait(src: string): string | null {
if (!(error instanceof SyntaxError)) throw error
// Acorn syntax errors include offsets and positions; batches cannot request more REPL input.
const e = error as SyntaxError & { pos: number; loc: Position }
if (e.message.startsWith('Unterminated ')) throw e
// If the parse error is before the first "await", then use the execution
// error. Otherwise we must emit this parse error, making it look like a
// proper syntax error.
@@ -253,7 +243,7 @@ export function processTopLevelAwait(src: string): string | null {
containsReturn: false,
}
recursive(body, state)
walk.recursive(body, state, visitors)
// Do not transform if
// 1. False alarm: there isn't actually an await expression.
@@ -67,16 +67,18 @@ async function evaluateScript(code: string, context: Context): Promise<unknown>
}
}
/** Native scripts bypass conversion; only compilation failures enter the await transformer. */
/** Detect top-level await before native compilation can interpret await as an identifier. */
function compileScript(code: string, options: ScriptOptions): { script: Script; transformed: boolean } {
try {
return { script: new Script(code, options), transformed: false }
} catch (error: unknown) {
if (!(error instanceof SyntaxError)) throw error
if (code.includes('await')) {
// Complete submissions need no REPL continuation; the newline terminates trailing comments.
const source = code.startsWith('#!') ? `//${code.slice(2)}` : code
const transformed = processTopLevelAwait(`${source}\n`)
if (transformed === null) throw error
return { script: new Script(transformed, options), transformed: true }
let transformed: string | null
try { transformed = processTopLevelAwait(`${source}\n`) } catch (error: unknown) {
// Parser diagnostics describe the program; transformer stack frames do not.
if (error instanceof SyntaxError) error.stack = `${error.name}: ${error.message}`
throw error
}
if (transformed !== null) return { script: new Script(transformed, options), transformed: true }
}
return { script: new Script(code, options), transformed: false }
}
@@ -166,11 +166,13 @@ function resolveMode(config: ResolvedConfig): ExecutionMode {
const lifetime = config.scriptPersistence
? 'Variables, functions, and imported modules remain in memory for this Agent across calls.'
: 'Each call runs in a fresh Node process.'
const recovery = config.scriptPersistence ? ' ' + RETAINED_STATE_INSTRUCTIONS : ''
const recovery = config.scriptPersistence
? ' ' + RETAINED_STATE_INSTRUCTIONS + ' If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment.'
: ''
return {
format: 'script',
retainsState: config.scriptPersistence,
executionInstructions: 'The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. ' + lifetime + ' Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty.' + recovery,
executionInstructions: 'The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. ' + lifetime + ' Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false.' + recovery,
stripTypes: stripTypeScriptTypes,
}
}
@@ -1,5 +1,5 @@
/**
* Lossless-JSON snapshots for the dependency-free source bootstrap closure.
* Lossless-JSON snapshots without workspace runtime imports in the source bootstrap.
* @module @deepseek-ai/dsh-ptc-runtime-node/json-wire
*/
@@ -3,6 +3,6 @@ import { defineConfig } from 'tsdown'
/** The Node provider and its private process bootstraps are published bundles. */
export default defineConfig([
{ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false },
{ entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'acorn'] },
{ entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'acorn'] },
{ entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'acorn', 'acorn-walk'] },
{ entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'acorn', 'acorn-walk'] },
])
+11
View File
@@ -12117,6 +12117,9 @@ importers:
acorn:
specifier: ^8.18.0
version: 8.18.0
acorn-walk:
specifier: ^8.3.5
version: 8.3.5
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -19933,6 +19936,10 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
[email protected]:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
[email protected]:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
@@ -27374,6 +27381,10 @@ snapshots:
dependencies:
acorn: 8.17.0
[email protected]:
dependencies:
acorn: 8.18.0
[email protected]: {}
[email protected]: {}