Skip to content

[JSC] SyntheticModuleRecord runs the host's evaluation steps - #796

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/f48be7fb/synthetic-module-evaluation-steps
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/f48be7fb/synthetic-module-evaluation-steps

Conversation

@robobun

@robobun robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A Synthetic Module Record has [[EvaluationSteps]] of the host. SyntheticModuleRecord::evaluate() runs none.
  • Bun's node:vm therefore walks the graph itself, ahead of Evaluate(), to run a vm.SyntheticModule callback. Since Bun 1.4.0 that walk makes a legal vm.SourceTextModule import cycle throw a TDZ ReferenceError.

Fix

  • A record can have an owner (setEvaluationStepsOwner). The first evaluate() gives the owner to Bun__runSyntheticModuleEvaluationSteps(), which the host defines, and the record forgets it.
  • That call is where InnerModuleEvaluation reaches the record, so the steps run at their depth-first position and a throw fails the importers.
  • Verified: JSTests/modules.yaml (1650 runs) with this branch's jsc. jsc cannot make a record with an owner, so the hook's tests are in the Bun pull request that uses it (link follows).

Background

  • InnerModuleEvaluation walks a module graph depth first. For a synthetic record it calls SyntheticModuleRecord::evaluate(), once per import edge.
  • A module record has two internal fields for its body's generator state. A synthetic record has no bytecode, so its frame field is free. The owner lives there, and the GC visits it.
  • Draft [JSC] FFI: compile C modules lowered to BIR with B3 #653 (ec68522) also adds evaluation steps to this function. Notes compare the two. One entry point for both needs a maintainer's decision.

Downsides

  • Each import edge to a synthetic record costs 6 more instructions (64 to 70 in the jsc shell). In Bun that is each import of a builtin module (63 to 69).
  • One more fork-only host hook: 40 lines under USE(BUN_JSC_ADDITIONS), not tested in this repository.
Notes

This PR had four commits. It has one now. The other three are not needed for the regression:

  • The async context of AsyncModuleExecutionDone is the second commit of JSC: evaluate dynamically imported modules under the importer's async context #274.
  • import.meta created at its first evaluation changes bytecode for every ES module. It is on the branch robobun/f48be7fb/import-meta-first-evaluation and needs its own decision and numbers for each tier.
  • JSModuleRecord::isSuspendedAtTopLevelAwait() is gone. Bun reads Field::State itself, as it already does in ZigGlobalObject.cpp.

Why a link-time hook and an internal field. One host function for every record costs no bytes per record and no setup per VM. It follows Bun__analyzeTranspiledModule in JSModuleLoader.cpp: JSC has a weak definition that does nothing, for jsc, and the host's definition replaces it at link. sizeof(SyntheticModuleRecord) stays 312 and its visit is unchanged. The readers of Field::State and Field::Frame are the bytecode of a module body, Interpreter::executeModuleProgram and JSModuleRecord::isTopLevelExecutionFinished. None of them runs for a synthetic record. JSC does not look at the owner: the host's function checks its type.

The steps run once. evaluate() runs once per import edge and once per read of a deferred namespace. The record forgets the owner before it calls the host. evaluate() always returns undefined: a promise would make evaluateModuleSync() call evaluate() again.

The cost. A record without an owner pays one load, two compares, two branches that are not taken and one move. The counts are gdb single-step counts from the entry of AbstractModuleRecord::innerModuleEvaluation to its return, for a JSON module in the jsc shell of the linux-amd64-lto lane and for node:fs in a release build of Bun.

#653 and this PR. ec68522 gives SyntheticSourceProvider an evaluator, keeps it in the record as a SharedTask, remembers what it throws, and rethrows that to every later importer. This PR differs in two ways, both because of vm.SyntheticModule:

  • The steps need the module object. A task that holds it needs a Strong handle, and record -> task -> module -> record is then a cycle the GC cannot collect. An owner in an internal field is visited like any other reference.
  • Node runs a module that imports a SyntheticModule whose callback threw earlier. So nothing is remembered here.

They agree on the rest: the steps run once, they are taken before the call, and a termination is not remembered. One entry point could serve both: the owner in the frame field, the steps as a task or a function, and a flag that says whether the error is remembered (it fits in the state field, which a synthetic record does not use either). This PR does not build that, because it changes #653's side too.

@robobun
robobun force-pushed the robobun/f48be7fb/synthetic-module-evaluation-steps branch from 07b2aaf to 88ab4fa Compare October 12, 2026 16:39
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Preview build of b5019a2: autobuild-preview-pr-796-b5019a2d

@robobun

robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

The graph walk that this PR lets Bun remove is also a release regression. Since Bun 1.4.0, vm.SourceTextModule graphs with an import cycle evaluate in the wrong order.

// A: import { v } from "B"; export const a = v + 1;
// B: import "A";            export const v = 1;
await A.link(s => ({ A, B })[s]);
await A.evaluate();
// Bun 1.4.0 to main: ReferenceError: Cannot access 'v' before initialization.
// Node v26.3.0, Bun 1.3.14, and the same two modules as files: ok

NodeVMModule::evaluateDependencies calls the spec Evaluate() on each dependency before the module that imports it. The innermost dependency becomes the root of InnerModuleEvaluation, and its importers run as if they were its dependencies. A ring A -> B -> C -> A runs B,A,C. Node runs C,B,A. The walk became wrong in oven-sh/bun#32018, which moved to the whole-graph Evaluate() and kept the walk.

Tests for the Bun PR are on the branch robobun/8a1dda31/vm-module-graph-order-tests of oven-sh/bun (commit 2d4a3549f0, tests only). Every expectation is the output of Node v26.3.0: the block ran under node with a stand-in for bun:test. On main 11 of the 16 fail. The other 5 guard graphs without a cycle.

Two of the guards matter for where the evaluation steps run. With A -> [B, S, C] the order is B, S, C, A, and the evaluateCallback of S sees what the body of B did. A walk in Bun that runs every callback before any module body (oven-sh/bun#33346) fixes cycles and breaks both. Steps that run where InnerModuleEvaluation reaches the record keep them.

Three more results from Node v26.3.0 that the Bun side has to keep:

  • A module that imports a SyntheticModule that already failed still evaluates later, if it was not on the evaluation stack at the failure. So the error of a synthetic module must not be stored where a later importer throws it again.
  • The same module with a source-text dependency that failed rejects with the same error object.
  • After evaluate({ timeout }) is cut short inside a dependency, module.error is null for the root and for the dependency. With one Evaluate(), JSC stores the string JavaScript execution terminated. on each record of its stack, and a later importer throws that string.

One more point, on the cost this PR lists for every import edge to a builtin, JSON or CommonJS record (one load and one branch in SyntheticModuleRecord::evaluate()). A form with no cost for those records exists on paper. It is not built. Keep the steps on a second final record class that derives from AbstractModuleRecord, and put its arms after the SyntheticModuleRecord compares in AbstractModuleRecord::link, both evaluate forms and evaluateModuleSync. SyntheticModuleRecord is final, so a record of the other class fails its exact class compare, and the records that exist today run the code that they run today. The price is a structure and an iso subspace: about 100 lines in 7 files, where this PR has about 50 in 3.

@robobun
robobun force-pushed the robobun/f48be7fb/synthetic-module-evaluation-steps branch 3 times, most recently from 31ba0c6 to f5362a4 Compare October 16, 2026 17:14
robobun added a commit to oven-sh/bun that referenced this pull request Oct 16, 2026
…from the module record

module.evaluate() walked the graph itself. It called evaluate() on every
dependency in request order, each with its own Evaluate() and its own
promise, and then on the module. A dependency that threw after a top-level
await rejected a promise that no caller could reach, so the process ended
with an unhandled rejection. A cycle ran in the wrong order, and a
SyntheticModule ran before the source text modules imported ahead of it.

evaluate() now calls Evaluate() of the module's record once. JSC runs the
whole graph in the order of the specification:

- SyntheticModuleRecord::evaluate() gives the record's owner to
  Bun__runSyntheticModuleEvaluationSteps(), so an evaluateCallback runs
  where the walk reaches its module, in the async context of the caller.
- import.meta is created when the module evaluates it for the first time.
  initializeImportMeta runs then, through the realm's
  moduleLoaderCreateImportMetaProperties hook. The module's
  NodeVMScriptFetcher keeps the callback, like importModuleDynamically.
- A module that imports a module with a top-level await runs in the async
  context that evaluate() was called in.

The status and the error of a SourceTextModule come from its record. A
record is made in the realm of the module's context, so its jobs go to
the queue of that context.

A timeout or a SIGINT that stops the walk, an evaluateCallback, or a job of
an afterEvaluate context gives the modules it stopped the error of the run
that was cut short.

The JavaScriptCore changes are in oven-sh/WebKit#796. WEBKIT_VERSION points
at the preview build of that pull request until it merges.
robobun added a commit to oven-sh/bun that referenced this pull request Oct 16, 2026
…from the module record

module.evaluate() walked the graph itself. It called evaluate() on every
dependency in request order, each with its own Evaluate() and its own
promise, and then on the module. A dependency that threw after a top-level
await rejected a promise that no caller could reach, so the process ended
with an unhandled rejection. A cycle ran in the wrong order, and a
SyntheticModule ran before the source text modules imported ahead of it.

evaluate() now calls Evaluate() of the module's record once. JSC runs the
whole graph in the order of the specification:

- SyntheticModuleRecord::evaluate() gives the record's owner to
  Bun__runSyntheticModuleEvaluationSteps(), so an evaluateCallback runs
  where the walk reaches its module, in the async context of the caller.
- import.meta is created when the module evaluates it for the first time.
  initializeImportMeta runs then, through the realm's
  moduleLoaderCreateImportMetaProperties hook. The module's
  NodeVMScriptFetcher keeps the callback, like importModuleDynamically.
- A module that imports a module with a top-level await runs in the async
  context that evaluate() was called in.

The status and the error of a SourceTextModule come from its record. A
record is made in the realm of the module's context, so its jobs go to
the queue of that context.

A timeout or a SIGINT that stops the walk, an evaluateCallback, or a job of
an afterEvaluate context gives the modules it stopped the error of the run
that was cut short.

The JavaScriptCore changes are in oven-sh/WebKit#796. WEBKIT_VERSION points
at the preview build of that pull request until it merges.
robobun added a commit to oven-sh/bun that referenced this pull request Oct 16, 2026
…from the module record

module.evaluate() walked the graph itself. It called evaluate() on every
dependency in request order, each with its own Evaluate() and its own
promise, and then on the module. A dependency that threw after a top-level
await rejected a promise that no caller could reach, so the process ended
with an unhandled rejection. A cycle ran in the wrong order, and a
SyntheticModule ran before the source text modules imported ahead of it.

evaluate() now calls Evaluate() of the module's record once. JSC runs the
whole graph in the order of the specification:

- SyntheticModuleRecord::evaluate() gives the record's owner to
  Bun__runSyntheticModuleEvaluationSteps(), so an evaluateCallback runs
  where the walk reaches its module, in the async context of the caller.
- import.meta is created when the module evaluates it for the first time.
  initializeImportMeta runs then, through the realm's
  moduleLoaderCreateImportMetaProperties hook. The module's
  NodeVMScriptFetcher keeps the callback, like importModuleDynamically.
- A module that imports a module with a top-level await runs in the async
  context that evaluate() was called in.

The status and the error of a SourceTextModule come from its record. A
record is made in the realm of the module's context, so its jobs go to
the queue of that context.

A timeout or a SIGINT that stops the walk, an evaluateCallback, or a job of
an afterEvaluate context gives the modules it stopped the error of the run
that was cut short.

The JavaScriptCore changes are in oven-sh/WebKit#796. WEBKIT_VERSION points
at the preview build of that pull request until it merges.
A Synthetic Module Record has [[EvaluationSteps]]: what the host runs when
the record is evaluated. SyntheticModuleRecord::evaluate() had no such step,
so a host with steps (Bun's node:vm SyntheticModule) had to walk the module
graph itself, ahead of Evaluate(), to run them.

A record can now have an owner. The first evaluate() gives the owner to
Bun__runSyntheticModuleEvaluationSteps(), which the host defines, and the
record forgets it: the steps run once. That happens where
InnerModuleEvaluation reaches the record, so the steps run at their
depth-first position, and what they throw fails the importers the way a
module body that throws does.

The owner is kept in the generator frame field every module record has. A
synthetic record has no bytecode, so it never used that field. The record
does not grow and has nothing more to visit.

jsc has no host: a weak definition of the function that does nothing is what
it links. No record has an owner there.

A record without an owner pays 6 instructions per import edge (64 to 70 in
the jsc shell, x86-64): one load, two compares, two branches that are not
taken, one move.
@robobun
robobun force-pushed the robobun/f48be7fb/synthetic-module-evaluation-steps branch from f5362a4 to b5019a2 Compare October 17, 2026 04:11
@robobun robobun changed the title [JSC] SyntheticModuleRecord runs the host's evaluation steps; a module that waits for a top-level await keeps the async context [JSC] SyntheticModuleRecord runs the host's evaluation steps Oct 17, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Oct 17, 2026
…from the module record

module.evaluate() walked the graph itself. It called evaluate() on every
dependency in request order, each with its own Evaluate() and its own
promise, and then on the module. A dependency that threw after a top-level
await rejected a promise that no caller could reach, so the process ended
with an unhandled rejection. A cycle ran in the wrong order, and a
SyntheticModule ran before the source text modules imported ahead of it.

evaluate() now calls Evaluate() of the module's record once. JSC runs the
whole graph in the order of the specification:

- SyntheticModuleRecord::evaluate() gives the record's owner to
  Bun__runSyntheticModuleEvaluationSteps(), so an evaluateCallback runs
  where the walk reaches its module, in the async context of the caller.
- import.meta is created when the module evaluates it for the first time.
  initializeImportMeta runs then, through the realm's
  moduleLoaderCreateImportMetaProperties hook. The module's
  NodeVMScriptFetcher keeps the callback, like importModuleDynamically.
- A module that imports a module with a top-level await runs in the async
  context that evaluate() was called in.

The status and the error of a SourceTextModule come from its record. A
record is made in the realm of the module's context, so its jobs go to
the queue of that context.

A timeout or a SIGINT that stops the walk, an evaluateCallback, or a job of
an afterEvaluate context gives the modules it stopped the error of the run
that was cut short.

The JavaScriptCore changes are in oven-sh/WebKit#796. WEBKIT_VERSION points
at the preview build of that pull request until it merges.
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: oven-sh/bun#46004 no longer needs this hook. vm.SyntheticModule now has a JSModuleRecord that Bun builds by hand, and the realm's moduleLoaderEvaluate hook runs the evaluateCallback, so node:vm reads status and error from the record. The engine change that the Bun PR needs is #832.

@robobun robobun closed this Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant