Skip to content

Add Module.prototype.load for new Module() instances#29256

Open
robobun wants to merge 15 commits intomainfrom
farm/fe239255/fix-module-prototype-load
Open

Add Module.prototype.load for new Module() instances#29256
robobun wants to merge 15 commits intomainfrom
farm/fe239255/fix-module-prototype-load

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 13, 2026

Closes #29253

Repro

npm install jsdoc
bun run node_modules/jsdoc/jsdoc.js
102 |   targetModule.load(targetModule.id);
                     ^
TypeError: targetModule.load is not a function. (In 'targetModule.load(targetModule.id)', 'targetModule.load' is undefined)
      at load (node_modules/requizzle/lib/loader.js:102:16)
      at requizzle (node_modules/requizzle/lib/requizzle.js:96:27)

Cause

Two related gaps:

  1. require('module').prototype returned a disposable object with only require and _compile on it. Instances created via new Module(...) were backed by JSCommonJSModulePrototype — a different object. Patching one never affected the other, and Module.prototype.load did not exist.

  2. Neither prototype had load. Packages like requizzle (a jsdoc dependency) construct a module by hand and call targetModule.load(targetModule.id) — the same pattern Node's cjs loader uses internally — so the whole jsdoc CLI crashed on launch.

Fix

  • src/bun.js/modules/NodeModuleModule.cppgetModulePrototypeObject now returns CommonJSModuleObjectStructure()->storedPrototypeObject(), so require('module').prototype === Object.getPrototypeOf(new Module(...)). Patching the constructor prototype is now visible on every instance (Node semantics).

  • src/js/builtins/CommonJS.ts — added modulePrototypeLoad (mirrors Node's Module.prototype.load): sets filename + paths, dispatches to Module._extensions[ext], marks loaded. Default .js extension reads the file and calls module._compile, which honors the current Module.wrapper.

  • src/bun.js/bindings/JSCommonJSModule.cppJSCommonJSModulePrototype::finishCreation now puts the builtin load on the prototype.

Verification

bun bd test test/regression/issue/29253.test.ts
(pass) Module.prototype.load is a function (#29253)
(pass) Module.prototype is the instance prototype (#29253)
(pass) new Module().load(filename) reads and evaluates the file (#29253)
(pass) Module.prototype.load honors an overridden Module.wrapper (#29253)
(pass) new Module().load populates filename/paths/loaded (#29253)

 5 pass
 0 fail

Without the fix, all 5 tests fail with m.load is not a function. Existing module tests (test/js/node/module/, 59 tests) still pass — in particular "Overwriting Module.prototype.require", which exercises the prototype-unification change.

requizzle (jsdoc's dependency) constructs modules by hand and
calls .load() on them — the same pattern Node's cjs loader uses
internally. Bun's Module.prototype was a disposable object with
only `require` and `_compile`, and instances had no `load`
at all, so `targetModule.load is not a function` fired before
jsdoc could start.

Fix:

- Unify `require('module').prototype` with the prototype used
  by `new Module(...)` (JSCommonJSModulePrototype). Patching
  one now affects the other, matching Node.

- Add `Module.prototype.load(filename)` as a builtin that sets
  filename/paths, dispatches to the registered extension, and
  marks the module loaded. The default .js extension reads the
  file and calls module._compile, which honors the current
  Module.wrapper.

Fixes #29253
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 13, 2026

Updated 9:36 AM PT - Apr 13th, 2026

@robobun, your commit 4d3379d has 4 failures in Build #45514 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29256

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

bun-29256 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Node-compatible Module.prototype.load by registering a builtin on the CommonJS module prototype, implementing Module.prototype.load in TypeScript, and adding regression tests covering load semantics, error recovery, wrapper injection, and compound-extension dispatch.

Changes

Cohort / File(s) Summary
Prototype binding
src/bun.js/bindings/JSCommonJSModule.cpp
Registers a direct builtin load on JSCommonJSModulePrototype (Module.prototype.load) via putDirectBuiltinFunction(..., "load", WebCore::commonJSModulePrototypeLoadCodeGenerator(vm), 0).
Load implementation
src/js/builtins/CommonJS.ts
Adds private modulePrototypeLoad(this: JSCommonJSModule, filename: string) that checks loaded, sets this.filename/this.paths, selects the longest-matching handler from Module._extensions (including compound extensions), invokes the handler with $call, and ensures this.loaded is set only after successful execution (reverts on throw).
Tests
test/regression/issue/29253.test.ts
Adds regression tests verifying Module.prototype.load is inherited, end-to-end evaluation and exports/bookkeeping (loaded, filename, paths), wrapper override behavior, retry-on-failure clearing of loaded, and compound-extension handler precedence.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding Module.prototype.load for new Module() instances, which matches the primary objective of fixing the missing load method.
Description check ✅ Passed The description includes a linked issue reference, clear reproduction steps, detailed cause analysis, comprehensive fix explanation across three files, and verification with passing test results.
Linked Issues check ✅ Passed The PR implementation directly addresses issue #29253 by adding Module.prototype.load and unifying prototypes so instances created via new Module(...) can call load() [#29253]. The changes also advance Node.js runtime compatibility goals mentioned in #39.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing #29253: adding Module.prototype.load in CommonJS.ts, exposing it in JSCommonJSModule.cpp, and unifying prototypes in NodeModuleModule.cpp. Test file is a regression test validating the fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/builtins/CommonJS.ts`:
- Around line 428-430: Remove the unused fs import by deleting the `const fs =
require("node:fs");` line (or removing `fs` from the grouped require
declaration) in src/js/builtins/CommonJS.ts; keep the existing `Module` and
`path` requires intact so `Module` and `path` used by
`modulePrototypeLoad`/`modulePrototypeLoadJSExtension` remain unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2993df3c-bef5-490a-a314-9dd485f7a7ad

📥 Commits

Reviewing files that changed from the base of the PR and between d35774f and ff60c09.

📒 Files selected for processing (4)
  • src/bun.js/bindings/JSCommonJSModule.cpp
  • src/bun.js/modules/NodeModuleModule.cpp
  • src/js/builtins/CommonJS.ts
  • test/regression/issue/29253.test.ts

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

This PR touches critical CommonJS prototype unification code, and there is a real bug where the _compile CustomAccessor setter/getter fails when accessed directly on Module.prototype (the prototype object is not a JSCommonJSModule instance, so the dynamic cast fails silently). This can break tools like Istanbul/nyc/ts-node that monkey-patch Module.prototype._compile.

Extended reasoning...

Overview

This PR fixes a real compatibility gap: new Module(...).load() was missing, breaking packages like requizzle/jsdoc. The fix has three parts: (1) getModulePrototypeObject now returns the real JSCommonJSModulePrototype instead of a disposable plain object; (2) Module.prototype.load is added as a builtin; (3) the TypeScript builtin modulePrototypeLoad is implemented.

Security risks

No security-sensitive code paths touched. This is module loader plumbing with no auth, crypto, or permission changes.

Level of scrutiny

High. This modifies the CommonJS prototype unification — a fundamental invariant of the module system. The change affects every new Module(...) instance and every access to Module.prototype. The bug hunting system flagged a real issue: after unification, Module.prototype._compile is backed by a CustomAccessor whose getter/setter both do jsDynamicCast<JSCommonJSModule*>(thisValue) and bail on failure. When thisValue is the prototype object itself (not an instance), the cast fails silently — the getter returns undefined and the setter is a no-op in non-strict mode. This breaks the canonical Module.prototype._compile = wrapper pattern used by Istanbul, nyc, ts-node, and babel/register.

Other factors

Two nit-level issues (dead code and an unused require) were also flagged and are minor. The test coverage for the new load() functionality is comprehensive. However, the _compile accessor regression is a correctness issue that warrants a human look before merging.

robobun and others added 2 commits April 13, 2026 10:44
Address review feedback:

- Revert the Module.prototype unification in NodeModuleModule.cpp.
  Unifying exposed the pre-existing CustomAccessor on _compile to
  prototype-level reads, which silently bailed on the cast to
  JSCommonJSModule* and broke Module.prototype._compile monkey-patching.
  The new load() method lives on JSCommonJSModulePrototype (the real
  instance prototype), which is all requizzle/jsdoc need.

- Use Node's longest-registered-extension lookup in modulePrototypeLoad
  instead of path.extname, so Module._extensions['.test.js'] (and any
  other compound-suffix handler) wins over '.js'.

- Reset this.loaded in the catch branch when the extension handler
  throws, so a failed load() does not permanently trap the instance
  behind the 'Module already loaded' assert. module._compile sets
  hasEvaluated eagerly for cycle-breaking, so without this reset,
  retrying on the same module object was impossible.

- Throw an AssertionError (via node:assert) for the already-loaded
  guard to match Node's error shape / code.

- Remove the dead modulePrototypeLoadJSExtension helper — the .js
  handler is already registered in C++.

New regression tests: failed-load retry, compound-extension dispatch.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 71-82: In the test block where const [stdout, stderr, exitCode] =
await Promise.all([...]) is used, move the assertions that inspect stdout
(parsing JSON into result and the expects on result.loaded, result.filename,
result.exports.*) to occur before the expect(exitCode).toBe(0); keep the stderr
assertions (expect(stderr).not.toContain(...)) adjacent to stdout checks but
ensure exitCode assertion comes last. Apply the same reorder in the other
mentioned blocks (around lines referenced) so stdout parsing/assertions execute
before asserting exitCode, using the variables stdout, stderr, exitCode and
result to locate the code to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: df738f79-1cff-44b3-943b-91df9db8fdc8

📥 Commits

Reviewing files that changed from the base of the PR and between ff60c09 and 1bf6b8b.

📒 Files selected for processing (2)
  • src/js/builtins/CommonJS.ts
  • test/regression/issue/29253.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
test/regression/issue/29253.test.ts (1)

71-82: ⚠️ Potential issue | 🟡 Minor

Reorder subprocess assertions so stdout checks run before exitCode.

expect(exitCode).toBe(0) is still executed before stdout parsing/assertions in these blocks. Keep stdout/stderr assertions first and assert exitCode last for better failure diagnostics.

♻️ Suggested reorder pattern (apply similarly to each block)
-  expect(stderr).not.toContain("TypeError");
-  expect(stderr).not.toContain("Error");
-  expect(exitCode).toBe(0);
-
-  const result = JSON.parse(stdout.trim());
+  expect(stderr).not.toContain("TypeError");
+  expect(stderr).not.toContain("Error");
+  const result = JSON.parse(stdout.trim());
   expect(result.loaded).toBe(true);
   expect(result.filename).toMatch(/target\.js$/);
   expect(result.exports.answer).toBe(42);
   expect(result.exports.filename).toBe(result.filename);
+  expect(exitCode).toBe(0);

As per coding guidelines: “Expect stdout assertions before exit code assertions - expect(stdout).toBe(...) BEFORE expect(exitCode).toBe(0) for more useful error messages”.

Also applies to: 120-126, 163-169, 213-218, 257-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29253.test.ts` around lines 71 - 82, Reorder the
assertions in this test so stdout/stderr checks and parsing happen before
asserting exitCode: read and parse stdout/stderr (the Promise.all result
variables stdout, stderr), assert stderr does not contain errors and then parse
JSON into result and run expect(result.loaded/filename/exports...) first, and
only after all stdout/stderr-based expectations are done assert
expect(exitCode).toBe(0); apply the same reordering pattern to the other blocks
mentioned (lines with the same stdout, stderr, exitCode usage).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 71-82: Reorder the assertions in this test so stdout/stderr checks
and parsing happen before asserting exitCode: read and parse stdout/stderr (the
Promise.all result variables stdout, stderr), assert stderr does not contain
errors and then parse JSON into result and run
expect(result.loaded/filename/exports...) first, and only after all
stdout/stderr-based expectations are done assert expect(exitCode).toBe(0); apply
the same reordering pattern to the other blocks mentioned (lines with the same
stdout, stderr, exitCode usage).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 25ca0606-f064-43c9-b2bc-6c233266b305

📥 Commits

Reviewing files that changed from the base of the PR and between 1bf6b8b and d4d1c67.

📒 Files selected for processing (1)
  • test/regression/issue/29253.test.ts

Matches the coding guideline: stdout checks run first so a failing
expect() shows the actual subprocess output before the exit code.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all previously flagged bugs addressed in 1bf6b8b; remaining nits are test-style only.

Extended reasoning...

Overview

The PR adds Module.prototype.load(filename) to JSCommonJSModulePrototype — the C++ change is a one-liner putDirectBuiltinFunction call, and the TypeScript implementation mirrors Node's CJS loader semantics. Three files are touched: the C++ prototype, the CommonJS.ts builtins, and a new regression test suite.

Security risks

None. This is a compatibility shim that routes through the existing extension-handler dispatch (Module._extensions) and module._compile, both of which already exist and are trusted. No new privilege surfaces or external input paths are introduced.

Level of scrutiny

The C++ change is mechanical (one function registration). The TypeScript implementation is moderate complexity but well-bounded: it sets filename/paths, runs the extension handler, and resets loaded on failure. The logic closely mirrors Node's own Module.prototype.load and is covered by 5 targeted tests.

Other factors

All serious concerns from prior review cycles were resolved: the unused fs import was removed, the risky prototype unification was reverted (avoiding the _compile CustomAccessor regression), dead modulePrototypeLoadJSExtension code was removed, the loaded flag is now correctly reset on handler failure, the already-loaded guard throws an AssertionError via assert(), and compound extensions (.test.js) are handled via a longest-match loop. The two remaining inline comments are minor test-style nits (concurrent vs sequential test declarations and assertion ordering) that do not affect correctness.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 78-80: Remove the brittle broad stderr substring assertions (the
expect(stderr).not.toContain("TypeError") and
expect(stderr).not.toContain("Error") checks) from this test and the similar
occurrences mentioned (around the checks at lines 122-124, 165-167, 215-216,
259-261); rely on the deterministic stdout assertions and
expect(exitCode).toBe(0) for success instead, and only add explicit stderr
assertions if you are verifying a specific, expected error message for a
regression. Locate the offending assertions by searching for
expect(stderr).not.toContain(...) in issue/29253.test.ts and delete them or
replace them with a focused check that matches a known, specific stderr message
if needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1fbf7690-f07b-4f55-a164-bcb653ffbca2

📥 Commits

Reviewing files that changed from the base of the PR and between d4d1c67 and 217ed5d.

📒 Files selected for processing (1)
  • test/regression/issue/29253.test.ts

Each spawning test owns its own tempDir and child process with no
shared state, so they can safely run in parallel. Removed the broad
stderr substring checks ("Error", "TypeError", etc.) per the
existing guideline that only specific, expected stderr strings should
be asserted — deterministic stdout + exitCode already cover success.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 23-34: The test must assert that a new Module instance shares the
exact prototype object with Module.prototype, not just that it inherited a load
method; update the assertions around the instance m to replace or augment the
prototype checks so they verify Object.getPrototypeOf(m) === Module.prototype
(in addition to existing checks for typeof m.load and no own property),
referencing the Module constructor, the instance m, and Module.prototype to
ensure prototype identity is enforced.
- Around line 123-161: The test's paths assertion is too weak; instead of only
checking Array.isArray(m.paths), call
Module._nodeModulePaths(path.dirname(target)) and assert m.paths strictly equals
that result (or deep-equals the array) so the test verifies load() computes
paths from the correct directory; locate the driver.js code that references
m.paths and replace the Array.isArray check with a comparison to
Module._nodeModulePaths(path.dirname(target)) (or alternatively exercise a
require() that would only succeed if the paths are correct).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 89ae26af-86c9-484d-a44c-9202aac71a6d

📥 Commits

Reviewing files that changed from the base of the PR and between 217ed5d and f5c713d.

📒 Files selected for processing (1)
  • test/regression/issue/29253.test.ts

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bun.js/bindings/JSCommonJSModule.cpp:819-824 — After this PR, require('module').prototype.load is undefined on Bun while (new Module()).load is a function — a split that has no Node.js precedent. In Node.js both paths point to the same object so load is always present. Any code that inspects or monkey-patches require('module').prototype.load to intercept all module loads will silently fail on Bun.

    Extended reasoning...

    What the bug is and how it manifests

    The PR adds load exclusively to JSCommonJSModulePrototype::finishCreation in JSCommonJSModule.cpp (lines 819-824). That object is the real per-instance prototype backing instances created by new Module(). However, require('module').prototype is served by a completely separate code path: getModulePrototypeObject() in NodeModuleModule.cpp (lines 766-780), which constructs a fresh plain JSObject and attaches only require and _compile. No load is added there. As a result, typeof require('module').prototype.load === 'undefined' on Bun after this PR, while typeof (new Module()).load === 'function'.

    The specific code path

    NodeModuleModule.cpp:917 registers prototype as a PropertyCallback backed by getModulePrototypeObject. That function returns a new disposable object each call. JSCommonJSModule.cpp:819-824 adds load only to JSCommonJSModulePrototype, the C++ class used as the real prototype of instances. These two objects are distinct and neither inherits from the other.

    Why existing code does not prevent it

    The PR originally attempted prototype unification but that was reverted in commit 1bf6b8b because the _compile CustomAccessor performs a jsDynamicCast<JSCommonJSModule*> and silently returns undefined when called on the prototype object itself. The revert was correct for _compile, but it re-introduced the load gap: before this PR neither object had load (consistently absent); after this PR only the instance prototype has it (split with no Node.js precedent).

    Impact

    Code that checks typeof require('module').prototype.load === 'function' will see 'undefined' on Bun. More critically, monkey-patching require('module').prototype.load = myWrapper to intercept all module loads (an Istanbul/nyc style instrumentation pattern) will silently not work on Bun, because the patch is applied to the disposable object while actual instances inherit from JSCommonJSModulePrototype, which is unaffected.

    Step-by-step proof

    1. require('node:module').prototype.load triggers getModulePrototypeObject(), returning a disposable object with no load property.
    2. typeof require('node:module').prototype.load returns 'undefined'.
    3. Patching: require('node:module').prototype.load = fn writes to the disposable object.
    4. const m = new Module('/foo.js'); m.load looks up JSCommonJSModulePrototype, finds the original builtin — the patch on the disposable object is invisible.
    5. On Node.js: require('module').prototype === Object.getPrototypeOf(new Module()) — both see the same object with load as a function.

    How to fix it

    The simplest fix is to also add load to the disposable prototype object in getModulePrototypeObject(), similarly to how _compile is added at line 777. Since the disposable object is a plain data property (not a CustomAccessor), assigning load there as a direct property would make typeof require('module').prototype.load === 'function' true, closing the compatibility gap without requiring full prototype unification.

  • 🔴 src/js/builtins/CommonJS.ts:433-434 — modulePrototypeLoad sets this.filename but not this.path (m_dirname), so __dirname inside a loaded file is taken from the Module constructor id rather than from the load filename — wrong when dirname(constructor_id) ≠ dirname(filename). Add this.path = path.dirname(filename) alongside the existing this.filename = filename assignment to fix it.

    Extended reasoning...

    What the bug is and how it manifests

    modulePrototypeLoad (CommonJS.ts:433) sets this.filename = filename and this.paths = Module._nodeModulePaths(path.dirname(filename)), but does NOT update this.path, which maps to m_dirname via setterPath. The native .js extension handler (jsLoaderJS) does NOT call module._compile directly; instead it routes through builtinLoader → fetchCommonJSModuleNonBuiltin → evaluateWithPotentiallyOverriddenCompile → evaluate(). The evaluate() call at JSCommonJSModule.cpp line ~1381 passes this->m_dirname.get() as the __dirname argument to evaluateCommonJSModuleOnce. m_dirname is set once from the constructor id argument (jsFunctionNodeModuleModuleConstructor) and never updated by load(), so it reflects the directory of the constructor path, not the load path.

    The specific code path that triggers it

    I confirmed this by reading the source directly:

    1. modulePrototypeLoad calls handler.$call(extensions, this, filename) for the .js handler.
    2. jsLoaderJS → builtinLoader(globalObject, callFrame, BunLoaderTypeJS) (JSCommonJSExtensions.cpp:243).
    3. builtinLoader calls fetchCommonJSModuleNonBuiltin (ModuleLoader.cpp:267).
    4. fetchCommonJSModuleNonBuiltin at line 799: target->evaluateWithPotentiallyOverriddenCompile(globalObject, specifierWtfString, specifierValue, res->result.value).
    5. evaluateWithPotentiallyOverriddenCompile checks m_overriddenCompile; if not set (the default), calls this->evaluate(globalObject, key, source, false).
    6. evaluate() calls evaluateCommonJSModuleOnce(vm, globalObject, this, this->m_dirname.get(), this->m_filename.get()).

    m_filename was updated by this.filename = filename in modulePrototypeLoad. m_dirname was NOT updated; it still holds the value derived from the constructor id.

    Addressing the refutation

    The refutation claims 'jsLoaderJS reads the file content and calls module._compile(content, filename)', citing functionJSCommonJSModule_compile (which does derive dirname from the filename argument). This is incorrect. Reading JSCommonJSExtensions.cpp:243-297, builtinLoader does NOT call _compile. It calls fetchCommonJSModuleNonBuiltin (line 267-279). The _compile path (functionJSCommonJSModule_compile) is a separate entry point, only reached when JavaScript code calls module._compile(source, filename) directly. The builtinLoader + fetchCommonJSModuleNonBuiltin path bypasses _compile entirely in the default (no overridden _compile) case.

    Why existing tests mask it

    All tests in 29253.test.ts construct Module with the same path they pass to load():
    const target = path.resolve(__dirname, "target.js");
    const m = new Module(target, module);
    m.load(target);
    So dirname(constructor_id) === dirname(filename) in every test. The stale m_dirname happens to be correct, hiding the bug.

    Concrete proof of the bug

    1. const m = new Module('/foo/id.js', module); — m_dirname = '/foo'
    2. m.load('/bar/other.js'); — m_filename is updated to '/bar/other.js' via this.filename=filename, but m_dirname stays '/foo'
    3. builtinLoader → fetchCommonJSModuleNonBuiltin → evaluateWithPotentiallyOverriddenCompile → evaluate()
    4. evaluateCommonJSModuleOnce is called with dirname='/foo', filename='/bar/other.js'
    5. Inside other.js: __dirname === '/foo' (wrong — should be '/bar'), __filename === '/bar/other.js' (correct)
    6. Any require('./dep') call inside other.js resolves against /foo, not /bar — also wrong.

    How to fix it

    Add this.path = path.dirname(filename) in modulePrototypeLoad alongside the existing this.filename = filename:

    this.filename = filename;
    this.path = path.dirname(filename); // <-- add this line to update m_dirname
    this.paths = Module._nodeModulePaths(path.dirname(filename));

    Node.js does not have this problem because its Module.prototype._compile always derives dirname directly from the filename argument rather than reading it from the module object.

  • 🟡 src/js/builtins/CommonJS.ts:443-449 — The longest-match extension loop in modulePrototypeLoad does not skip leading-dot position (index 0), diverging from Node.js for dotfiles. For a file named .test.js, Bun checks Module._extensions['.test.js'] first (if user-registered), while Node.js's findLongestRegisteredExtension explicitly skips index 0 and only tries .js. The fix is to add a guard skipping startDot === 0 at the top of the while loop body.

    Extended reasoning...

    What the bug is and how it manifests

    The while loop in modulePrototypeLoad (CommonJS.ts, the extension-lookup block) starts scanning from startDot = basename.indexOf('.') with no guard for startDot === 0. For a dotfile like .test.js, basename.indexOf('.') returns 0, so the first loop iteration produces suffix = '.test.js' and checks extensions['.test.js']. Node.js's findLongestRegisteredExtension has an explicit if (index === 0) continue guard that skips position 0, meaning Node would never try .test.js for a dotfile — it would only see .js.

    The specific code path that triggers it

    For basename .test.js:

    • Bun: startDot = 0, first iteration suffix = '.test.js', checks extensions['.test.js'] (fires if user-registered), then falls through to .js.
    • Node.js: index = 0, skipped by continue, then index = 5, currentExtension = '.js' — only .js is ever tried.

    The PR's compound-extension test covers foo.test.js (dot at position 3, not 0), so it passes correctly on both Bun and Node. The dotfile case (.test.js, .gitignore, etc.) is not exercised by any test.

    Why existing code doesn't prevent it

    The loop condition while (startDot \!== -1 && startDot \!== basename.length - 1) correctly avoids scanning a trailing dot but makes no distinction between a leading dot at position 0 and a legitimate compound-extension dot at a later position.

    What the impact would be

    This is a narrow edge case: Module.prototype.load is never called on dotfiles in real codebases. However, if a user registers Module._extensions['.test.js'] and calls .load() on a file literally named .test.js, Bun dispatches to their handler while Node.js silently falls back to the .js handler. Conversely, for .gitignore (single dot at position 0), Bun tries .gitignore as an extension while Node skips it entirely.

    Step-by-step proof (dotfile .test.js)

    1. Module._extensions['.test.js'] = myHandler is registered.
    2. new Module('.test.js', null) is constructed.
    3. m.load('.test.js') is called; modulePrototypeLoad runs.
    4. basename = '.test.js', startDot = basename.indexOf('.') = 0.
    5. First iteration: suffix = '.test.js', handler = extensions['.test.js'] = myHandler — fires.
    6. On Node.js: index 0 is skipped by the continue guard; only .js is tried; myHandler is never called.

    How to fix it

    Add a leading-dot guard at the top of the while loop body:

    while (startDot \!== -1 && startDot \!== basename.length - 1) {
      if (startDot === 0) { startDot = basename.indexOf(".", 1); continue; } // skip leading dot like Node.js
      const suffix = basename.slice(startDot);
      handler = extensions[suffix];
      if (handler) break;
      startDot = basename.indexOf(".", startDot + 1);
    }

    This matches Node's findLongestRegisteredExtension semantics exactly.

robobun added 2 commits April 13, 2026 11:32
A stale git index from an earlier rebase dance caused f344c1f to
accidentally remove the fix. Putting the modulePrototypeLoad builtin
and the Module.prototype.load registration back.
Two small Node-compat fixes:

- modulePrototypeLoad now sets this.path = dirname(filename) alongside
  this.filename. The .js extension goes through evaluate(), which reads
  this.m_dirname for __dirname. Without this, loading a file from a
  path different from the constructor id produced stale __dirname.

- getModulePrototypeObject (the disposable object backing
  require('module').prototype) now exposes load as a builtin, so
  typeof require('module').prototype.load === 'function' matches Node.
  The instance prototype still has its own binding via JSCommonJSModule.
- modulePrototypeLoad's longest-match loop now skips startDot === 0,
  matching Node's findLongestRegisteredExtension and Bun's native Zig
  equivalent. Without this, a registered Module._extensions['.gitignore']
  handler would fire for a file literally named .gitignore.

- Test header comment still claimed the PR unifies the two module
  prototypes; it doesn't. Rewrote it to describe the actual shape:
  both prototypes now expose load independently, but they're still
  distinct objects (unification was reverted in 1bf6b8b to preserve
  _compile CustomAccessor semantics).
Previously only asserted that instances inherit load(). This adds
an explicit check that `typeof require('module').prototype.load === 'function'`
so a future revert of the getModulePrototypeObject registration is
caught by this test, not just by the instance-prototype path.
Without `$overriddenName`, the builtin's .name was inferred from
the source identifier ("modulePrototypeLoad"). Matches overridableRequire's
`$overriddenName = "require"` annotation.
The fields test used to pass the same path as both the Module
constructor id and the load argument. That meant the C++ ctor's
m_dirname init already set this.path correctly, so removing the
this.path = dirname assignment from modulePrototypeLoad would
leave all tests green while silently breaking __dirname when
the ids differ.

New fixture: constructor is given a stub id, load() is called
with a file in a different subdirectory, and the loaded file's
__dirname is asserted against dirname(target).
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all functional issues from prior review rounds have been addressed.

Extended reasoning...

Overview

Adds Module.prototype.load to fix a crash in packages like requizzle (a jsdoc dependency) that construct new Module(...) and call .load() on it. Touches JSCommonJSModule.cpp (prototype registration), NodeModuleModule.cpp (disposable prototype), CommonJS.ts (TypeScript builtin implementation), and a regression test file.

Security risks

None. This is a Node.js API compatibility fix adding a method to the module prototype. No auth, crypto, or permission-sensitive paths are involved.

Level of scrutiny

Moderate complexity but well-scoped. The PR went through multiple review rounds and addressed all raised issues: unused imports, dead code, prototype unification revert (to preserve _compile monkey-patching), try/catch for loaded state reset on failure, assert error type matching, compound extension lookup with leading-dot guard, concurrent tests, stdout-before-exitCode assertion ordering, and coverage for both the instance prototype and the disposable Module.prototype object.

Other factors

The remaining inline nit (missing .name assertion for the $overriddenName = "load" annotation) is a test coverage gap for an already-correct implementation detail — the annotation is confirmed present at CommonJS.ts:422. The inline comment serves as a prompt to tighten coverage in a follow-up.

robobun added 2 commits April 13, 2026 14:41
Without the `$overriddenName` annotation, JSC would derive the
name from the source identifier ("modulePrototypeLoad"). Pin
`.name === 'load'` on both the instance and Module.prototype so
a future refactor can't silently regress it.
One ASAN shard has been flaking at the default 5s — each test
spawns a subprocess bun that imports node:module + node:path and
runs a synthetic load(), which routinely takes 2.5-3s just for
startup on debug+ASAN. Under concurrent execution on the same
shard, an overloaded container can blow the 5s budget.
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.

npm package jsdoc throws error in Bun but not in Node

1 participant