Skip to content

Declare node:process and node:module ESM exports lazily as well - #37726

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/3b4bf1e4/lazy-process-module-esm-exports
Aug 12, 2026
Merged

Declare node:process and node:module ESM exports lazily as well#37726
Jarred-Sumner merged 1 commit into
mainfrom
farm/3b4bf1e4/lazy-process-module-esm-exports

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

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

// 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:

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.

Both generators still called get() on every property of the object they
mirror, so `import process from "node:process"` constructed process.stdout,
stderr and stdin (and with them the tty and stream stack), config, release,
allowedNodeEnvironmentFlags and the rest of the table, and importing
node:module built the require cache object, builtinModules, the wrapper
proxy and so on, all before the importer ran.

They now share exportObjectProperties() with the "bun" module: a value that
is already stored on the object is exported as is, everything else (static
table entries nobody has read, accessors, inherited properties) is declared
without a value and read off the object when something first binds to it.
The export lists are unchanged: process still lists its whole prototype
chain, module still lists exactly its static table.
@coderabbitai

coderabbitai Bot commented Aug 12, 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: 5 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: caf0e1a5-d0fd-4f03-b6bc-e84628e5f43b

📥 Commits

Reviewing files that changed from the base of the PR and between 3e61ab3 and 4571912.

📒 Files selected for processing (7)
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/modules/NativeModuleList.h
  • src/jsc/modules/NodeModuleModule.cpp
  • src/jsc/modules/NodeModuleModule.h
  • src/jsc/modules/NodeProcessModule.h
  • src/jsc/modules/_NativeModule.h
  • test/js/bun/resolve/builtin-esm-lazy-exports.test.ts

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

@robobun

robobun commented Aug 12, 2026

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

@robobun, your commit 4571912 is building: #92779

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with the snippet in the description (import process from "node:process" constructed 86 entries of the process object and import { createRequire } from "node:module" 27 of the Module constructor; 2 and 3 with this change). Fix and tests are in this PR; startup numbers are in the comment below.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Startup numbers. All wall-clock bun file.mjs, min of the runs after a warm-up, same machine. The box this was measured on kept running out of disk, so a release build of this branch did not get built; the release row is the eager cost as shipped in 1.4.0 (whose node:process / node:module generators are the ones main has), the debug rows are this branch.

Release 1.4.0 (eager, n=20):

file min
empty module 7.3 ms
globalThis.process.pid 7.4 ms
process.stdout (just constructing the stream) 23.0 ms
import process from "node:process"; process.pid 25.1 ms

So before this change the import cost about 18 ms over an empty file, which is the stdio streams (stdout, stderr, stdin are three of the 84 entries it constructed) plus the rest of the table.

Debug + ASAN build of this branch (n=10):

file min
empty module 247 ms
globalThis.process.pid 261 ms
import process from "node:process"; process.pid 256 ms
process.stdout (just constructing the stream) 1084 ms
process.getBuiltinModule("node:module").createRequire 243 ms
import { createRequire } from "node:module" 255 ms

The two imports are now within noise of touching the global directly; constructing stdout alone is ~840 ms on this build, and the eager generator used to do that (and stderr, stdin, and the rest) on every import ... from "node:process", which is also what every test file that imports it was paying. The export lists are unchanged (pinned by the tests), and the repro in the description shows 2 / 3 entries constructed instead of 86 / 27.

@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 changes the sampling semantics for node:process / node:module ESM exports (accessor-backed exports now read at first binding, and a throwing getter now throws instead of exporting undefined), a human look would still be worthwhile even though the pattern mirrors #37714.

What was reviewed:

  • NativeModuleList.h reordering: verified the regex-based scanner in internal-module-registry-scanner.ts sees the same file-order sequence, so generated ids are unchanged.
  • exportObjectProperties: getDirect returns empty for un-reified static entries and inherited names, and the GetterSetter/CustomGetterSetter guard prevents leaking accessor cells into the namespace.
  • Consumers of the changed generator signatures (ModuleLoader.cpp createWithLazyExports, InternalModuleRegistry.cpp's templated generateNativeModule) both accept the JSObject* return.
Extended reasoning...

Overview

This PR extends the lazy-ESM-export pattern from #37714 (the "bun" module) to node:process and node:module. Both generators move from BUN_FOREACH_ESM_NATIVE_MODULE to BUN_FOREACH_LAZY_ESM_NATIVE_MODULE, and the three generators now share one helper, exportObjectProperties() in _NativeModule.h. The helper exports each name either as its already-stored value (getDirect finds a plain data property) or as a lazy binding that JSC materializes on first read. The old per-export get() loop and its TopExceptionScope cleanup are deleted. Three new subprocess tests are added alongside the existing "bun" cases in builtin-esm-lazy-exports.test.ts.

Security risks

None identified. The change does not touch validation, auth, or any user-input path; it only defers when existing property callbacks run.

Level of scrutiny

Medium-high. The mechanism is a straight application of the pattern already merged in #37714, and the diff is small and mostly deletion. But it sits on the module-loading path that every ESM import process from "node:process" and import { createRequire } from "node:module" goes through, and it carries a documented behavioral change: an accessor-backed export is read when first bound rather than when the module loads, and a getter that throws now throws from the binding instead of exporting undefined. The PR description argues (correctly, as far as I can tell) that for the first importer these are the same moment, and the throwing case matches what #37714 already established for "bun" — but a maintainer should confirm that contract is acceptable for node:process too, since Node compat expectations are stricter there than for the Bun object.

Other factors

I verified that the file-order-sensitive codegen in internal-module-registry-scanner.ts (a regex over macro("...", ...) lines) still sees NodeModule → NodeProcess → BunObject in the same positions after the macro-group move, so generated numeric ids are unchanged. The two remaining consumers of the generator signature — ModuleLoader.cpp's SyntheticSourceProvider::createWithLazyExports case and InternalModuleRegistry.cpp's templated generateNativeModule — both handle the JSObject*-returning form. The new helper's isGetterSetter() || isCustomGetterSetter() guard mirrors the existing cachedDataValue lambda in INIT_NATIVE_MODULE, so a reified CustomAccessor (e.g. Module._resolveFilename, Module.wrapper, Bun.main) is not leaked as a raw cell into the namespace. Test coverage is thorough (linking, listing, reading, inherited methods, accessor bindings, pre-load data-property snapshotting) and each case runs in its own subprocess. The one cosmetic nit is a missing trailing newline in _NativeModule.h.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the trailing newline in _NativeModule.h: that predates this PR (the file has never ended with one; it is clang-format off, so the Format check does not mind). The current CI run is nearly through and green so far, so I am not re-running it for that byte alone; if anything else needs a push here I will fold it in.

@Jarred-Sumner
Jarred-Sumner merged commit 2ffb8d4 into main Aug 12, 2026
52 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/3b4bf1e4/lazy-process-module-esm-exports branch August 12, 2026 05:08
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