Skip to content

[JSC] AOT: do not bind a require() of an ES module that uncompiled code can import - #988

Draft
robobun wants to merge 2 commits into
robobun/1d5b956b/aot-module-order-counts-unseen-codefrom
robobun/1d5b956b/aot-require-of-named-module-asks-run-time
Draft

robobun wants to merge 2 commits into
robobun/1d5b956b/aot-module-order-counts-unseen-codefrom
robobun/1d5b956b/aot-require-of-named-module-asks-run-time

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Needs #961 (its commit is the first commit here) and #987 (the base branch).

Problem

  • A bun build --compile --aot executable never settles one kind of import(): no then callback runs. Uncompiled code (new Function, eval) names an ES module that a require() still evaluates. The build made that require() a direct call.
  • mayLoadTwoGraphsAtOnce() (aot/passes/AOTCallModules.cpp) counts only a compiled import(). [JSC] AOT: settle the import() of a module that a require() still evaluates #961 settles the promise in Modules::evaluateWithOwnStack, which a direct call never enters.

Fix

Background

  • A bound require() is a direct call of the module's start (RequireSitesBound). An unbound one calls the run time, which settles promises at the end.
  • The promise stays pending where the loader's step for the import() runs before the start returns. Else it settles late.
  • Considered a term in mayLoadTwoGraphsAtOnce(): it unbinds every ES-module require() in each program with one eval.

Downsides

  • Each require() that is no longer bound calls the run time once. That costs about +13,000 instructions and one cell of 80 or 128 bytes.
  • A text import() of another module inside a bound require() still runs later than in plain Bun.
  • One program goes from Node's output to plain Bun's rejected import() (Notes).
Notes

Where this comes from

No user reported it. It is the case that #961 names as out of its reach ("planned as a change of its own, on top of this one"). The draft oven-sh/bun#47538 holds a program for it under // Todo:. I wrote every program below.

The branch is #987, then the commit of #961 (b458ef4113, picked onto 0ef2dfd320), then one commit.

The program

// main.mjs (entry point)
require("./running.mjs");
console.log("main ends");
export {};
// running.mjs (entry point)
import { value } from "./imported.mjs";
console.log("running begins with", value);
require("./imports-back.mjs");
console.log("running ends");
export const ended = true;
// imported.mjs
console.log("imported runs");
export const value = 1;
// imports-back.mjs
import { existsSync } from "node:fs";
console.log("imports-back runs", typeof existsSync);
const importByName = new Function("name", "return import(name)");
importByName(import.meta.resolve("./running.mjs")).then(
  names => console.log("import of running: fulfilled, ended:", names.ended),
  error => console.log("import of running: rejected,", error.message),
);

bun build --compile --aot main.mjs running.mjs --outfile app && ./app

Plain Bun and Node 26.3.0 (with createRequire) print import of running: fulfilled, ended: true as the last line. The executable printed every line but that one and ended with exit code 0 (RequireSitesBound 1). With the change it prints the line (RequireSitesBound 0).

When the promise stays pending

CyclicModuleRecord::evaluate() stores the capability when the loader's step for the import() runs. That step runs before the bound start returns in three cases:

  • A require() inside the module asks the run time, which runs the steps of the loader before it returns (the program above).
  • The text runs the waiting jobs itself, for example with drainMicrotasks() of bun:jsc.
  • The text makes a require() for itself (process.getBuiltinModule("module").createRequire(url)) of a file whose text calls import().

Where the text only calls import(), the step runs when the start has returned and the promise settles. Each case has a test.

The set, and the one file that stays bound

callModules asks modulesThatUnseenCodeNames(program, flow.findsFilesOfProgramByName(), build.options().admitsUnseenCode()), the function that #987 adds for the module order. It holds what Modules::refusesRequestFromHost lets such code name. Where only one of those files runs code or loads a file, every realm begins with it: a require() of it evaluates nothing, and the site stays a call.

That is not "the one entry point". With new Worker(name), a realm begins with a file that a name finds, and that file may require() the entry point. call-modules-where-a-file-that-a-name-finds-requires-the-entry-point.ir and the native test import-by-text-of-entry-point-that-file-found-by-name-requires pin it.

A require() that asks the run time runs the steps of the loader before it returns, so a text import() can begin a module inside it. The module order must know the same names, or a compiled read runs before its store. The native test import-by-text-of-file-found-by-name-while-required-file-found-by-name-runs is built with admit=FilesRunByHost for that: it ends with a ReferenceError, not with a segmentation fault.

Under --compile-inspector the Bun front end marks every file of the program IsListedInCache, so none was bound. The engine's term (admit=Inspector, every module) also reaches the ES-module faces of CommonJS files, which carry no such mark: RequireSitesBound goes from 25 to 17 in one barrel program and from 4 to 3 in three others, in inspector builds only.

A build with --aot-allow-code-from-disk rests on the front end, which marks every file IsFoundByName.

Tests

Engine, release with assertions, x64, on 0ef2dfd320:

  • testaot run aot/tests: 5876 passed, 0 failed, 42 todo. Without the edit of AOTCallModules.cpp: 5870 passed, 6 failed.
  • testaot native: 3081 passed. module-programs/: 24 passed. Without the edit: 18 passed, 6 failed.
  • 12 new files tests/passes/call-modules-where-*.ir. Six are twins that stay bound: no text, a text with one entry point, a file that a name finds where no load names a file, a second entry point that runs nothing, a neighbor that no name finds, and a build that refuses code outside the image.
  • import-by-text-of-entry-point-that-required-module-imports expects pending twice, with and without the change. Plain Bun leaves that promise pending (CyclicModuleRecord::evaluate()). [JSC] Settle an Evaluate() of an EVALUATING module when the walk pops its cycle #871 changes that function, and the test must follow it.

Bun level, on 0ef2dfd320 with #987 and this PR, under oven-sh/bun#47605 (22621ef913, which pins the #982 preview):

  • 12 programs for test/aot/parallel/modules, run by test/aot/run-parallel.ts: 11 pass and 1 is marked // Todo: (the order below). Without the two PRs 10 fail.
  • 45 more programs, each compared line for line with plain Bun: 38 print plain Bun's lines, among them require() written as import.meta.require, module.require, createRequire, import x = require(), in a function and in a try, and texts of eval, indirect eval, AsyncFunction, node:vm and --aot-allow-code-from-disk.
  • test/aot/tools/same-output.ts (machine code, function by function), engine 0ef2dfd320 against both PRs: IDENTICAL for 289 programs of perf/idioms and 3,126 programs of closure-factories, modules, closed-world and commonjs.

