From cf6e05df6d79d4e52df16a5a992b505d382665ab Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 21:07:10 +0800 Subject: [PATCH] fix(ptc): preserve import semantics across evaluation lifetimes --- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 4 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 4 +- .../ptc-runtime-node/src/environment.ts | 5 +- .../ptc-runtime-node/src/evaluators/types.ts | 2 +- .../src/imports/evaluation-ended.ts | 11 ++ .../src/imports/module-hooks.ts | 12 +- .../src/imports/vm-modules.ts | 71 ++++++++--- .../src/persistent-process.ts | 2 +- .../ptc-runtime-node/src/process.ts | 12 +- .../ptc-runtime-node/tests/imports.spec.ts | 118 ++++++++++++++++++ .../ptc-script-imports/workspace/entry.mjs | 2 + 12 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/imports/evaluation-ended.ts diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 7e34a3562d..be7dd59c0a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -42,8 +42,8 @@ en: b857f3cf0045fd10 zh: 70953359243d8b96 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/runner-import-handling: - en: 13adfb84310fefc7 - zh: 282e044c0e8bcb02 + en: 9caee42df6554e2e + zh: ab7f69be3fdc364c /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map: en: af96c044b4fbda62 zh: 481180e36cc7e951 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 83e201d5c4..9d5e1f8443 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -146,11 +146,11 @@ The handler receives each program's dynamic imports and, in VM Script, static an - `{ type: 'redirect', specifier }` resolves and loads the replacement specifier with the original attributes. - `{ type: 'module', exports }` exposes own enumerable string properties as module exports, including a property named `default`. -Default and redirected imports resolve relative to the importing file; the replacement specifier does not reenter the handler. VM Script uses import-meta-resolve for package resolution and loads `.mjs` files and `.js` files in `type: module` packages through `SourceTextModule` in the Script context. Their static dependencies, re-exports and later dynamic imports consult the same async-capable handler. Each evaluator caches file modules by resolved URL, including loading, linking and evaluation failures; static imports bind once. Modules retain `import.meta.url`, `filename`, `dirname`, `resolve()` and `main: false`. Other formats use Node's loader, as do all imports when no handler is supplied. A thrown or rejected handler value rejects the importing graph without fallback; unrelated graphs remain usable. VM Script rejects source-phase imports before calling the handler and rejects import attributes on VM-loaded ESM. AsyncFunction and Node REPL retain Node's native dependency and source-phase loading rules. +Default and redirected imports resolve relative to the importing file; the replacement specifier does not reenter the handler. VM Script uses Node's ESM resolver with the runner's active conditions and flags, and loads `.mjs` files and `.js` files in `type: module` packages through `SourceTextModule` in the Script context. Their static dependencies, re-exports and later dynamic imports consult the same async-capable handler. Each evaluator caches file modules by resolved URL, including module and handler failures; static imports bind once. A later evaluation reloads modules whose linking or evaluation failed because a previous evaluation ended, while successfully evaluated modules retain their identity. Modules retain `import.meta.url`, `filename`, `dirname`, `resolve()` and `main: false`. Other formats use Node's loader, as do all imports when no handler is supplied. A thrown or rejected handler value rejects the importing graph without fallback; unrelated graphs remain usable. VM Script rejects source-phase imports before calling the handler and rejects import attributes on VM-loaded ESM. AsyncFunction and Node REPL retain Node's native dependency and source-phase loading rules. Export values can be arbitrary runner-local objects: prototypes, private fields, closures, cycles and synchronous methods retain their normal behavior. One exports object identifies one module per evaluator; names and values are captured once, while referenced objects remain mutable. Returning a different exports object creates another module. The handler is consulted on every dynamic import and each static request during linking. Module evaluation failures remain cached for that exports object. A callable `then` export follows JavaScript's dynamic-import Promise assimilation in AsyncFunction and VM Script; Node REPL rejects the resulting non-module value, so use another export name there. -AsyncFunction and Node REPL share synchronous `registerHooks` and a symbol-keyed global export bridge. A VM-compiled factory calls the native AsyncFunction constructor with Node's default loader, preserving strict function-body syntax and parameter bindings. Each evaluator and compilation filename has a distinct source URL so V8's constructor cache retains the correct import handler and referrer. REPL uses `AsyncLocalStorage` to distinguish evaluators sharing Node's cwd-based import URL. VM Script owns ESM loading and linking through per-module callbacks; Node retains evaluation state. Cleanup releases evaluator caches and removes hooks and bridges; Node retains natively loaded ESM namespaces and symbol registry keys until process exit. Imports started from a completed evaluation and async handler results arriving after completion reject; a saved function invoked by a later active evaluation can import normally. +AsyncFunction and Node REPL share synchronous `registerHooks` and a symbol-keyed global export bridge. A VM-compiled factory calls the native AsyncFunction constructor with Node's default loader, preserving strict function-body syntax and parameter bindings. Each evaluator and compilation filename has a distinct source URL so V8's constructor cache retains the correct import handler and referrer. REPL uses `AsyncLocalStorage` to distinguish evaluators sharing Node's cwd-based import URL. VM Script owns ESM loading and linking through per-module callbacks; Node retains evaluation state. Cleanup releases evaluator caches and removes hooks and bridges; Node retains natively loaded ESM namespaces and symbol registry keys until process exit. Imports started or resolved after a retained evaluation ends reject; temporary evaluations keep these imports pending until process exit. A saved function invoked by a later active evaluation can import normally. ### Source map diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 2dfef5ad8d..a1c8446360 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -146,11 +146,11 @@ handler 接收程序的动态导入;在 VM Script 中,还接收 VM 加载的 - `{ type: 'redirect', specifier }` 使用原始属性解析并加载替换后的模块标识符。 - `{ type: 'module', exports }` 将自有、可枚举的字符串属性暴露为模块导出,包括名为 `default` 的属性。 -默认导入和重定向导入相对于实际导入文件解析;替换后的模块标识符不会再次进入 handler。VM Script 使用 import-meta-resolve 解析包,通过 `SourceTextModule` 在 Script context 内加载 `.mjs` 文件以及 `type: module` 包中的 `.js` 文件。其静态依赖、再导出和稍后的动态导入都调用同一个支持异步决策的 handler。每个求值器按解析后的 URL 缓存文件模块,包括加载、链接和求值失败;静态导入只绑定一次。模块保留 `import.meta.url`、`filename`、`dirname`、`resolve()` 和 `main: false`。其他格式使用 Node 加载器;未提供 handler 时,所有导入也使用 Node 加载器。handler 抛出值或拒绝会使导入该依赖的模块图失败,不会回退;无关模块图仍可使用。VM Script 在调用 handler 前拒绝 source 阶段导入,并拒绝 VM 加载的 ESM 上的导入属性。AsyncFunction 和 Node REPL 保留 Node 原生依赖加载与 source 阶段规则。 +默认导入和重定向导入相对于实际导入文件解析;替换后的模块标识符不会再次进入 handler。VM Script 使用 Node ESM 解析器,沿用 runner 当前启用的条件和标志,通过 `SourceTextModule` 在 Script context 内加载 `.mjs` 文件以及 `type: module` 包中的 `.js` 文件。其静态依赖、再导出和稍后的动态导入都调用同一个支持异步决策的 handler。每个求值器按解析后的 URL 缓存文件模块,包括模块与 handler 失败;静态导入只绑定一次。若模块的链接或求值因先前求值结束而失败,后续求值会重新加载该模块,成功求值的模块仍保留身份。模块保留 `import.meta.url`、`filename`、`dirname`、`resolve()` 和 `main: false`。其他格式使用 Node 加载器;未提供 handler 时,所有导入也使用 Node 加载器。handler 抛出值或拒绝会使导入该依赖的模块图失败,不会回退;无关模块图仍可使用。VM Script 在调用 handler 前拒绝 source 阶段导入,并拒绝 VM 加载的 ESM 上的导入属性。AsyncFunction 和 Node REPL 保留 Node 原生依赖加载与 source 阶段规则。 导出值可以是 runner 内的任意对象:原型、私有字段、闭包、循环引用和同步方法都保持正常行为。一个 exports 对象在每个求值器中标识一个模块;名称和值只捕获一次,引用的对象仍可修改。返回不同的 exports 对象会创建另一个模块。每次动态导入以及链接时的每个静态请求都会调用 handler。模块求值失败也会针对该 exports 对象缓存。可调用的 `then` 导出在 AsyncFunction 和 VM Script 中遵循 JavaScript 动态导入的 Promise 同化规则;Node REPL 会拒绝由此产生的非模块值,因此在 REPL 中需使用其他导出名。 -AsyncFunction 和 Node REPL 共用同步 `registerHooks` 和以 symbol 为键的全局导出桥接对象。经 VM 编译的工厂使用 Node 默认加载器调用原生 AsyncFunction 构造器,保留严格模式函数体语法和参数绑定。每个求值器与编译文件名组合使用独立的 source URL,确保 V8 构造器缓存保留正确的 import handler 和 referrer。REPL 使用 `AsyncLocalStorage` 区分共享 Node 基于 cwd 生成的导入 URL 的求值器。VM Script 通过逐模块回调管理 ESM 加载与链接;Node 保留求值状态。清理时释放求值器缓存,并移除 hook 和桥接对象;Node 会将原生加载的 ESM 命名空间和 symbol 注册表键保留到进程退出。从已完成求值中发起的导入,以及求值完成后才返回的异步 handler 结果都会被拒绝;保存的函数在后续活动求值中调用时可以正常导入。 +AsyncFunction 和 Node REPL 共用同步 `registerHooks` 和以 symbol 为键的全局导出桥接对象。经 VM 编译的工厂使用 Node 默认加载器调用原生 AsyncFunction 构造器,保留严格模式函数体语法和参数绑定。每个求值器与编译文件名组合使用独立的 source URL,确保 V8 构造器缓存保留正确的 import handler 和 referrer。REPL 使用 `AsyncLocalStorage` 区分共享 Node 基于 cwd 生成的导入 URL 的求值器。VM Script 通过逐模块回调管理 ESM 加载与链接;Node 保留求值状态。清理时释放求值器缓存,并移除 hook 和桥接对象;Node 会将原生加载的 ESM 命名空间和 symbol 注册表键保留到进程退出。保留模式求值结束后才发起或返回结果的导入会被拒绝;临时求值让这些导入保持挂起,直到进程退出。保存的函数在后续活动求值中调用时可以正常导入。 ### 源码索引 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/environment.ts b/packages/ptc-runtime/ptc-runtime-node/src/environment.ts index 3ffb07f11e..3695b1668a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/environment.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/environment.ts @@ -6,8 +6,9 @@ export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet = new Set(['PATH', ' /** * Clear inherited credentials while preserving native child-process startup paths. - * Refresh built-in ESM exports so preloaded node:process imports observe the replacement. - * @param state - Child process environment, replaced with an empty program-visible object. + * With the native child process, preloaded node:process exports observe the replacement. + * Isolated state objects only replace their own environment; they do not replace the native process. + * @param state - Native child process in production, or isolated state in in-process tests. */ export function clearProgramEnvironment(state: { env: NodeJS.ProcessEnv }): void { for (const key of Object.keys(state.env)) { diff --git a/packages/ptc-runtime/ptc-runtime-node/src/evaluators/types.ts b/packages/ptc-runtime/ptc-runtime-node/src/evaluators/types.ts index 89af552249..b9fef5e620 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/evaluators/types.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/evaluators/types.ts @@ -28,7 +28,7 @@ export type ProgramImportResult = * Intercept program imports inside the runner, independently of evaluation syntax. * VM Script recursively loads .mjs files and .js files in type: module packages in its context. * Their static and dynamic imports also consult the handler; each static request binds once. - * Default and redirect resolve relative to the importer; the replacement itself does not reenter the handler. + * Default and redirect use Node's active resolution conditions relative to the importer; replacements do not reenter the handler. * Other formats, AsyncFunction and Node REPL use Node's loader without intercepting dependencies. * A thrown or rejected value rejects the program's import; there is no fallback after failure. * VM Script accepts async handlers and requires --experimental-vm-modules. diff --git a/packages/ptc-runtime/ptc-runtime-node/src/imports/evaluation-ended.ts b/packages/ptc-runtime/ptc-runtime-node/src/imports/evaluation-ended.ts new file mode 100644 index 0000000000..4e7a8b0aa2 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/imports/evaluation-ended.ts @@ -0,0 +1,11 @@ +/** Import cancellation belongs to an evaluation, not to the imported module's cached failure. */ +export class ImportEvaluationEndedError extends Error { + /** Whether a later evaluation can reuse this process after the cancelled import. */ + readonly retainsState: boolean + + constructor(retainsState: boolean, pending = false) { + super(pending ? 'PTC evaluation ended before its import completed' + : 'PTC evaluation has ended; import modules from a new run_code evaluation') + this.retainsState = retainsState + } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/imports/module-hooks.ts b/packages/ptc-runtime/ptc-runtime-node/src/imports/module-hooks.ts index ee172b22cb..83ffe4864c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/imports/module-hooks.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/imports/module-hooks.ts @@ -5,6 +5,7 @@ import * as module from 'node:module' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import type { ProgramImportHandler } from '../evaluators/types.ts' +import { ImportEvaluationEndedError } from './evaluation-ended.ts' /* v8 ignore next 4 -- Typed decisions exhaust this union; the standalone child cannot depend on built workspace packages. */ function assertNever(value: never): never { @@ -36,7 +37,15 @@ export class ModuleHooksImportLoader { const filename = this.programs.get(context.parentURL) ?? (this.scope.getStore() ? this.replPrograms.get(context.parentURL) : undefined) if (filename === undefined) return nextResolve(specifier, context) - const result = handler({ specifier, referrer: filename, attributes: context.importAttributes }) + let result + try { + result = handler({ specifier, referrer: filename, attributes: context.importAttributes }) + } catch (error) { + if (error instanceof ImportEvaluationEndedError && !error.retainsState) { + return { url: 'data:text/javascript,await new Promise(() => {})', shortCircuit: true } + } + throw error + } if ('then' in result) { // Native hooks cannot await this result; consume a rejection before reporting unsupported handling. void result.catch(() => {}) @@ -53,6 +62,7 @@ export class ModuleHooksImportLoader { }, load: (url, context, nextLoad) => { const source = this.sources.get(url) + this.sources.delete(url) return source === undefined ? nextLoad(url, context) : { format: 'module', source, shortCircuit: true } }, }) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/imports/vm-modules.ts b/packages/ptc-runtime/ptc-runtime-node/src/imports/vm-modules.ts index 10d25884ee..fa51da8912 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/imports/vm-modules.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/imports/vm-modules.ts @@ -4,10 +4,10 @@ import { readFile } from 'node:fs/promises' import { dirname, extname } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { types } from 'node:util' -import { resolve as resolveImport } from 'import-meta-resolve' import { getPackageType } from 'import-meta-resolve/lib/package-json-reader.js' -import type { ImportAttributes } from 'node:module' +import { registerHooks, type ImportAttributes } from 'node:module' import type { ProgramImportHandler } from '../evaluators/types.ts' +import { ImportEvaluationEndedError } from './evaluation-ended.ts' /* v8 ignore next 4 -- Typed decisions exhaust this union; the standalone child cannot depend on built workspace packages. */ function assertNever(value: never): never { @@ -26,6 +26,7 @@ const SourceModule = vm.SourceTextModule as new (code: string, options: vm.Sourc interface ModuleRecord { module: vm.Module dependencies?: Promise + expired?: boolean } type ImportResult = ModuleRecord | { namespace: Record } @@ -91,10 +92,19 @@ export class VmModuleImportLoader { return load as vm.DynamicModuleLoader } - private async request(specifier: string, referrer: string, attributes: ImportAttributes, phase: string): Promise { + private async request( + specifier: string, referrer: string, attributes: ImportAttributes, phase: string, staticImport = false, + ): Promise { this.assertOpen() if (phase !== 'evaluation') throw new Error('PTC importHandler supports evaluation-phase imports only') - const result = await this.handler({ specifier, referrer: fileURLToPath(referrer), attributes }) + let result + try { + result = await this.handler({ specifier, referrer: fileURLToPath(referrer), attributes }) + } catch (error) { + // A closing temporary process must not resume detached program catch handlers. + if (error instanceof ImportEvaluationEndedError && !error.retainsState) return new Promise(() => {}) + throw error + } this.assertOpen(true) switch (result.type) { case 'module': return this.module(result.exports) @@ -106,12 +116,14 @@ export class VmModuleImportLoader { const url = new URL(resolveImport(specifier, referrer)) if (isSourceModule(url)) { if (Object.keys(attributes).length) throw new TypeError(`PTC ESM imports do not accept import attributes: ${url.href}`) - let record = this.files.get(url.href) - if (!record) { - record = this.sourceModule(url) - this.files.set(url.href, record) - } - return record + return this.file(url) + } + if (staticImport) { + // Export the namespace as a value so native Promise assimilation cannot consume its then export. + const source = `import * as namespace from ${JSON.stringify(url.href)} with ${JSON.stringify(attributes)}; export { namespace }` + const loaded = await this.nativeImport(`data:text/javascript,${encodeURIComponent(source)}`, {}) as { namespace: Record } + this.assertOpen(true) + return { namespace: loaded.namespace } } const value = await this.nativeImport(url.href, attributes) this.assertOpen(true) @@ -120,6 +132,20 @@ export class VmModuleImportLoader { return this.module({ then(resolve: (result: unknown) => void) { resolve(value) } }) } + private async file(url: URL): Promise { + let pending = this.files.get(url.href) + if (!pending) { + pending = this.sourceModule(url) + this.files.set(url.href, pending) + } + const record = await pending + if (record.expired || (record.module.status === 'errored' && record.module.error instanceof ImportEvaluationEndedError)) { + if (this.files.get(url.href) === pending) this.files.delete(url.href) + return this.file(url) + } + return record + } + private async sourceModule(url: URL): Promise { const source = await readFile(url, 'utf8') this.assertOpen(true) @@ -142,15 +168,20 @@ export class VmModuleImportLoader { if (visited.has(record)) return visited.add(record) record.dependencies ??= this.dependencies(record.module) - const dependencies = await record.dependencies - this.assertOpen(true) - await Promise.all(dependencies.map(dependency => this.link(dependency, visited))) + try { + const dependencies = await record.dependencies + this.assertOpen(true) + await Promise.all(dependencies.map(dependency => this.link(dependency, visited))) + } catch (error) { + if (error instanceof ImportEvaluationEndedError) record.expired = true + throw error + } } private async dependencies(module: vm.Module): Promise { if (module instanceof SourceModule) { const dependencies = await Promise.all(module.moduleRequests.map(async (request) => { - const result = await this.request(request.specifier, module.identifier, request.attributes, request.phase) + const result = await this.request(request.specifier, module.identifier, request.attributes, request.phase, true) return 'namespace' in result ? this.module(result.namespace) : result })) this.assertOpen(true) @@ -177,6 +208,18 @@ export class VmModuleImportLoader { } } +/** Resolve with the native loader's conditions and flags while retaining the importing filename. */ +function resolveImport(specifier: string, parentURL: string): string { + const marker = 'dsh-ptc:resolve' + const hooks = registerHooks({ + resolve(request, context, nextResolve) { + return request === marker ? nextResolve(specifier, { ...context, parentURL }) : nextResolve(request, context) + }, + }) + try { return import.meta.resolve(marker) } + finally { hooks.deregister() } +} + /** Explicit file ESM stays in the evaluator; other formats retain Node's loader. */ function isSourceModule(url: URL): boolean { if (url.protocol !== 'file:') return false diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts index 5d047b267b..010fcfc359 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -9,7 +9,7 @@ import type { ProgramImportHandler } from './evaluators/types.ts' * Serve evaluations through the installed retained-bootstrap entry. * @param stream - Inherited private control endpoint. * @param maxMessageBytes - Maximum control frame and queued output bytes. - * @param processState - Child environment, output and directory control. + * @param processState - Native child process; structural test doubles do not replace Node's globals or built-in exports. * @param format - Default syntax for hosts that omit it from program data. * @param importHandler - Runner-local import decisions; omission retains native loading. * @returns After control closure and evaluator cleanup. diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index c4c2cf145f..1f14e43ff2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -7,6 +7,7 @@ import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, import type { BindingErrorConstructor, PatchableStream } from './bootstrap.ts' import { clearProgramEnvironment } from './environment.ts' import { createProgramEvaluator } from './evaluators/index.ts' +import { ImportEvaluationEndedError } from './imports/evaluation-ended.ts' import type { ProgramEvaluator, ProgramImportHandler, ProgramImportResult } from './evaluators/types.ts' import type { ProgramResult, HostMethods, ProgramMethods } from './protocol.ts' @@ -31,7 +32,7 @@ interface Evaluation { * Serve sequential evaluations until the host closes the control channel. * @param stream - Inherited, already-adopted control endpoint. * @param maxMessageBytes - Maximum control message and queued output bytes. - * @param processState - Child environment, output, directory control and exit status. + * @param processState - Native child process; structural test doubles do not replace Node's globals or built-in exports. * @param defaultFormat - Syntax used by installed bootstraps when the host omits it. * @param importHandler - Runner-local import decisions; omission retains native loading. * @returns After control closure and evaluator cleanup. @@ -94,13 +95,16 @@ export async function runNodeMain( if (active?.active && error) active.finish(prepareException(error, active.logs.remainingOutputBytes(), active.maxOutputBytes)) }, importHandler === undefined ? undefined : (request) => { const active = context.getStore() - if (!active?.active) throw new Error('PTC evaluation has ended; import modules from a new run_code evaluation') + if (!active?.active) throw new ImportEvaluationEndedError(active?.reusable ?? true) const result = importHandler(request) const checkActive = (decision: ProgramImportResult): ProgramImportResult => { - if (!active.active) throw new Error('PTC evaluation ended before its import completed') + if (!active.active) throw new ImportEvaluationEndedError(active.reusable, true) return decision } - return 'then' in result ? result.then(checkActive) : checkActive(result) + return 'then' in result ? result.then(checkActive, (error: unknown) => { + if (!active.active) throw new ImportEvaluationEndedError(active.reusable, true) + throw error + }) : checkActive(result) }) evaluator.globals.console = makeConsoleShim(logSink) // Context access must not reserve a function-body parameter named process. diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/imports.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/imports.spec.ts index e1a6b9bb73..702497de07 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/imports.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/imports.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { Duplex, PassThrough } from 'node:stream' +import { registerHooks } from 'node:module' import { expect, it, onTestFinished, vi } from 'vitest' import { createProgramEvaluator } from '../src/evaluators/index.ts' import type { ProgramEvaluator, ProgramImportHandler, ProgramImportRequest, ProgramImportResult } from '../src/evaluators/types.ts' @@ -241,6 +242,44 @@ it('keeps then exports callable from static ESM imports and assimilates them fro expect(await e.expression(`import(${JSON.stringify(fixture.url('then.mjs'))})`)).toBe(17) }) +it('keeps every native export when a static dependency exports a callable then', async () => { + const native = 'data:text/javascript,export const answer = 42; export function then(resolve) { resolve(17) }' + const fixture = await moduleFixture({ + 'entry.mjs': `import { answer, then } from ${JSON.stringify(native)}; export const result = [answer, typeof then]`, + }) + const e = evaluator('script', () => ({ type: 'default' })) + expect(await e.expression(`(await import(${JSON.stringify(fixture.url('entry.mjs'))})).result`)).toEqual([42, 'function']) + expect(await e.expression(`import(${JSON.stringify(native)})`)).toBe(17) +}) + +it.each(['node-addons', 'module-sync'])('uses Node %s conditions for default, redirected, and import.meta resolution', async (condition) => { + const fixture = await moduleFixture({ + 'entry.mjs': 'import { answer } from "conditional"; export { answer }; export const resolved = import.meta.resolve("conditional")', + 'node_modules/conditional/package.json': JSON.stringify({ type: 'module', exports: { [condition]: './selected.js', default: './fallback.js' } }), + 'node_modules/conditional/selected.js': 'export const answer = 42', + 'node_modules/conditional/fallback.js': 'export const answer = 17', + }) + const e = evaluator('script', ({ specifier }) => specifier === 'runner:redirect' + ? { type: 'redirect', specifier: fixture.url('entry.mjs') } : { type: 'default' }) + for (const specifier of [fixture.url('entry.mjs'), 'runner:redirect']) { + expect(await e.expression(`import(${JSON.stringify(specifier)}).then(m => [m.answer, m.resolved])`)) + .toEqual([42, fixture.url('node_modules/conditional/selected.js')]) + } +}) + +it('preserves nested native resolution performed by another registered hook', async () => { + const fixture = await moduleFixture({ 'entry.mjs': 'export const answer = 42' }) + let nested: string | undefined + const hooks = registerHooks({ resolve(specifier, context, nextResolve) { + if (specifier === fixture.url('entry.mjs')) nested = import.meta.resolve('node:path') + return nextResolve(specifier, context) + } }) + onTestFinished(() => { hooks.deregister() }) + const e = evaluator('script', () => ({ type: 'default' })) + expect(await e.expression(`(await import(${JSON.stringify(fixture.url('entry.mjs'))})).answer`)).toBe(42) + expect(nested).toBe('node:path') +}) + it('retains live dynamic native bindings while static imports capture their exports', async () => { const source = 'data:text/javascript,export let value = 1; export function update() { value = 2 }' const fixture = await moduleFixture({ 'entry.mjs': `export { value } from ${JSON.stringify(source)}` }) @@ -461,6 +500,20 @@ it.each([false, true])('uses the active evaluation when a retained function impo expect(await r.run('(await saved()).count')).toBe(2) }) +it('rejects a runner invocation outside an evaluation while allowing the saved function in a later evaluation', async () => { + let saved: (() => Promise) | undefined + const holder = { save(fn: () => Promise) { saved = fn } } + const handler = vi.fn(({ specifier }: ProgramImportRequest): ProgramImportResult => ({ + type: 'module', exports: specifier === 'runner:holder' ? holder : { answer: 42 }, + })) + const r = await runner(handler) + await r.run('var saved = () => import("runner:answer"); (await import("runner:holder")).save(saved)') + if (saved === undefined) throw new Error('program did not publish its import function') + await expect(saved()).rejects.toThrow('PTC evaluation has ended; import modules from a new run_code evaluation') + expect(handler).toHaveBeenCalledTimes(1) + expect(await r.run('(await saved()).answer')).toBe(42) +}) + it.each(['script', 'repl'] as const)('rejects a finished %s evaluation before a delayed import can invoke the handler', async (format) => { const handler = vi.fn((): ProgramImportResult => ({ type: 'default' })) const r = await runner(handler, false, format) @@ -482,6 +535,71 @@ it('rejects a late handler result before constructing its module', async () => { expect(read).not.toHaveBeenCalled() }) +it.each(['link', 'evaluate'] as const)('retries an ESM graph whose background %s expired in an earlier retained evaluation', async (phase) => { + const fixture = await moduleFixture({ + 'entry.mjs': 'export { answer } from "./dependency.mjs"', + 'dependency.mjs': phase === 'link' + ? 'import { answer } from "runner:answer"; export { answer }' + : 'export const answer = (await import("runner:answer")).answer', + }) + const entered: PromiseWithResolvers = Promise.withResolvers() + const released = Promise.withResolvers() + onTestFinished(() => { released.resolve({ type: 'module', exports: { answer: 42 } }) }) + let pending = true + const r = await runner(({ specifier }) => { + if (specifier === 'runner:gates') return { type: 'module', exports: { entered: entered.promise } } + if (specifier !== 'runner:answer') return { type: 'default' } + entered.resolve() + return pending ? released.promise : { type: 'module', exports: { answer: 42 } } + }, true) + await r.run(`var gates = await import('runner:gates'); var late = import(${JSON.stringify(fixture.url('entry.mjs'))}).catch(error => error.message); await gates.entered; void 0`) + released.resolve({ type: 'module', exports: { answer: 17 } }) + expect(await r.run('await late')).toMatch(/PTC evaluation (has ended|ended before)/) + pending = false + expect(await r.run(`Promise.all([import(${JSON.stringify(fixture.url('entry.mjs'))}), import(${JSON.stringify(fixture.url('entry.mjs'))})]).then(([a, b]) => [a.answer, a === b])`)).toEqual([42, true]) +}) + +it.each(formats)('does not resume a detached %s import after a temporary evaluation ends', async (format) => { + const released: PromiseWithResolvers = Promise.withResolvers() + const resumed = vi.fn() + const handler = vi.fn((): ProgramImportResult => ({ type: 'module', exports: { wait: released.promise, resumed } })) + const r = await runner(handler, false, format, false) + await r.run('var control = await import("runner:control"); void control.wait.then(() => import("node:path")).then(control.resumed, control.resumed);') + released.resolve() + // Reach the next task after the released Promise's import and refusal microtasks. + await r.run('await new Promise(resolve => setImmediate(resolve)); void 0') + expect(handler).toHaveBeenCalledTimes(1) + expect(resumed).not.toHaveBeenCalled() +}) + +it.each(['resolve', 'reject'] as const)('keeps a temporary import pending when its handler later %ss', async (settlement) => { + const entered: PromiseWithResolvers = Promise.withResolvers() + const released = Promise.withResolvers() + const resumed = vi.fn() + const read = vi.fn(() => 42) + const r = await runner(({ specifier }) => { + if (specifier === 'runner:control') return { type: 'module', exports: { entered: entered.promise, resumed } } + entered.resolve() + return released.promise + }, false, 'script', false) + await r.run('var control = await import("runner:control"); void import("runner:late").then(control.resumed, control.resumed); await control.entered; void 0') + if (settlement === 'resolve') released.resolve({ type: 'module', exports: Object.defineProperty({}, 'answer', { enumerable: true, get: read }) }) + else released.reject(new Error('late handler failure')) + await r.run('await new Promise(resolve => setImmediate(resolve)); void 0') + expect(read).not.toHaveBeenCalled() + expect(resumed).not.toHaveBeenCalled() +}) + +it('preserves an active handler rejection and classifies a retained late rejection as expired', async () => { + const rejected = Promise.withResolvers() + const r = await runner(({ specifier }) => specifier === 'runner:active' + ? Promise.reject(new Error('active handler failure')) : rejected.promise) + expect(await r.run('await import("runner:active").catch(error => error.message)')).toBe('active handler failure') + await r.run('var late = import("runner:late").catch(error => error.message);') + rejected.reject(new Error('late handler failure')) + expect(await r.run('await late')).toBe('PTC evaluation ended before its import completed') +}) + it.each(formats)('passes the runner handler through a temporary process evaluation for %s', async (programFormat) => { const r = await runner(() => ({ type: 'module', exports: { answer: () => 42 } }), false, programFormat, false) expect(await r.run(`${programFormat === 'function-body' ? 'return ' : ''}(await import('runner:answer')).answer()`)).toBe(42) diff --git a/snapshots/session/ptc-script-imports/workspace/entry.mjs b/snapshots/session/ptc-script-imports/workspace/entry.mjs index e65bc6431e..37320186a8 100644 --- a/snapshots/session/ptc-script-imports/workspace/entry.mjs +++ b/snapshots/session/ptc-script-imports/workspace/entry.mjs @@ -1,7 +1,9 @@ import { Counter, counter } from 'runner:counter' import { env } from 'node:process' +import { answer, then } from 'data:text/javascript,export const answer = 42; export function then(resolve) { resolve(17) }' if (Object.keys(env).length !== 0) throw new Error('Expected an empty program environment') +if (answer !== 42 || typeof then !== 'function') throw new Error('Expected every static native export') export const first = () => [counter.next(), counter instanceof Counter] export async function later() {