Skip to content

Add missing exception checks on module-loading paths - #40069

Merged
Jarred-Sumner merged 8 commits into
mainfrom
claude/exception-checks-module-loading
Aug 22, 2026
Merged

Jarred-Sumner merged 8 commits into
mainfrom
claude/exception-checks-module-loading

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

What does this PR do?

Third batch from the missing-exception-check audit: module loading / resolution.

  • Object-loader plugins (ModuleLoader.cpp): when a Bun.plugin onLoad/build.module returns { loader: "object", exports } for a CommonJS require(), a throwing __esModule or default getter on exports was passed to reject() with the exception still pending — a second throwException, or rejectedInternalPromise built over it. It now goes through rejectOrResolve, which takes the exception first; that helper uses TRY_CLEAR_EXCEPTION so a VM termination is left pending rather than converted to a rejection.
  • Module.runMain(x): check after toWTFString before loadAndEvaluateModule.
  • import.meta.resolveSync / require resolution when virtual modules exist or Module._resolveFilename is overridden (ImportMetaObject.cpp): check after each toWTFString, the requireMap get, and constructEmptyObject.
  • $createCommonJSModule / JSCommonJSModule::create(key): check after toString, JSString::value, jsSubstring; napi_module_register checks its create().
  • module.filename / id / path setters: check after toString.
  • process.dlopen: stop draining pending napi registrations once one throws; check after the module.exports put.
  • bun:jsc heapStats(options): check after reading options.dump, its string conversion, and both JSONParse calls (the function had no scope).

How did you verify your code works?

New tests: object-loader __esModule getter (plugins.test.ts), Module.runMain argument stringify and module setters (node-module-module.test.js). These already produced the right error in release — the fixes are about not running further JSC operations with the exception pending — and pass under BUN_JSC_validateExceptionChecks=1 on a debug build along with plugins, import-meta, bun-jsc, heapStats-mimalloc.

- Object-loader plugins: a throwing `__esModule` / `default` getter on the
  exports object was handed to `reject()` with the exception still pending
  (a second throw / rejectedInternalPromise over it). Route through
  rejectOrResolve, which takes it first; use TRY_CLEAR_EXCEPTION there so a
  termination is not turned into a rejection.
- Module.runMain: check after toWTFString before loadAndEvaluateModule.
- import.meta.resolveSync / require resolution with virtual modules or an
  overridden Module._resolveFilename: check after each toWTFString, the
  requireMap get and constructEmptyObject.
- $createCommonJSModule and JSCommonJSModule::create(key): check after
  toString / JSString::value / jsSubstring; napi_module_register checks its
  create() call.
- module.filename / id / path setters: check after toString.
- process.dlopen: stop draining pending napi registrations once one throws;
  check after the `module.exports` put.
- bun:jsc heapStats: check after reading `options.dump`, its toWTFString,
  and the two JSONParse calls.
@coderabbitai

coderabbitai Bot commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 40 minutes

Limit details: You’ve used the included review currently available. Your 61 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 29 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 51e47c2d-5784-400b-b9c7-daab51d67d4b

📥 Commits

Reviewing files that changed from the base of the PR and between 4627502 and 6528de4.

📒 Files selected for processing (10)
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ImportMetaObject.cpp
  • src/jsc/bindings/JSCommonJSModule.cpp
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/napi.cpp
  • src/jsc/bindings/v8/node.cpp
  • src/jsc/modules/BunJSCModule.h
  • src/jsc/modules/NodeModuleModule.cpp
  • test/js/bun/plugin/plugins.test.ts
  • test/js/node/module/node-module-module.test.js

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

@robobun

robobun commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:13 PM PT - Aug 22nd, 2026

✅ @dylan-conway, your commit 6528de457db35c1cf2efee99d875b972ad6aedbd passed in Build #103674! 🎉


🧪   To try this PR locally:

bunx bun-pr 40069

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

bun-40069 --bun

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/napi.cpp
…w; node::AddEnvironmentCleanupHook path checks JSCommonJSModule::create too

@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.

