node:module: report a rejecting runMain override result once - #38160
node:module: report a rejecting runMain override result once#38160robobun wants to merge 4 commits into
Conversation
The promise adopted from a Module.runMain override's return value is stored as the entry point promise, which the run command reports when it is rejected. Unlike the module loader's promises it was not marked as handled, so a rejection that happened while waiting for it was also reported by the unhandled rejection tracker, printing the error twice and firing both unhandledRejection and uncaughtException listeners. Mark it handled when it is adopted.
|
Warning Review limit reached
Next review available in: 26 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 2:41 PM PT - Aug 13th, 2026
✅ @robobun, your commit 856f596e556dc39f9dd1f23ccf5ec56fbbcc0dee passed in 🧪 To try this PR locally: bunx bun-pr 38160That installs a local version of the PR into your bun-38160 --bun |
|
Status: reproduced and fixed; self-review in progress.
|
There was a problem hiding this comment.
LGTM — one-line fix marking the adopted runMain override promise as handled so the entry-point rejection is reported once.
Reviewed: the reload_entry_point path where the override's return value is wrapped via resolvedPromise and stored as pending_internal_promise; confirmed JSInternalPromise aliases JSPromise so JSPromise::opaque_mut(...).set_handled() is type-correct and routes to markAsHandled(). Checked the null-return case from resolvedPromise — pre-existing (stored and deref'd null before this change), tracked separately by #38113, and opaque_mut's null panic is not a regression over that. Tests cover already-rejected / late-rejecting / thenable × stderr-count / listener-delivery, use the harness helpers, and drain pipes concurrently.
Extended reasoning...
Overview
This PR adds a single line (plus comment) to reload_entry_point in src/jsc/VirtualMachine.rs: after adopting a Module.runMain override's return value via JSC__JSInternalPromise__resolvedPromise, it marks the resulting promise as handled before storing it as pending_internal_promise. Six new subprocess test cases are added to the existing test/js/node/module/node-module-module.test.js.
Security risks
None. This touches only how a rejected entry-point promise is bookkept for the unhandled-rejection tracker; no untrusted input parsing, no auth/crypto, no filesystem or network semantics change.
Level of scrutiny
Low-to-medium. The change is one flag set on a promise the runtime already owns and reports via Run::start. I verified the mechanics: JSInternalPromise is pub use JSPromise as JSInternalPromise (src/jsc/lib.rs:206), so crate::JSPromise::opaque_mut(resolved) accepts the *mut JSInternalPromise and set_handled() calls JSC__JSPromise__setHandled → promise->markAsHandled(). The rationale — module-loader promises stored in the same field are already created marked-handled by JSC, and Run::start is the single reporter — is sound and fixes the bug at the layer that owns the invariant (the site that adopts the promise), not at the symptom.
Other factors
opaque_mutpanics on null.JSPromise::resolvedPromisecan in principle return null if a thenable'sthengetter throws, but the pre-fix code already stored and later deref'd that null; the PR description explicitly notes #38113 covers the exception check there and the changes compose. Not a regression introduced here.- Tests follow harness conventions exactly:
test.eachmatrix,tempDir/bunEnv/bunExe,Promise.allover stdout/stderr/exited, combined-object assertions, no sleeps, hermetic. Placed in the existing module test file insidedescribe.concurrent, so they run in parallel with independent temp dirs. - No CODEOWNERS constraints on the touched paths. No prior human review comments to address.
There was a problem hiding this comment.
LGTM — one-line fix aligning the adopted runMain override promise with the invariant every other pending_internal_promise store already has.
What was reviewed:
- Confirmed
JSInternalPromisealiasesJSPromise(src/jsc/lib.rs:206), soJSPromise::opaque_mut(resolved).set_handled()type-checks and callsmarkAsHandled()on the C++ side. - Both consumers of this field —
Run::start(run_command.rs:1434) and the worker'sobserve_entry(web_worker.rs:869) — already report the rejection viauncaught_exceptionand explicitly rely on the promise being handled; this was the one store that was not. opaque_mutpanics on null, but the pre-existing code already stored/returnedresolvedunchecked, so no new failure mode (and #38113 covers the exception-check side).- Tests cover already-rejected / late-rejecting / thenable × stderr-count and listener delivery, plus the worker
preloadpath; they follow harness conventions (tempDir, bunEnv, drained pipes, combined-object assertions).
Extended reasoning...
Overview
Two-line change in src/jsc/VirtualMachine.rs (reload_entry_point): after adopting a patched Module.runMain override's return value via JSC__JSInternalPromise__resolvedPromise, mark the resulting promise as handled before storing it in pending_internal_promise. Seven new subprocess tests in test/js/node/module/node-module-module.test.js.
Security risks
None. This adjusts which of two internal reporting paths fires for an entry-point rejection; no untrusted-input parsing, no auth/crypto/permissions surface.
Level of scrutiny
Medium. It touches VM entry-point promise handling, but the change is minimal and mechanical: it sets the same isHandled flag on the adopted promise that the module loader already sets on every other promise stored in this field. I verified that both consumers of pending_internal_promise (Run::start and the worker's observe_entry) do report the rejection themselves and are documented as relying on the handled flag (web_worker.rs:867 comment: "The loader marks this promise handled, so nothing else would report it"). The fix is at the layer that owns the invariant — the one store site that broke it.
Other factors
JSInternalPromiseis a type alias forJSPromise(src/jsc/lib.rs:206), socrate::JSPromise::opaque_mut(*mut JSInternalPromise)compiles and dispatches toJSC__JSPromise__setHandled→promise->markAsHandled().opaque_mutpanics on null; the pre-fix code already stored and returnedresolvedwithout a null check, so this introduces no new failure mode. The exception-check gap afterresolvedPromiseis explicitly deferred to #38113.- The fulfilled case is unaffected (handled flag only matters for rejection tracking). The passthrough-override case (line 2711) returns the loader's own promise, which is already handled.
- Tests follow repo conventions:
tempDir,bunEnv,Promise.allon stdout/stderr/exited, combined-objecttoEqualassertions. The variant matrix (already-rejected, late-rejecting, thenable; with and without listeners; main thread and worker preload) is covered, and the PR description confirms five of the seven fail on the unfixed binary. - The comment-cop bot feedback (comment length) was addressed in 856f596 — the comment is now one line naming the invariant.
Problem
--requirepreload that replacesModule.runMainwith a function returning a promise (or thenable) that is still pending when it returns and rejects later gets the rejection reported twice: theerror: ...block is printed twice before exiting 1, and with listeners installed bothunhandledRejectionanduncaughtExceptionfire for the same error.await, are reported once.reload_entry_point(src/jsc/VirtualMachine.rs:2714) adopts the override's return value withJSPromise::resolvedPromiseand stores it aspending_internal_promise, whichRun::start(src/runtime/cli/run_command.rs:1434) reports once it is rejected. Every other promise stored in that field comes from the module loader, which creates them marked as handled. The adopted promise was not, so when it rejected during the event loop spin inload_entry_point, the rejection tracker reported it at the end of that tick, and thenRun::startreported it again.preloadoption overridesrunMain; there the worker's own entry check (src/jsc/web_worker.rs,observe_entry, which also relies on the promise being handled) is the second reporter, so both listeners fire inside the worker as well.bun --require ./preload.cjs ./main.cjsprintserror: boomtwice. Same with() => ({ then(_, reject) { reject(new Error("boom")); } }).Fix
Run::startstays the single reporter.Run::startdoes to the promise after reporting it anyway. The result is the already-rejected case and the late-rejecting case now behave identically: printed once, delivered once touncaughtExceptionlisteners with originunhandledRejection, and a passthrough override that calls the originalrunMainis unaffected (it stores the loader's promise, which was already handled).unhandledRejectionlistener no longer also fires for a late-rejecting override result; it did not fire for an already-rejected one before either.resolvedPromise) and node:module: throw instead of crashing when a preload sets runMain to a non-callable, and report a throwing override #38119 (non-callable / throwing override); the three touch neighbouring lines and compose, whichever lands first.test/js/node/module/node-module-module.test.js: seven new cases (already rejected promise, promise that rejects later, rejecting thenable, each checked for the stderr count and for listener delivery on the main thread, plus the late-rejecting override installed by a workerpreload). Five of them fail on the unfixed binary (errorsPrinted: 2, both listeners firing) and all pass with the fix; the two already-rejected cases pass both ways and pin the behaviour the other cases are aligned with.test/js/node/test/parallel/test-module-run-main-monkey-patch.js,test/cli/run/preload-test.test.js,test/js/bun/resolve/bun-main-entry-point.test.ts.Background
Module.runMainoverride: if a preload assignsrequire("module").runMain, bun calls the override instead of evaluating the entry point itself. If the override calls the originalrunMain, the module loader's promise is stored as the entry point promise; otherwise the override's return value is adopted withPromise.resolvesemantics (a native promise is used as is, a thenable is wrapped) and stored instead.pending_internal_promise: the VM field holding the entry point promise.load_entry_pointspins the event loop until it settles, thenRun::startprints a rejection through the uncaught exception path (uncaughtExceptionlisteners, originunhandledRejection) and exits 1.unhandledRejectionlisteners, otherwise print) at the end of each event loop tick. A promise whose handled flag is set is skipped by both the notification and the queue. JSC's own module loader sets that flag on the promises it hands to embedders because the embedder, not the tracker, reports them.Probes on the release binary (before) and the debug build (after)