Skip to content

Stop running builtin modules' lazy accessors when they are imported as ESM - #37525

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/68bba673/lazy-builtin-esm-exports
Aug 12, 2026
Merged

Stop running builtin modules' lazy accessors when they are imported as ESM#37525
Jarred-Sumner merged 1 commit into
mainfrom
farm/68bba673/lazy-builtin-esm-exports

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Builtins implemented in src/js are handed to ES module importers by generateInternalModuleSourceCode (src/jsc/bindings/ModuleLoader.cpp), which snapshots the builtin's CommonJS exports object into a synthetic module record. It did that with object->get() on every own enumerable property, so every accessor on the exports object ran at import time. Those accessors are the builtins' lazy-loading mechanism, which means they only ever helped require() callers:

  • node:fs: ReadStream, WriteStream, FileReadStream, FileWriteStream, Utf8Stream each require("internal/fs/streams"), which loads the whole node:stream stack.
  • node:tls: rootCertificates parses the bundled CA store, DEFAULT_CIPHERS queries BoringSSL.
  • node:http: globalAgent instantiates the agent. node:timers / node:stream: promises load timers/promises and stream/promises. node:assert: AssertionError loads internal/assert/assertion_error. node:repl, node:events, node:buffer, node:os have a few more.

#35541 is about to turn more of these into accessors (fs.promises among them), which this path would immediately defeat for the most common import style there is.

Repro

// probe.mjs, run as `bun --expose-internals probe.mjs esm` vs `... require`
const mode = process.argv[2];
const { createRequire } = await import("node:module");
let t = performance.now();
if (mode === "esm") await import("node:fs"); else createRequire(import.meta.url)("node:fs");
const fsMs = performance.now() - t;
t = performance.now();
createRequire(import.meta.url)("internal/fs/streams");
console.log(mode, "node:fs", fsMs.toFixed(1), "ms; internal/fs/streams afterwards", (performance.now() - t).toFixed(1), "ms");

bun 1.4.0 (release): esm node:fs 13.3 ms; internal/fs/streams afterwards 0.2 ms (already loaded) vs require node:fs 7.8 ms; internal/fs/streams afterwards 5.0 ms. On a debug build the difference is ~370ms per process, and test/harness.ts does import fs from "node:fs", so every test file paid it.

The directly observable form, which the tests use: the node:fs getters replace themselves with data properties when they run, so after import fs from "node:fs", Object.getOwnPropertyDescriptor(fs, "ReadStream") had a value instead of a get.

Fix