LGTM — both earlier findings were addressed in e078a58, and this pass found nothing new.

What was reviewed:

  • Verified m_pendingNapiModules.clear() now present at both dlopen cleanup sites and the v8/node.cpp JSCommonJSModule::create caller now checks — the two items from the prior review.
  • Confirmed the removed !scope.exception() guard before the module.exports put in BunProcess.cpp is redundant: RETURN_IF_EXCEPTION at lines 754/772 already cover it.
  • rejectOrResolve({}) replacing reject(scope.exception()) in ModuleLoader.cpp correctly takes the pending exception via TRY_CLEAR_EXCEPTION (leaves termination pending).
  • The lone CI failure (test/internal/oxlint-plugin-bun.test.ts) is a lint test unrelated to these module-loading paths.
Extended reasoning...

Overview

Third batch of the missing-exception-check audit, focused on module loading/resolution. Adds DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION after throwing JSC calls (toWTFString, toString, getIfPropertyExists, JSString::value, jsSubstring, JSONParse, constructEmptyObject, map get) across ImportMetaObject.cpp, JSCommonJSModule.cpp, ModuleLoader.cpp, BunProcess.cpp, napi.cpp, v8/node.cpp, BunJSCModule.h, and NodeModuleModule.cpp. Also restructures process.dlopen's napi-registration drain loops to break on exception and clear leftover pending registrations. New tests cover the object-loader __esModule getter throw, Module.runMain argument stringify, and module setter conversion failures.

Security risks

None. All changes are defensive: adding early-return exception checks after operations that can throw. No new inputs, no new trust boundaries, no changes to validation logic.

Level of scrutiny

Moderate. This touches critical native module-loading code, but the changes follow the well-established JSC RETURN_IF_EXCEPTION discipline mechanically — the same pattern applied in the two prior batches of this audit. Every change is either (a) inserting an exception check after a call that can throw, or (b) routing an existing exception-pending path through the correct helper (rejectOrResolve / TRY_CLEAR_EXCEPTION). The one non-mechanical piece — break-on-exception in the dlopen napi drain loops — was flagged in my earlier review for leaking pending registrations, and the follow-up commit adds m_pendingNapiModules.clear() at both cleanup sites alongside the existing V8-vector clear.

Other factors

  • I reviewed the previous revision and left two nit-level findings; both were addressed in e078a58 and both threads are resolved.
  • The removed !scope.exception() guard before the final module.exports put in Process_functionDlopen is provably redundant: RETURN_IF_EXCEPTION at lines 754 and 772 guarantee no exception is pending when control reaches that block, and the new check after the put is the correct placement.
  • TRY_CLEAR_EXCEPTION is the established repo macro (used across streams bindings) for clearing a non-termination exception while leaving termination pending — matches the PR description's rationale.
  • Tests were added for the user-reachable paths and the author reports the touched suites pass under BUN_JSC_validateExceptionChecks=1.
  • The single CI failure is test/internal/oxlint-plugin-bun.test.ts, a lint-check test unrelated to any file in this diff.

Comment thread src/jsc/bindings/BunProcess.cpp
…ck runs (inside dlopen() and in the cached replay), not only after napi execution

@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.

LGTM — the three prior review threads are addressed and the current Process_functionDlopen refactor traces correctly.

Checked: the scope-exit guard in both dlopen blocks now clears m_pendingNapiModules and the exports slots on every exit; RETURN_IF_EXCEPTION sits right after the V8-style register callback ran (post-dlopen() and after each node_module_register() in the unrolled replay loop) and after each executePendingNapiModule; the removed !scope.exception() guard on the final put() is dead given the preceding checks. TRY_CLEAR_EXCEPTION in rejectOrResolve matches its existing uses in the streams code so a termination stays pending. The JSCommonJSModule::create(JSString*) overload's three callers (napi.cpp, v8/node.cpp, and the WTF::String overload) are all covered.

Extended reasoning...

Overview