Bun level, earlier runs on the engine at 115632d802 (#972) with #961 and the same source patches, under oven-sh/bun#47605 at 64ddaea8aa:

  • test/aot files with "test-run" in the name (73 files, 538 tests) and the 19 files that set BUN_AOT_TESTS_NAME_WHAT_THEY_USE (132 tests): the same results before and after. Run four at once, 1 file failed before and 2 other files failed after. Each of the three passes alone with both builds.

What still differs from plain Bun

  • Order. A text import() of another module (another entry point, or node:fs) inside a bound require() of a module that no name finds runs its loader steps after the caller goes on. Plain Bun runs them inside the require(). Five of the 45 programs show it, for example other entry runs behind main ends. The repair is flow.unseenCodeCanRun() in mayLoadTwoGraphsAtOnce(), which unbinds every ES-module require() in each program with one text.
  • main.mjs, the one entry point, requires running.mjs, whose text imports main.mjs: the executable prints import of main: fulfilled, done: 1, and plain Bun leaves that promise pending. Before and after.
  • One program prints the report of an uncaught error in another format.
  • While the run time asks the host for a file, it lets a name find any module.

One program changes sides. order-oob3 (the crash program of #987 without the node:fs import, so its require() was bound) printed main reads port: 8080 name: service-0 and import of main: fulfilled, which is what Node 26.3.0 prints. It now prints plain Bun's line, import of main: rejected, undefined is not an object (evaluating 'config.port').

Measurements

On the engine at 115632d802 with #961 and the same source patches, unless a line says another base.

  • --explain=statistics over 1,219 programs of test/aot/parallel (all that name require, all 85 with more than one entry point): no statistic changes between [JSC] AOT: count files that uncompiled code begins by name in the module order and in closure-factory copies #987 and this PR. RequireSitesBound is 4,533 before and after, in 554 programs, 22 of them with more than one entry point.
  • test/aot/perf/compare-builds.ts on the seven servers of test/aot/servers/app (56 rows, one request of each route), before against both PRs: 0 slower, 0 faster.
  • Price of a site that is no longer bound (jsc counters, assertions on, least of 7 runs, on 9f5001ee2c). Instructions to the end of the entry point:
program bound asks the run time
one require() 445,472 480,908
100 in a row 450,811 1,786,845
300 in a row 460,766 4,396,318
100 nested 447,918 2,106,600
300 nested 451,910 6,350,645

That is 50 against 13,047 instructions a module in a row, and 20 against 21,220 nested, once per process. For 100 modules: operationAOTRequireModule 0 to 100, cells of 80 bytes 0 to 85, cells of 128 bytes 1 to 16.

  • A site in code that runs often (base 5869a3b791): 300.5 to 292.5 instructions per call and no call of the run time, because the module has run. In a build where every block is cold (heat=EverythingIsCold) it is 300.5 to 902.5, with one call of the run time per execution.

Open

@robobun

robobun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Status

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and did not find any bugs; because it changes which require() sites the AOT pass binds and how Modules::evaluateWithOwnStack settles an import() capability, both of which carry run-time ordering and cost trade-offs the description itself calls out (inspector builds losing bound sites, the +13k-instruction cost per unbound require()), a human look from a JSC/AOT maintainer would still be worthwhile.

What was reviewed:

  • Checked that the set callModules excludes matches the module order's set: flow.findsFilesOfProgramByName() is set from filesOfProgramAreLoadedByName || hostRunsFilesOutsideImage (AOTValueFlowResultsBuiltins.cpp:1472), the same expression AOTValueFlowResultsOrder.cpp:307 passes, and flow.unseenCodeCanRun() only differs from the solution's UnseenCodeRuns via a LoadsCodeByName record, which is only appended when letModulesBeLoadedByName/runUnseenCode have already raised UnseenCodeRuns.
  • Checked the runtime change: m_records.get() plus takeStatus() goes through ensureRecord(), which leaves an Evaluating self-evaluated module's record unchanged and otherwise moves the table status into the record before the capability is fulfilled or rejected; the throw path is pinned by the two "throws" native tests.
  • Checked the new namespace-scope names in AOTModuleProgramTests.cpp against the rest of aot/ for the unified-sources uniqueness rule; runInShell/checkShellOutput only gained a parameter and the new literals and helpers are unique.
Extended reasoning...

The change is two small source edits (8 lines in aot/passes/AOTCallModules.cpp, 5 lines in aot/runtime/AOTModuleEvaluation.cpp) plus 12 IR pass tests and ~720 lines of native shell tests in aot/tests/AOTModuleProgramTests.cpp. It touches no security-sensitive surface (no auth, injection or data-exposure paths), only the AOT compiler's require()-binding decision and the module-evaluation promise settlement. Deferral rather than approval rests on the path being covered by the Source/JavaScriptCore CODEOWNERS entry, on the design trade-off the change makes (every ES-module require() reachable by unseen code now goes through the run time, including all of them in inspector builds), and on the subtlety of the record-status handoff in evaluateWithOwnStack, which I traced but which deserves a maintainer's confirmation. The two candidate issues in the ruled-out list (mismatch for disk-loading builds without eval, and inspector builds losing bound sites) were re-examined: the first does not arise because UnseenCodeRuns is raised whenever a LoadsCodeByName record is produced, and the second is a disclosed cost rather than a correctness defect.

…luates

Modules::evaluateWithOwnStack() settles the capability that an import()
gave to the root while the root ran. It looked for the record with
recordIfExists(), which answers only while the status byte of the module
says InRecord. The table keeps the status of a module whose start
evaluates it, and of a module without a body that this walk began, so
the record that the import() made was not found and the promise stayed
pending.

evaluateWithOwnStack() now reads the record from m_records. A record
with a pending capability takes the status, and the capability is
fulfilled or rejected as before.

Three native tests run such programs in the shell. Two fail without the
change:
module-programs/import-of-module-that-require-runs and
module-programs/import-of-module-without-body-that-require-runs.
The third passes with and without it, because a module that throws
gives its record the status:
module-programs/import-of-module-that-require-runs-and-that-throws.
…mpiled can name asks the run time

In a program that compiles no import(), callModules() makes a require()
of an ES module a call of its start. The start ends with one byte store,
and no function of the run time sees the end of the evaluation. Code
that the build has not compiled (the text of eval or of a Function
constructor, a file from disk) may call import() with the name of that
module while the require() evaluates it. Where the loader's step for
that import() runs before the start returns,
CyclicModuleRecord::evaluate() stores a pending capability in the
record, and nothing settles it: no then-callback runs, and an error
that the module throws is not delivered.

callModules() now leaves the require() of such an ES module to the run
time where FlowResults::unseenCodeCanRun(). The site is then
Modules::evaluateSynchronously(), which ends in
Modules::evaluateWithOwnStack(), where the promise is settled. The
modules are modulesThatUnseenCodeNames(), the set that the module order
of flow reads: a file that the host asks for, unless it is the one file
that every realm begins with, and every module where code comes in at
any place (CompileOptions::admitsUnseenCode()).

Tests: twelve pass tests call-modules-where-*.ir, of which six fail
without the change, and seven native tests under module-programs/, of
which six fail without it. The seventh,
import-by-text-of-entry-point-that-required-module-imports, stays
pending as under plain Bun.
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Preview build of de0f320: autobuild-preview-pr-988-de0f3203

@robobun
robobun force-pushed the robobun/1d5b956b/aot-require-of-named-module-asks-run-time branch from 04a8708 to de0f320 Compare November 2, 2026 01:42

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the re-pushed version of this PR and found no bugs; because it changes how the AOT run time settles an import() promise and which require() sites stay bound, a human look at the two source commits is still worthwhile.

What was reviewed:

  • callModules() gate: the set passed to modulesThatUnseenCodeNames() uses flow.findsFilesOfProgramByName(), which is filesOfProgramAreLoadedByName || hostRunsFilesOutsideImage — the same argument the module order builds in AOTValueFlowResultsOrder.cpp:307, so the two agree as the description claims.
  • evaluateWithOwnStack(): m_records.get(root) plus takeStatus() goes through ensureRecord(), which leaves the record not holding status while the table still says Evaluating for a self-evaluating/body-less root; the throw path then relies on the stack loop or the stored status, which the new -and-that-throws native tests exercise but I could not run here.
  • Unified-sources rule: the new file-static helpers in AOTModuleProgramTests.cpp either are unique in aot/ or are overloads with distinct signatures of names already used in other test files.
Extended reasoning...

The change touches two source files in the JSC AOT subsystem: ~8 lines in the compiler pass AOTCallModules.cpp that add an isImportedWhileRequired term to the require()-binding decision, and ~5 lines in the runtime AOTModuleEvaluation.cpp that look up the root ModuleRecord directly and take its status before settling a pending top-level capability; the rest is ~1,100 lines of new .ir and native tests. No security-sensitive surface (auth, injection, data exposure) is involved. The bug hunt ran to a dry streak with no findings and the source edits are small, but they sit in module-evaluation and promise-settlement semantics whose correctness depends on run-time status bookkeeping I could only read, not execute, and the prior review on an earlier push could not be read, so a human look rather than approval is the right call.

@robobun
robobun marked this pull request as draft November 2, 2026 01:56
@robobun

robobun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the previous head (04a8708b) has one red job: Test bun-webkit-linux-arm64-lto-hardened-aot, step run-javascriptcore-tests (job).

I moved this pull request to draft until that job is explained.

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