mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
fix(ptc): preserve import semantics across evaluation lifetimes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 注册表键保留到进程退出。保留模式求值结束后才发起或返回结果的导入会被拒绝;临时求值让这些导入保持挂起,直到进程退出。保存的函数在后续活动求值中调用时可以正常导入。
|
||||
|
||||
### 源码索引
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet<string> = 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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<ModuleRecord[]>
|
||||
expired?: boolean
|
||||
}
|
||||
|
||||
type ImportResult = ModuleRecord | { namespace: Record<string, unknown> }
|
||||
@@ -91,10 +92,19 @@ export class VmModuleImportLoader {
|
||||
return load as vm.DynamicModuleLoader<object>
|
||||
}
|
||||
|
||||
private async request(specifier: string, referrer: string, attributes: ImportAttributes, phase: string): Promise<ImportResult> {
|
||||
private async request(
|
||||
specifier: string, referrer: string, attributes: ImportAttributes, phase: string, staticImport = false,
|
||||
): Promise<ImportResult> {
|
||||
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<never>(() => {})
|
||||
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<string, unknown> }
|
||||
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<ModuleRecord> {
|
||||
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<ModuleRecord> {
|
||||
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<ModuleRecord[]> {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<unknown>) | undefined
|
||||
const holder = { save(fn: () => Promise<unknown>) { 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<void> = Promise.withResolvers()
|
||||
const released = Promise.withResolvers<ProgramImportResult>()
|
||||
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<void> = 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<void> = Promise.withResolvers()
|
||||
const released = Promise.withResolvers<ProgramImportResult>()
|
||||
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<ProgramImportResult>()
|
||||
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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user