Third batch of missing-exception-check fixes on module-loading paths, touching 8 C++ files plus 2 test files. Most changes are one-line RETURN_IF_EXCEPTION insertions after toWTFString/toString/getIfPropertyExists/JSONParse/constructEmptyObject. The non-trivial piece is the Process_functionDlopen refactor in BunProcess.cpp: the manual post-loop state cleanup was replaced with a WTF::makeScopeExit guard, exception checks moved into the drain-loop bodies and after the V8-register callback sites, and the cached-replay std::visit unrolled to a plain if/else so the check sits next to node_module_register().

Security risks

None. These are exception-scope hygiene fixes — the failure mode without them is running further JSC operations with an exception pending, which trips BUN_JSC_validateExceptionChecks=1 in debug and is caught by the next check in release. No untrusted-input parsing, no auth/crypto/permissions.

Level of scrutiny

Medium. The dlopen refactor touches native-module loading and required three review iterations: (1) the break-on-exception left re-entrantly-registered napi modules in the global vector; (2) the sibling v8/node.cpp call site of the newly-throwing JSCommonJSModule::create() was missed; (3) moving the check into the loop body dropped coverage for the V8-only path where the loop never executes. All three are now fixed and the threads are resolved. I traced the final state line-by-line: the scope-exit guard fires on every return, and there is a RETURN_IF_EXCEPTION on every path where an nm_register_func (V8 or napi) or executePendingNapiModule could have thrown.

Other factors

New tests cover the object-loader __esModule getter throw, Module.runMain argument stringify failure, and the filename/id/path setter Symbol coercion — the user-reachable paths. The PR description confirms these plus the existing plugins, import-meta, bun-jsc, and heapStats-mimalloc tests pass under BUN_JSC_validateExceptionChecks=1. The bug-hunting system found nothing on this revision. The remaining edits (ImportMetaObject.cpp, JSCommonJSModule.cpp setters/create, BunJSCModule.h heapStats, NodeModuleModule.cpp runMain, ModuleLoader.cpp rejectOrResolve) are mechanical and match the surrounding code's conventions.

@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've reviewed this across three rounds and the current state looks correct — all three earlier findings (stale m_pendingNapiModules on error, the missed v8/node.cpp sibling, and the V8-only registration path) are addressed by the scope-exit guard + the checks at BunProcess.cpp:611/624/658/676. Because the process.dlopen refactor reshapes error-path control flow in a critical native-loading path and took a few iterations to converge, a human sign-off would still be worthwhile.

What was reviewed:

  • Process_functionDlopen: verified the makeScopeExit guard fires on every early return in both self-register and cached-replay blocks, and that m_pendingNapiModuleAndExports[0] is read into a local before the guard clears it.
  • Confirmed the removed !scope.exception() guard at the module.exports put is dead — lines 749/767 already return on exception before reaching it.
  • Traced JSCommonJSModule::create's new nullptr return through its callers; both napi.cpp and v8/node.cpp now check.
  • rejectOrResolve's TRY_CLEAR_EXCEPTION leaves a termination pending rather than converting it to a rejection.
Extended reasoning...

Overview

Third batch of the missing-exception-check audit, focused on module loading. Eight native files (BunProcess.cpp, ImportMetaObject.cpp, JSCommonJSModule.cpp, ModuleLoader.cpp, napi.cpp, v8/node.cpp, BunJSCModule.h, NodeModuleModule.cpp) plus two test files. Most hunks are the mechanical pattern — declare a throw scope, insert RETURN_IF_EXCEPTION after toWTFString/toString/getIfPropertyExists/JSONParse/constructEmptyObject. The non-mechanical piece is the Process_functionDlopen refactor: manual post-loop cleanup replaced with a WTF::makeScopeExit guard, per-iteration RETURN_IF_EXCEPTION in both drain loops, and the cached-replay std::visit flattened into a plain loop so the check sits next to node_module_register().

Security risks

None identified. This is exception-scope hygiene — release-build behavior is essentially unchanged (JSC checks after native calls anyway); the goal is correctness under BUN_JSC_validateExceptionChecks=1 and not running further JSC operations with an exception pending. No new user-controlled input parsing, no permission/auth surface.