An ES module binding cannot be made lazy from the embedder side: once the record exists, named imports read the exporting environment's slot directly (ModuleVar) and namespace reads go through getValue() in JSModuleNamespaceObject, so the slot has to hold the value before anything binds to it. The engine half is oven-sh/WebKit#408, merged as 7b763944f0ec. WEBKIT_VERSION moves from 09e477744721 (what main pins) to that sha; it is the only commit between the two:

  • SyntheticModuleRecord::tryCreateWithExportNamesAndValues() gets an overload taking a source object; an export whose value is the empty JSValue is declared but left in TDZ.
  • materializeLazyExport() fills such a slot from source[name] the first time something binds to it: CyclicModuleRecord::initializeEnvironment when an importer links a named import that resolves to it (directly or through export * / export { x } from chains), JSModuleNamespaceObject::getOwnPropertySlotCommon when a namespace read finds the slot empty (it then re-reads and installs the value the same way as before, so the namespace IC still applies), and WebAssemblyModuleRecord::initializeImports, which snapshots imported bindings directly (unreachable in bun today, reachable once runtime: implement WebAssembly/ESM integration for .wasm imports #35587 lands). It is a no-op for a slot that already has a value, so overrideExportValue (what mock.module and spyOn use) keeps winning, *namespace* is skipped, a getter that re-enters keeps the first value that landed, and an exception from the getter propagates and leaves the slot for the next attempt. The write uses the same symbolTablePutTouchWatchpointSet the eager path used for its one write, so IC / DFG behaviour after materialization is identical to an eagerly built record; the baseline namespace IC already bails to the slow path on an empty slot and the DFG only folds loads that went through an installed IC.
  • SyntheticSourceProvider::createWithLazyExports() takes a generator that returns the source object, and makeModule passes it through. Records built any other way (JSON modules, vm.SyntheticModule, every existing create() caller) have no source object and take a pointer test on the new paths.

The bun half is generateInternalModuleSourceCode: it now looks each property up with getOwnPropertySlot, snapshots data properties exactly as before, declares everything else (accessors) as a lazy export, and returns the exports object as the source. A builtin without accessors (most of them) produces a record identical to the old one. require() of a builtin never went through this function (fetchCommonJSModule returns the registry object), so it is unaffected. The engine change is inert without this opt-in: building the new WebKit with the old ModuleLoader.cpp still fails the new tests the same way the release does.

Why this is the right behaviour

Node builds the ESM facade of a builtin by reading the getters too, so the old behaviour was not wrong, just expensive, and there is no compat reason to keep it. The only observable difference is when an accessor export is sampled: at import before, at the point something first binds to it now. Data properties still snapshot at import. A namespace export or named import of an accessor is a snapshot either way (that is what module.syncBuiltinESMExports() exists for), and the later sample is never staler than the old one. Everything an importer can do with the export resolves to the same object require() would hand out, which is what the tests check for each binding path.

Object.keys(ns), hasOwnProperty, spread, and console.log(ns) still materialize what they touch, because [[GetOwnProperty]] has to produce the value; in ([[HasProperty]]) does not. Plugin loader: "object" modules and user CommonJS imported from ESM are deliberately not touched: those expose user objects whose getter timing is user-visible (#36677 is about that), whereas builtins' accessors are our own lazy-loading idiom.

Tests

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts, next to the other module-system behaviour tests in that directory. Each case runs in its own process (a binding can only be materialized once) and checks both that importing left the node:fs accessors untouched and that the binding is the real class once used, for: default + named data import, named import of an accessor (exactly that one materializes), import * as, import defer * as, dynamic import() including Object.keys and that the export list is unchanged, export * and export { x as y } from re-exports (named import through the star and a read off the re-exporter's namespace, which exercises materializing on a record other than the namespace's own), accessors on node:assert (a function exports object), node:timers and node:stream, and spyOn / mock.module on an already imported builtin. 7 of the 8 cases fail on the current eager code (everything reports value); the assert/timers/stream identity case is a regression guard.

With the fix, the probe above reports internal/fs/streams as not loaded after import("node:fs") (debug build: 338ms to load it afterwards in both modes, versus 10ms before because the import had already loaded it). Also run on the new build: test/js/node/fs/fs.test.ts (covers the existing export { ReadStream, WriteStream } from "node:fs" and export * from "node:fs" fixtures), the test/js/bun/resolve module tests, test/js/bun/test/mock/mock-module*.test.ts, node-module-module.test.js, buffer-inspectmaxbytes.test.ts (which pins that a named import of an accessor is a snapshot), and the events / os / timers / assert / tls-internals / node-http suites, all green apart from two failures that are identical on the released binary in this environment (os.userInfo, the http proxy test).


[decide:webkit] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 7 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/builtin-esm-lazy-exports.test.ts
bun test v1.4.0 (435641a6b)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
132 |       afterKeys: kinds(),
133 |       keysMatch: namespaceKeys.sort().join() === [...exportsKeys, "default"].sort().join(),
134 |       allAreTheClasses: STREAMS.every(name => typeof ns[name] === "function" && ns[name] === fs[name]),
135 |     });
136 |   `);
137 |   expect(result).toEqual({
                       ^
error: expect(received).toEqual(expected)

  {
    "afterImport": {
-     "FileReadStream": "accessor",
-     "FileWriteStream": "accessor",
-     "ReadStream": "accessor",
-     "Utf8Stream": "accessor",
-     "WriteStream": "accessor",
+     "FileReadStream": "value",
+     "FileWriteStream": "value",
+     "ReadStream": "value",
+     "Utf8Stream": "value",
+     "WriteStream": "value",
    },
    "afterKeys": {
      "FileReadStream": "value",
      "FileWriteStream": "value",
      "ReadStream": "value",
      "Utf8Stream": "value",
      "WriteStream": "value",
    },
    "after
... (truncated)

release without fix: 7 FAILED
bun test v1.4.0-canary.1 (9008ae7ab)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
68 |   const result = await runEntry(`
69 |     import { ReadStream } from "node:fs";
70 |     import { fs, kinds, print } from "./helper.mjs";
71 |     print({ kinds: kinds(), isTheClass: typeof ReadStream === "function" && ReadStream === fs.ReadStream });
72 |   `);
73 |   expect(result).toEqual({ kinds: kinds("ReadStream"), isTheClass: true });
                      ^
error: expect(received).toEqual(expected)

  {
    "isTheClass": true,
    "kinds": {
-     "FileReadStream": "accessor",
-     "FileWriteStream": "accessor",
+     "FileReadStream": "value",
+     "FileWriteStream": "value",
      "ReadStream": "value",
-     "Utf8Stream": "accessor",
-     "WriteStream": "accessor",
+     "Utf8Stream": "value",
+     "WriteStream": "value",
    },
  }

- Expected  - 4
+ Received  + 4

      at <anonymous> (/workspace/bun/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:73:18)
89 |       afterRead: kinds(),
90 |       isTheClass: typeof WriteStream === "function" && WriteStream === fs.WriteStream,
91 |       secondReadIsStable: ns.WriteStream === WriteStream,
92 |     })
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/builtin-esm-lazy-exports.test.ts
bun test v1.4.0 (435641a6b)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
(pass) importing the module does not run its accessors [1746.65ms]
(pass) a named import materializes exactly the binding it links [2145.14ms]
(pass) a namespace export materializes when it is read, not on import or `in` [2175.43ms]
(pass) a deferred namespace (import defer) materializes on read as well [2720.80ms]
(pass) import() namespace: same export list as before, enumerating it materializes everything [2873.44ms]
(pass) re-exports bind through to the builtin's own binding [2232.58ms]
(pass) spyOn and mock.module on an imported builtin [2125.50ms]
(pass) accessors on other builtins bind to what require() returns [2735.47ms]

 8 pass
 0 fail
 24 expect() calls
Ran 8 tests across 1 file. [8.97s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     435641a6bd
  features     baseline

22 deps, 107 codegen, 1176 objects in 1540ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/1238] fetch zlib
[zlib] up to date
[5/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1238] fetch tinycc
[tinycc] up to date
[7/1237] fetch picohttpparser
[picohttpparser] up to date
[8/1237] gen .bind.ts → GeneratedBindings.cpp
[9/1237] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[10/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11/1237] gen ProcessBindingHTTPParser.lut.h
Generating /workspac
... (truncated)
diff hotspot
scripts/build/deps/webkit.ts                       |   2 +-
 src/jsc/bindings/ModuleLoader.cpp                  |  28 ++-
 .../bun/resolve/builtin-esm-lazy-exports.test.ts   | 232 +++++++++++++++++++++
 3 files changed, 254 insertions(+), 8 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
scripts/build/deps/webkit.ts                              3      3      0
src/jsc/bindings/ModuleLoader.cpp                         1      3      0
test/js/bun/resolve/builtin-esm-lazy-exports.test.ts      0      0      0

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 90f70069-2b0b-4321-bf88-5b381bb28505

📥 Commits

Reviewing files that changed from the base of the PR and between 0826514 and 435641a.

📒 Files selected for processing (3)
  • scripts/build/deps/webkit.ts
  • src/jsc/bindings/ModuleLoader.cpp
  • test/js/bun/resolve/builtin-esm-lazy-exports.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.0 with the probe in the description: after import("node:fs"), Object.getOwnPropertyDescriptor(require("fs"), "ReadStream") is a data property (the getter ran at import); after require("node:fs") it is still an accessor.
  • Engine half SyntheticModuleRecord: exports whose values are produced on first binding WebKit#408 is merged; this PR is rebased on main and pins its merge commit 7b763944f0ec (main is on its parent 09e477744721).
  • test/js/bun/resolve/builtin-esm-lazy-exports.test.ts: 7 of 8 cases fail with the new WebKit and the old ModuleLoader.cpp, 8 of 8 pass with this diff.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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 didn't find any bugs. Because it depends on an unmerged companion change in oven-sh/WebKit#408 (currently pinned as a preview build that the description says must be repointed before landing) and changes the module-loader path every builtin ESM import goes through, a human look is still warranted.

Checked: exception-scope handling around the new getOwnPropertySlot / slot.getValue calls; that data-property exports still snapshot exactly as before and default is still appended; that the hasOwn == false fallback preserves the old object->get behaviour; that require() of builtins doesn't route through this generator; and that the test file's per-process isolation, test.concurrent, and pipe-draining match harness conventions.

Extended reasoning...

Overview

This PR stops generateInternalModuleSourceCode in src/jsc/bindings/ModuleLoader.cpp from eagerly invoking accessor properties on builtin modules' exports objects when they are imported as ESM. Accessors are instead declared as lazy exports (empty JSValue in the export values buffer) and the exports object is returned as the "source" for later materialization. The engine machinery that actually materializes those slots on first bind/read lives in oven-sh/WebKit#408, which this PR pulls in by pinning WEBKIT_VERSION to a preview autobuild tag. A new 8-case test file exercises named imports, import *, import defer *, dynamic import, re-exports, function-typed exports objects, and spyOn/mock.module interaction.

Security risks

None identified. The change is scoped to the internal-module-registry ESM path (Bun's own src/js builtins); user loader: "object" plugins and user CJS-from-ESM are explicitly left on the eager path. No untrusted input reaches the new code.

Level of scrutiny

High. This is the hot path for every import of a node:* / bun:* builtin, and correctness depends on engine changes that are not in this diff — GC rooting of the returned source object across the module record's lifetime, TDZ-slot handling in JSModuleNamespaceObject, and the IC/DFG claims in the description all live in the WebKit PR. The bun-side diff itself is small and looks correct (exception checks after each fallible call, data properties unchanged, hasLazyExports gates returning the object so accessor-free builtins produce byte-identical records), but I can't verify the WebKit half from here.

Other factors

  • The WebKit pin is explicitly a preview build with a comment saying to repoint it at a main sha once oven-sh/WebKit#408 merges. That's a landing-order decision a maintainer should sign off on.
  • The observable-timing change (accessor sampled at first bind rather than at import) is argued in the description as strictly-no-worse than Node's behaviour, but it is a user-visible semantic change to when builtin getters run.
  • Test coverage is thorough for the binding paths and follows harness conventions (per-process isolation, test.concurrent, concurrent pipe drains, tempDir). The tests can't run without the preview WebKit, so CI is the real verification here.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points the review flags:

  • The engine-side reasoning (GC visiting of the source object, the TDZ slot handling in JSModuleNamespaceObject, why the namespace IC and DFG cannot observe an unmaterialized slot) is written up in SyntheticModuleRecord: exports whose values are produced on first binding WebKit#408, which is the diff to read for that half. The bun side only decides which exports are declared lazy.
  • The new test file and the suites listed in the description were run locally against the same preview build this PR pins, not only on CI; CI here builds against that pin as well. The landing order (WebKit PR first, then repoint WEBKIT_VERSION) is as stated in the description.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase once WebKit PR finishes building

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

oven-sh/WebKit#408 merged as 7b763944f0ec; its release build on WebKit main is still running. Once the autobuild-7b763944f0ec... release is up I will rebase onto main and point WEBKIT_VERSION at that sha.

@robobun
robobun force-pushed the farm/68bba673/lazy-builtin-esm-exports branch from 45b4247 to 1b92c69 Compare August 11, 2026 23:52
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:16 PM PT - Aug 11th, 2026

@robobun, your commit 435641a6bd5250bad6ac6b263b50dec73cc2a9d3 passed in Build #92626! 🎉


🧪   To try this PR locally:

bunx bun-pr 37525

That installs a local version of the PR into your bun-37525 executable, so you can run:

bun-37525 --bun

@robobun
robobun force-pushed the farm/68bba673/lazy-builtin-esm-exports branch from 1b92c69 to 1f03b20 Compare August 11, 2026 23:54
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
…s ESM

generateInternalModuleSourceCode turns a builtin's CommonJS exports object
into the synthetic module ESM importers see. It read every own property
with get(), so the accessors builtins use to defer loading parts of
themselves (node:fs's stream classes, which pull in the whole node:stream
stack; tls.rootCertificates; http.globalAgent; timers.promises; ...) all
ran on every import of the module, and only require() callers got the
laziness.

Data properties are still snapshotted at import. Accessors are now
declared as lazy exports of the synthetic record and the exports object is
handed over as their source; JSC reads the property the first time an
importer links the binding or reads it off the namespace object. A builtin
whose exports object has no accessors produces the same record as before.

The engine half is oven-sh/WebKit#408; WEBKIT_VERSION moves from
09e477744721 to its merge commit 7b763944f0ec, which is the only change
between the two.
@robobun
robobun force-pushed the farm/68bba673/lazy-builtin-esm-exports branch from 1f03b20 to 435641a Compare August 11, 2026 23:58
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (which now pins 09e477744721 via #37352) and pointed WEBKIT_VERSION at 7b763944f0ec, the merge commit of oven-sh/WebKit#408 and the only commit between the two. The ObjectPrototypeInlines.h include that the earlier range needed is already on main, so the diff is back to webkit.ts (one line), ModuleLoader.cpp and the test file.

Checked locally against the new tarball: the test file passes with the diff and 7 of its 8 cases fail with main's ModuleLoader.cpp; fs.test.ts, the test/js/bun/resolve directory, the mock-module tests, node-module-module.test.js and test-util-types.test.js are green (the only failures were the two load-same-js-file-a-lot timing tests, which time out identically here with main's WebKit).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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 didn't find any bugs. Because it's a coordinated engine + runtime change to how every builtin's ESM record is built — with a WebKit bump whose GC-rooting and IC/TDZ semantics live in oven-sh/WebKit#408 — a human look is still warranted before landing.

Checked on the bun side: exception handling in the rewritten generateInternalModuleSourceCode loop (getOwnPropertySlot / slot.getValue / fallback get each have RETURN_IF_EXCEPTION); !slot.isValue() correctly catches accessor and custom slots while data properties still snapshot eagerly; returning nullptr when no accessors were seen keeps the record identical to before. The test file spawns each case in its own process, drains stdout/stderr/exited concurrently, and covers named/namespace/defer/re-export/mock.module paths. Also confirmed generateObjectModuleSourceCode (plugin loader: "object") and JSON/TOML paths still use plain SyntheticSourceProvider::create, so user-object getter timing is unchanged.

Extended reasoning...

Overview

The PR changes generateInternalModuleSourceCode in src/jsc/bindings/ModuleLoader.cpp so that when a src/js builtin is imported as ESM, own accessor properties on its exports object are declared as lazy exports (empty JSValue slots) instead of being read eagerly via object->get(). It switches the return type to LazySyntheticSourceGenerator (returning the exports object as the lazy source, or nullptr if no accessors were seen) and the call site to SyntheticSourceProvider::createWithLazyExports. WEBKIT_VERSION moves one commit forward to pick up the engine half (oven-sh/WebKit#408). A new 232-line test file exercises named imports, import *, import defer *, dynamic import(), export * / renamed re-exports, function-typed exports objects (node:assert), and spyOn/mock.module interaction.

Security risks

None identified. The change is internal to how Bun's own builtin modules are exposed to ESM; user-supplied objects (plugin loader: "object", user CJS) continue through the unchanged generateObjectModuleSourceCode / SyntheticSourceProvider::create path. No new untrusted-input parsing.

Level of scrutiny

High. fetchESMSourceCode's InternalModuleRegistryFlag branch is on the path of every import 'node:*', and the correctness of leaving slots in TDZ until first binding depends entirely on engine-side invariants (source-object GC visiting in SyntheticModuleRecord, materialization in initializeEnvironment / JSModuleNamespaceObject::getOwnPropertySlotCommon / WebAssemblyModuleRecord, namespace IC bailing on empty slots, DFG folding only through installed ICs) that live in a separate repository's diff. The bun-side C++ is small and looks correct in isolation — each fallible call has RETURN_IF_EXCEPTION, PropertySlot is used with InternalMethodType::GetOwnProperty, and the !slot.isValue() predicate matches JSC's accessor/custom-getter classification — but the overall behaviour cannot be signed off without also reviewing the WebKit half.

Other factors

  • The change intentionally shifts when an accessor export is sampled (import time → first binding). The description argues this is never observable as staler than before, and the tests pin identity to require()'s value on every path, but it is a semantic change to module-loading behaviour that a maintainer should acknowledge.
  • Jarred-Sumner has engaged (asked for the rebase after the WebKit build) but has not yet approved.
  • The WebKit pin move, while a single commit, means every platform's prebuilt tarball changes; CI on the pinned build (#92626) should be green before merge.
  • Test quality is good: per-process isolation (a binding materializes once), concurrent pipe draining, test.concurrent, and 7/8 cases verified to fail on the old path.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to change from that review. The remaining item it lists, CI on the final pin (7b763944f0ec), is running as build 92642 for 435641a; the comment-cop threads from the earlier pushes are resolved.

@Jarred-Sumner
Jarred-Sumner merged commit d8e3e19 into main Aug 12, 2026
52 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/68bba673/lazy-builtin-esm-exports branch August 12, 2026 00:54
Jarred-Sumner pushed a commit that referenced this pull request Aug 12, 2026
… whole Bun object (#37714)

Follow-up to #37525, which made the ES module view of the `src/js`
builtins declare their accessors lazily. The `"bun"` module is not one
of those: it is a native module (`generateNativeModule_BunObject` in
`src/jsc/bindings/BunObject.cpp`), and its generator still built the
record eagerly:

- `object->reifyAllStaticProperties()` runs every `PropertyCallback` in
`bunObjectTable` (about 60 of them: `$`, `SQL`/`sql`/`postgres`,
`S3Client`/`s3`, `RedisClient`/`redis`, `Glob`, `TOML`, `WebView`,
`secrets`, `stdin`/`stdout`/`stderr`, ...) and stores each result on the
object, so that line alone constructs every lazy `Bun.*` property.
- `exportBunObject` then called `get()` on each of them to fill the
export values.

Note that the most common forms never reach this code: the transpiler
rewrites a literal `import { write } from "bun"`, `import * as b from
"bun"`, `import("bun")` and `require("bun")` into reads of
`globalThis.Bun` (`src/js_printer/lib.rs`, `ImportRecordTag::Bun`),
which is why `import { write } from "bun"` only ever constructed
`write`. The generator runs for `export * from "bun"` / `export { x }
from "bun"` and for `import()` with a non-literal specifier, and every
one of those constructed all 115 static properties.

## Repro

```js
// reexport.mjs
export * from "bun";
// entry.mjs
import { describe } from "bun:jsc";
import { write } from "./reexport.mjs";
// describe() dumps the Structure, i.e. the static-table entries that have been constructed so far.
console.log(describe(Bun).match(/\{[^}]*\}/)[0].split(",").length);
```

Before: 116 entries (`Symbol.toStringTag` plus all 115). After: 2
(`Symbol.toStringTag` and `write`). A non-literal `import(specifier)` of
`"bun"` behaves the same way.

A second consequence of the eager path: a property callback that throws
fails the whole module. `Bun.redis` builds the default client from
`REDIS_URL` and throws on an invalid URL, so with
`REDIS_URL='http://[::1'` the entry above died at load with `TypeError:
Invalid URL format` even though it only wanted `write`. (The
`DECLARE_TOP_EXCEPTION_SCOPE` in `exportBunObject` that turned getter
errors into `undefined` never got to run for these, because
`reifyAllStaticProperties()` propagated first.)

## Fix

`generateNativeModule_BunObject` now has the
`LazySyntheticSourceGenerator` signature from #37525. It lists the
object's own property names (`getOwnNonIndexPropertyNames` includes the
static table entries that have not been reified, so dropping
`reifyAllStaticProperties()` leaves the export list unchanged,
`DontEnum` entries excluded as before), provides `default` = the Bun
object, appends an empty `JSValue` for every other export, and returns
the Bun object as the source. JSC's `materializeLazyExport` then reads
`Bun[name]` the first time something binds to that export: when an
importer links a named import of it (directly or through `export *` /
`export { x as y } from`), or when it is read off a namespace object.
Reading it reifies exactly that one property on the Bun object, the same
thing a direct `Bun[name]` access does, so the binding is identical to
the property (`main`, the one `CustomAccessor`, is read through its
getter like before).

Plumbing: the native module list in `NativeModuleList.h` gets a separate
`BUN_FOREACH_LAZY_ESM_NATIVE_MODULE` group for generators with this
signature (the codegen scanner numbers modules by their order in the
file, and `bun` was already last, so the ids do not move);
`_NativeModule.h` forward-declares that group with the new return type;
`ModuleLoader.cpp` dispatches it through
`SyntheticSourceProvider::createWithLazyExports`; and
`generateNativeModule` in `InternalModuleRegistry.cpp` (the generated
`createInternalModuleById` case, which only needs the default export)
becomes a template so it accepts both signatures.

### Behaviour differences

- When a `Bun.*` value is sampled: at first binding rather than at
module load. The affected values are constructed on first read and then
fixed, so the same object is handed out either way; for `main` (a live
getter) a re-export now samples the value when it is first bound rather
than when the module loads, which is the same behaviour #37525 gave the
builtins' accessors.
- A throwing callback now throws from whatever binds to that export (the
import that links it, or the namespace read, including `Object.keys(ns)`
/ `console.log(ns)` since those read every export), and the other
exports keep working. Previously it failed the module. Of the current
callbacks, `Bun.redis` throws on an invalid `REDIS_URL` / `VALKEY_URL`,
`Bun.embeddedFiles` can throw on OOM, and `Bun.$`, `Bun.sql`,
`Bun.postgres`, `Bun.SQL` would throw if their builtin failed to load;
`Bun.s3` reports its error as unhandled and yields `undefined` instead.
The error surfacing on the `redis` binding is the same error a direct
`Bun.redis` read produces, which the new test pins.
- `mock.module("bun", ...)` on an already loaded record writes into the
binding with `overrideExportValue`; `materializeLazyExport` is a no-op
for a slot that has a value, so the mock wins and the real property is
never constructed.
- No bulk reification means nothing here runs callbacks back to back
without exception checks, which is what #33150 was fixing in the eager
path; this supersedes it. Checked with
`BUN_JSC_validateExceptionChecks=1` on the re-export, the non-literal
`import()`, the throwing `redis` case, and a full `Object.keys(ns)`
materialization.

## Tests

Added to `test/js/bun/resolve/builtin-esm-lazy-exports.test.ts` (the
file from #37525), each in its own process. The readout is `bun:jsc`'s
`describe(Bun)`, filtered to a sample of the lazy properties plus
`write`; `Object.getOwnPropertyDescriptor` cannot be used because it
reifies the property it is asked about.

- `export *` / `export { x as y }` / `export { default as ... }`
re-exports: linking `import { write }` constructs only `write`; `in`
constructs nothing; reading the renamed export constructs `Glob`;
reading through the star constructs `SQL`; each is identical to the
`Bun.*` value and stable across reads; `default` is the Bun object.
- Non-literal `import()`: nothing constructed on import;
`Reflect.ownKeys` of the namespace equals `Object.keys(Bun)` plus
`default` and constructs nothing; reading `TOML` constructs only `TOML`;
every export is `===` the corresponding `Bun` property (which constructs
everything). The specifier is imported from the helper module because a
`const` holding it can get inlined into a literal `import("bun")` in
some files.
- `REDIS_URL` set to an invalid URL: the module still loads,
`reexported.redis` throws the same error as `Bun.redis`, other exports
work.
- `mock.module("bun", () => ({ SQL: "mocked" }))` in a `bun test` child
after the re-export was imported: the namespace sees the mock, `SQL` is
never constructed, `Bun.SQL` is intact.

All four fail on the merge base (everything shows up as constructed
after import; the `REDIS_URL` case fails at load), and the eight
existing cases in the file pass on both. Also run on this build:
`test/js/bun/util/BunObject.test.ts` (which `console.log`s a non-literal
`import()` namespace of `"bun"` and compares every property),
`test/js/bun/resolve/`, `test/js/bun/test/mock/`,
`test/js/node/stubs.test.js`, `node-module-module.test.js`, and
`test-process-get-builtin.mjs`; all green apart from
`load-same-js-file-a-lot.test.ts`, which times out identically on the
unmodified build in this environment.

## Startup

Wall time of `bun file.mjs`, min of 10 runs after a warm-up, debug
(ASAN) builds of the merge base and of this branch on the same machine:

| file | before | after |
| --- | ---: | ---: |
| empty module | 273 ms | 264 ms |
| `import { write } from "bun"; write.length` | 277 ms | 251 ms |
| same, but `write` comes from a module doing `export * from "bun"` |
555 ms | 279 ms |

The literal form is rewritten by the transpiler and was already at the
floor; the re-export form loses the roughly 280 ms (debug build) it
spent constructing the object. Release numbers to follow in a comment.
Jarred-Sumner pushed a commit that referenced this pull request Aug 12, 2026
Follow-up to #37714 (the `"bun"` module) and #37525 (the `src/js`
builtins): the two remaining native ES modules that mirror an existing
object, `node:process` and `node:module`, still built their records
eagerly. `generateNativeModule_NodeProcess` called `get()` on every
enumerable property of `process` and its prototype chain, and
`generateNativeModule_NodeModule` on every entry of the Module
constructor's static table. Unlike `"bun"`, these imports are not
rewritten by the transpiler, so every `import process from
"node:process"` (a very common line in published ESM packages) and every
`import { createRequire } from "node:module"` paid for it.

## Repro

```js
// entry.mjs
import { describe } from "bun:jsc";
import process from "node:process";
import { createRequire } from "node:module";
// describe() dumps an object's Structure, i.e. which of its static table entries have been constructed so far.
const entries = object => describe(object).match(/\{[^}]*\}/)[0].split(",").length;
console.log(entries(process), entries(globalThis.process.getBuiltinModule("node:module")));
```

Before: `86 27`. For `process` that is everything in the table that can
be reified, including `stdout`, `stderr` and `stdin`, which construct
the stdio streams and load `node:tty` / `node:stream` to do it, plus
`config`, `release`, `allowedNodeEnvironmentFlags`, `versions`, ...; for
`Module` it is `_cache`, `builtinModules`, `globalPaths`, `SourceMap`,
the `wrapper` proxy and the rest of the table. After: `2 3`, i.e. only
what is there before user code runs (`Symbol.toStringTag` and `_exiting`
on `process`, `length` and `name` on `Module`) plus `createRequire`, the
one thing the file imported.

On this machine (release build of 1.4.0, min of 20 runs), `import
process from "node:process"` added about 18 ms to the startup of an
otherwise empty file, about the same as touching `process.stdout` does;
with this change the import itself is free and that cost moves to
whoever actually reads `stdout`. Measured numbers (release 1.4.0 for the
eager cost, a debug build of this branch for the new one) are in a
comment below.

## Fix

Both generators move to the `BUN_FOREACH_LAZY_ESM_NATIVE_MODULE` group
added in #37714 (the entries keep their position in
`NativeModuleList.h`, so the generated ids do not change) and share one
helper, `exportObjectProperties()` in `_NativeModule.h`, which the
`"bun"` generator now uses too. For each name the caller wants exported
it does the split #37525 does for the `src/js` builtins: a value that is
already stored on the object (`getDirect()` finds a plain value) is
exported as is, anything else is declared without a value and JSC's
`materializeLazyExport` reads `object[name]` the first time something
binds to it. "Anything else" covers a static table entry nobody has read
yet (the expensive case), an accessor (`process.argv`, `process.title`,
`Module._resolveFilename`, `Module.wrapper`), and a property inherited
from the prototype chain (the EventEmitter methods of `process`). The
generators themselves only decide the name list, which is what keeps the
export lists identical to before:

- `node:process`: `getPropertyNames()` on `process`, as before, so the
inherited EventEmitter methods (`on`, `emit`, ...) stay exports, and a
data property assigned onto `process` before the module is loaded is
still exported and still snapshotted at load (`process.test.js` has a
test for exactly that, with `default` on top, which keeps being skipped
in favour of the object).
- `node:module`: the static table, as before, so `length`, `name` and
anything user code assigned onto `Module` stay out.
- `"bun"`: `getOwnNonIndexPropertyNames()`, as in #37714. The only
difference from #37714 is that a `Bun.*` property something already read
before the module loads is now snapshotted instead of declared lazily;
it is the same object either way.

The generators no longer run any getters, so the `TopExceptionScope`
handling that turned a throwing getter into an `undefined` export (and
the comments about not bulk-reifying because of the exception-check
verifier) go away with them. A getter that throws now throws from
whatever binds to that export, and a termination arriving while a
binding is being materialized propagates from there, the same way #37714
describes for `"bun"`. The sampling-time difference is also the same as
there: an accessor export is read when it is first bound rather than
when the module loads, which for the first importer is the same moment.

## Tests

Added to `test/js/bun/resolve/builtin-esm-lazy-exports.test.ts`, same
shape as the `"bun"` cases (each in its own process, readout is
`describe()` of the object filtered to a watched sample of names;
`Object.keys` / `Reflect.ownKeys` are used for the export-list checks
because `for...in` reifies every static property of the object it
enumerates):

- `node:process`: linking `import proc, { on, release }` constructs
exactly `release`; `on` is the inherited method; the export list equals
the enumerable names of `process` and its prototype chain plus
`default`; listing it constructs nothing; reading `stdout` off the
namespace constructs exactly `stdout` and is the real stream; `argv` (an
accessor) binds to `process.argv`.
- `node:process`: a data property assigned before the load is exported
with its value at load time, and one assigned afterwards is not exported
(the part of the behaviour this keeps from the eager version).
- `node:module`: linking `import Module, { createRequire }` constructs
exactly `createRequire` (and it works); the export list equals
`Object.keys(Module)` plus `default`; reading `builtinModules`
constructs exactly that and is the same array; `_resolveFilename` binds
to the accessor's value.

The two "linking constructs ..." cases fail on main (everything watched
shows up as constructed after import); the snapshot case and the 12
existing cases in the file pass on both. Also run on this build:
`test/js/node/process/` (`process.test.js`, `process-stdio`,
`process-on`, `call-constructor`, which imports `node:process` as ESM),
`test/js/node/module/`, `test/js/node/events/event-emitter.test.ts`,
`test/js/bun/util/BunObject.test.ts`, `test/js/bun/test/mock/`,
`stubs.test.js`, `require-esm-transitive-tla` and `import-meta-resolve`;
green except for two tests that fail identically without this change in
this environment (`process.test.js` "process" wants `$USER` set, and
`process-args.test.js` spawns 100 debug processes inside a 5 s timeout).
`BUN_JSC_validateExceptionChecks=1` is clean for importing both modules
and for reading every export of both namespaces.
robobun added a commit that referenced this pull request Aug 12, 2026
Builtin ESM records declare accessor-backed exports lazily (#37525); the
binding slot stays empty until something binds to it. Snapshotting the
original through the namespace object read the slot, which ran the getter
and defeated the laziness mock.module() is supposed to preserve.

Read the binding's slot directly instead. A materialized value is
snapshotted as before. An empty slot is recorded against the record's
default export (the builtin's exports object, which is what the engine
reads lazy exports from), and mock.restore() reads the real value off it
at that point, so the getter runs when the user restores, not when they
mock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants