Skip to content

node:module: make Module.runMain a data property and call what it holds after the preloads - #47833

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/9486e685/module-runmain-data-property
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/9486e685/module-runmain-data-property

Conversation

@robobun

@robobun robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Draft. @dylan-conway: only the runMain row of Layer 3 (#35466). May it land before oven-sh/WebKit#339?

Problem

  • A --require preload that replaces Module.runMain with Object.defineProperty, or with delete then =, is ignored: Bun prints main ran. Node.js v26.3.0 calls the replacement.
  • runMain is a CustomAccessor row (src/jsc/modules/NodeModuleModule.cpp:1016). Only its setter sets has_patched_run_main, which reload_entry_point tests (src/jsc/VirtualMachine.rs:3832).

Fix

  • runMain is a Function row. After the preloads, Bun calls what the property holds, with Module as this, unless that is the default. The setter, its copy and both flags are deleted.
  • Separate: text (--eval, stdin) and a stopped Worker no longer go through Module.runMain, as in Node.js.
  • Verified: test/js/node/module/node-module-module.test.js (45 new tests, 37 fail on main), rows in run-aot.test.ts and hot.test.ts.
  • Self-reviewed: 11 concerns raised, 11 addressed.

Background

  • Module.runMain(main) starts the main file in Node.js (run_main_module.js).
  • A CustomAccessor row of a static table is a native getter and setter. A Function row is a plain data property.
  • Considered a read beside the setter: a subclass, a frozen Module and a number still reach it.

Downsides

  • Breaking: a preload's Module.runMain = 5 or delete ends with a TypeError, as in Node.js.
  • Farther from Node.js: every patch from an --import preload is called (12 of 22 forms equal before, 7 after). A defined wrapper that awaits before the original hides the main script's throw (node:module: report a script that Module.runMain() starts and that fails #45358).
  • With a preload: +27 instructions per start (+232 if it loads node:module), no allocation.
Notes

The decision this draft needs.

  • Layer 3 of the plan in cjs: read module source through an overridden fs.readFileSync #35466 says: "_resolveFilename, runMain: plain data properties", "slow path reads the current value and calls it", and "The dead hasOverriddenModuleRunMain bool on ZigGlobalObject and the three CustomAccessor pairs are deleted".
  • @dylan-conway answered "great. do layers 1-3", "oops i mean layers 0-3", and then "we're going all on the plan we agreed on. this pr you will force push over with the proper fix once the webkit change is ready" (comment). The stale sweep closed cjs: read module source through an overridden fs.readFileSync #35466 unmerged.
  • This PR is not that delivery. It changes one row, keeps the static table, and uses a function of its own (NodeModuleModule__hasReplacedRunMain) where the plan has the bit of Layer 1.
  • The rule about text entries and the check for a stopped Worker are not in Layer 3. Each is one condition of the if in reload_entry_point. Say if they must be a PR of their own.
  • If the answer is no, the row and its tests go into the Layer 3 change.
  • A run of the same scripts under Bun and Node.js found the defect. No issue reports it.

Repro.

// preload.cjs
const Module = require("node:module");
Object.defineProperty(Module, "runMain", {
  value() { console.log("runMain patch called"); },
  writable: true, configurable: true, enumerable: true,
});
// main.cjs
console.log("main ran");
-r ./preload.cjs main.cjs
Node.js v26.3.0 runMain patch called
main bdb0471 main ran
this PR runMain patch called

22 ways to change the property, 8 ways to start. Each cell compares stdout and the exit code with Node.js v26.3.0. Release builds of main bdb0471 and of this PR.

start main: equal to Node.js this PR
--require preload, file 6 of 22 22 of 22
Worker with a preload, file 6 of 22 22 of 22
--eval 12 of 22 22 of 22
--print 12 of 22 22 of 22
stdin 12 of 22 22 of 22
Worker with eval: true 12 of 22 22 of 22
Worker from a data: URL 12 of 22 22 of 22
--import preload, file 12 of 22 7 of 22
  • Of the 176 cells, 98 give another result than on main. 87 of those equal Node.js now. The other 11 are in the --import row: 7 ways now call the patch and 4 end with the TypeError, where Node.js runs the main script.
  • The 22 ways: an assignment, Reflect.set, Object.assign, Object.defineProperty / Reflect.defineProperty / Object.defineProperties with a value, a getter, delete and an assignment, delete and a function on Function.prototype, an assignment and then Object.defineProperty, three ways to put the original back, Object.defineProperty with other attributes only, a write to a subclass, to a frozen Module, with another receiver, and 5, null, undefined, {}, delete alone.
  • Node.js attaches a preload to a Worker with execArgv: ["--require", file], Bun with the preload option.
  • The 17 ways that leave a function, and the wrapper, getter and this rows, are rows of the test. Node.js prints the same for each with a CommonJS and with an ES module main file (57 of 57 runs).

What changes for a preload that runs on main.

  • Module.runMain = 5, = null, = undefined and delete Module.runMain: the main script ran. Now TypeError: 5 is not a function and exit code 1. Node.js: TypeError: require(...).Module.runMain is not a function, exit code 1. Fix a segfault when Module.runMain is not a function, and report what an override throws #44352 did the same for {}, a string, a symbol and a BigInt.
  • Sub.runMain = f on a subclass of Module, Module.runMain = f on a frozen Module, Reflect.set(Module, "runMain", f, other): f was called. Now the main script runs, as in Node.js.
  • A patch that is put back with Object.defineProperty was still called. Now the main script runs.
  • The replacement is called with Module as this. It was the global object.
  • --eval, --print, stdin, bun --interactive, a cron run (--cron-title, --cron-period), a Worker from a data: or blob: URL (also eval: true of node:worker_threads): an assigned patch was called and the text did not run unless the patch called the original. Now the text runs. Node.js has no cron run. For the others it does the same (eval_string.js reads no runMain).
  • A Worker whose preload calls process.exit(), or that its parent terminates while the preload runs: an assigned patch was called after the stop. Now nothing is called, as in Node.js. load_preloads makes the same check (src/runtime/jsc_hooks.rs:952).

Still different from Node.js.

  • A patch from an --import preload is called. Node.js reads Module.runMain before it loads an --import module. On main an assigned patch is called (12 of 22 equal), with this PR every way to install one (7 of 22 equal). --import, --preload, --require and a preload of bunfig.toml are one list in Bun (src/runtime/cli/Arguments.rs:1470).
  • A patch that a preload installs in a later tick (process.nextTick) is called. Node.js reads the property in the tick of the last --require module. Bun waits for each preload.
  • The replacement gets "bun:main" for a file, not the path. original(...args) then loads the generated entry module. A wrapper that passes process.argv[1] starts the main module too (a row of the test), but not a server from export default { fetch }. node:module: default Module.runMain() to process.argv[1] and reject a non-string argument #41147 and node:module: make a script that Module.runMain() starts the main module #47665 are open for the argument.
  • A replacement that calls Module._load(main, null, true) starts nothing: _load is a stub (Route require() through an overridden Module._load #33026).
  • A wrapper that calls the original after an await, a timer or a tick: what the main script throws is not reported, and the exit code is 0. On main that is so for an assigned wrapper. With this PR it is so for every way to install one. The row is test.failing with the output of Node.js. node:module: report a script that Module.runMain() starts and that fails #45358 reports such a load.
  • A --hot reload does not call the replacement again (a row of hot.test.ts). The preload list is empty at a reload.
  • A Worker does not run the --require preloads of the command line. NODE_OPTIONS=--require is not read.
  • The text of the TypeError.

The same defect in other hooks, not changed here.

Open PRs that read what this PR deletes.

How it works.

  • The static table row is runMain jsFunctionRunMain Function 2: name runMain, length 2, { writable, enumerable, configurable }, as the function had on main.
  • reload_entry_point runs the check only if there are preloads, the entry point is a file, and script may still run. Then it calls NodeModuleModule__hasReplacedRunMain.
  • That function returns false if nothing made the Module constructor in the realm. It looks the name up in the atom table without adding it, and reads the own property without a Get. No own property and rows that are not own properties yet: the default. No own property after a delete: replaced. Anything but the default function, an accessor too: replaced.
  • Only then NodeModuleModule__callOverriddenRunMain does one Get (a getter runs once) and the call. A value that is no function throws the TypeError of Fix a segfault when Module.runMain is not a function, and report what an override throws #44352.
  • Sub.runMain = f now makes an own property of Sub. jest-runtime copies the statics of Module that way.

Measurements. Release builds of main bdb0471 and of this PR on one machine.

  • size: text 140,285,692 to 140,285,180 bytes (-512). data and bss are the same.
  • llvm-nm: moduleRunMain (126), setModuleRunMain (384) and the initializer of the copy (437) are gone. NodeModuleModule__hasReplacedRunMain is 274 bytes, NodeModuleModule__callOverriddenRunMain 628 to 750, reload_entry_point 4,351 to 4,459.
  • sizeof(Zig::GlobalObject): 11,072 to 11,064 bytes (DWARF of the debug builds).
  • Instructions from the return of load_preloads to the load of the entry point (gdb stepi): 37 to 64 with a preload that does not load node:module, 37 to 269 with one that does, 37 to 410 with one that reads Module.runMain. No syscall in that window on either build.
  • Instructions of reload_entry_point itself with no preload: 361 to 360.
  • Allocator calls in NodeModuleModule__hasReplacedRunMain (gdb breakpoints on WTF::fastMalloc and its siblings and on mi_malloc and its siblings): 0 for each of four preloads.
  • Calls of it for each start: 0 with no preload, 1 with a preload, 0 for --eval with a preload. It makes no Module constructor.
  • Cells after Bun.gc(true) (heapStats().objectTypeCounts): the same as main with no preload, an empty preload and a preload that loads node:module. A preload that reads Module.runMain: +1 Structure. A wrapper that reads and assigns: +1 Structure, +1 StructureRareData.
  • Generated code: one row of NodeModuleModule.lut.h, one host export fewer (119 to 118). aot_builtin_table.json is the same file.

Verified.

  • Debug build with ASAN: node-module-module.test.js 125 pass. 37 of the 45 new tests fail on main. The other 8 hold what main does already.
  • run-aot.test.ts: the new row passes for bun run and bun run --debug-aot, and fails on main. hot.test.ts: 27 pass, the new row 15 of 15 runs.
  • worker.test.ts 43 pass, worker_threads.test.ts 164, preload-test.test.js 8, test-module-run-main-monkey-patch.js.
  • Fails on main too, in the same debug build: one test of plugins.test.ts (a timeout), one of run-aot.test.ts.
  • BUN_JSC_validateExceptionChecks=1: a getter that throws, a Proxy on the prototype chain, a value that is no function, a stopped Worker. No report.
  • 6 files of test/aot/builtins pass. aotBuiltinMismatches() returns the same list as on main. The rest of test/aot was not run here.
  • Release build: 157 upstream tests test-module-*, test-require-*, test-preload* and test-worker-* give the same results as on main.
  • By hand: an executable of bun build --compile --compile-exec-argv="--require ./preload.cjs" calls a defined wrapper. mock.method(Module, "runMain") of node:test works (on main it throws ERR_INVALID_ARG_VALUE).

Self-review. Eleven concerns came back.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/module/node-module-module.test.js, test/cli/run/run-aot.test.ts, test/cli/hot/hot.test.ts

…ds after the preloads

Module.runMain was a custom accessor. Only its setter told the runtime
that a preload had replaced the function, and the runtime called a copy
that the setter kept. Object.defineProperty and delete followed by an
assignment never reached the setter.

Module.runMain is now a function row of the static table. After the
preloads, the runtime looks at the property of the Module constructor
and calls what it holds, with Module as the receiver, when that is not
the default function. Node.js does the same with Module.runMain(main).

An entry point that is text (--eval, --print, stdin, a Worker from a
data: or blob: URL) is not started through Module.runMain, as in
Node.js.

Deleted: the accessor pair, m_moduleRunMainFunction,
hasOverriddenModuleRunMain, has_patched_run_main and
Bun__VirtualMachine__setOverrideModuleRunMain.
…preload stopped

A Worker whose preload calls process.exit(), or that its parent
terminates while the preload runs, no longer reads Module.runMain and
no longer calls a replacement. load_preloads makes the same check.

Tests: the rows for what stays different from Node.js (an --import
preload, a write in a later tick, a wrapper that calls the original
late), cron runs, bun run --aot and --hot.
@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How this was reproduced:

  • preload.cjs replaces Module.runMain with Object.defineProperty. main.cjs prints main ran.
  • node -r ./preload.cjs main.cjs (v26.3.0) prints runMain patch called.
  • bun -r ./preload.cjs main.cjs on main bdb0471 prints main ran.
  • The same files are rows of test/js/node/module/node-module-module.test.js. 37 of the 45 new tests fail on a debug build of main. All pass on this branch.

CI (build 137180, finished):

  • test/js/node/module/node-module-module.test.js passes on every lane that was read: debian x64 and x64-asan, alpine x64, darwin aarch64 and x64 (125 pass each), Windows x64 and aarch64 (122 pass, 4 skipped).
  • test/cli/hot/hot.test.ts and test/cli/run/run-aot.test.ts have no failure on any lane.
  • 40 other test files are red: 39 under test/aot, and test/bundler/compile-aot-size.test.ts. None is in this diff.
  • Each of the 40 is also red in the final build of css: keep a named sublayer that follows an anonymous layer in a masked import #47810, which is merged (build 137115).

This PR is #47833. It is a draft: it waits for an answer to the question at the top of the description.

@github-actions github-actions Bot added the claude label Nov 3, 2026
@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:25 AM PT - Nov 3rd, 2026

❌ @robobun, your commit f405fed has 40 failures in Build #137180 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47833

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

bun-47833 --bun

Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/modules/NodeModuleModule.cpp Resolved
Comment thread src/jsc/modules/NodeModuleModule.cpp Resolved
Comment thread src/jsc/web_worker.rs Resolved

This branch has not been deployed

No deployments
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.

1 participant