Level of scrutiny

High for BunProcess.cpp — process.dlopen is native module loading, and the scope-exit refactor changes error-path ordering (state cleanup now happens via destructor rather than inline). This hunk went through three review rounds with me, each finding a real (nit-level) gap that was fixed: (1) stale m_pendingNapiModules on early-exit, (2) the v8/node.cpp twin of JSCommonJSModule::create not updated, (3) the V8-only registration path where the drain loop body never runs. All three are now addressed and I've verified the current diff against each. Low-to-medium for everything else — those are the standard RETURN_IF_EXCEPTION-after-throwing-call insertions the previous batches established.

Other factors

New tests cover the object-loader __esModule getter, Module.runMain argument stringify, and the module setter conversions; PR description confirms the affected suites pass under BUN_JSC_validateExceptionChecks=1. The one thing tipping this to defer rather than approve is that the dlopen refactor took multiple iterations to get right — a human familiar with the napi/V8 registration flow should confirm the scope-exit ordering and the std::visit → plain-loop rewrite before merge.

@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.

LGTM — all three prior findings are addressed and the current diff checks out.

What was reviewed:

  • Re-traced both Process_functionDlopen self-registration blocks: the makeScopeExit guard now clears m_pendingNapiModules alongside the V8 vector, and RETURN_IF_EXCEPTION is placed right after dlopen() and after each replayed node_module_register(), covering the V8-only path where the drain loop body doesn't execute.
  • Confirmed the resultValue read at line 631/680 happens before the scope-exit guard clears m_pendingNapiModuleAndExports[0], so the ABI-mismatch error-instance path still throws correctly.
  • Verified v8/node.cpp now checks after JSCommonJSModule::create(), matching the napi.cpp twin.
  • Checked the removed !scope.exception() guard at BunProcess.cpp:777 is redundant given the preceding RETURN_IF_EXCEPTION at 749/767.
Extended reasoning...

Overview

Third batch of a missing-exception-check audit, focused on module loading and resolution. Adds RETURN_IF_EXCEPTION after fallible JSC calls (toString, toWTFString, value(), jsSubstring, getIfPropertyExists, constructEmptyObject, JSONParse, put) across ImportMetaObject.cpp, JSCommonJSModule.cpp, ModuleLoader.cpp, napi.cpp, v8/node.cpp, BunJSCModule.h, and NodeModuleModule.cpp. The one non-mechanical change is the Process_functionDlopen refactor in BunProcess.cpp: the two self-registration cleanup sequences are now WTF::makeScopeExit guards, exception checks moved to sit directly after each point where a module's register callback runs, and the cached-replay std::visit became a plain loop so the check can sit next to node_module_register(). Two new tests cover the object-loader throwing-__esModule path and Module.runMain/setter string-conversion failures.

Security risks

None. These are exception-hygiene fixes — they don't introduce new inputs, change validation, or touch auth/crypto/permissions.

Level of scrutiny

High, and it received it: this is my fourth pass. Three prior rounds each surfaced a specific issue in the BunProcess.cpp refactor (stale m_pendingNapiModules on exception, the missed v8/node.cpp sibling, and the V8-only-registration path losing coverage when the check moved into the drain loop). All three were fixed in follow-up commits (e078a58, 19d0730), and I've re-verified each fix against the current file contents. The remaining seven files are mechanical one-to-three-line RETURN_IF_EXCEPTION insertions following the same pattern as prior batches.

Other factors

  • The PR description states validation under BUN_JSC_validateExceptionChecks=1 for the affected test suites, which is the correct verification for this class of change per REVIEW.md.
  • The ModuleLoader.cpp change from reject(scope.exception()) to rejectOrResolve({}) is an improvement: the pending exception is taken via TRY_CLEAR_EXCEPTION (leaves a termination pending) rather than being passed to a second throwException while still set.
  • The dropped makeAtomString in jsFunctionRunMain is intentional per the commit message — loadAndEvaluateModule atomizes internally.
  • The multi-agent bug hunting system found nothing new on this revision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants