Propagate errors thrown by a runtime plugin's onResolve callback - #33408
Propagate errors thrown by a runtime plugin's onResolve callback#33408robobun wants to merge 1 commit into
Conversation
WalkthroughChanges update native promise handling in BunPlugin's onResolve callback so pending and rejected promises are marked handled and thrown as exceptions rather than converted to fulfilled state, refactor ZigGlobalObject's moduleLoaderResolve exception flow with a zero-initialized result struct and throw scope, and add/update tests validating these failure paths. ChangesonResolve rejection and pending promise handling
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
No issues found — the fix mirrors the established pattern in moduleLoaderImportModule and the markAsHandled() + throwException() handling matches ModuleLoader.cpp — but since this restructures exception-scope handling in moduleLoaderResolve (the hook every static import and entry point goes through) and fixes an uninitialized-stack read, it's worth a human pass.
Extended reasoning...
Overview
Two native changes plus tests:
BunPlugin.cpp(OnResolve::run): For a rejected promise, replacespromise->setFlags(static_cast<uint16_t>(Status::Fulfilled))+ return-result-as-if-it-were-a-resolve-object withmarkAsHandled()+throwException(globalObject, scope, promise->result()). For a pending promise, addsmarkAsHandled()before throwing the existing "doesn't support pending promises yet" TypeError so a later rejection doesn't leak to the unhandled queue.ZigGlobalObject.cpp(moduleLoaderResolve): Zero-initializesErrorableString reswithmemset, movesDECLARE_THROW_SCOPEabove theZig__GlobalObject__resolvecall, and guards the read ofres.result.errwith!scope.exception()beforeRETURN_IF_EXCEPTION. This is a line-for-line copy of the shape already in the siblingmoduleLoaderImportModule(lines ~3505–3538), which was previously the only reasonawait import()didn't crash on this path.- Tests: Six new subprocess tests in
plugins.test.tscovering async/sync throw, non-Error throw,import()vsrequire(), entry-point resolution (the segfault repro), and a late-settling pending promise. Each positively assertsunhandled: []from aprocess.on('exit')reporter. Also tightens22199.test.tswhich previously passed for the wrong reason (unhandled rejection produced the same exit code + stderr).
Security risks
None identified. This is error-propagation plumbing; no auth, crypto, path handling, or untrusted-input parsing is touched. The change removes an uninitialized-stack read rather than adding attack surface.
Level of scrutiny
High. moduleLoaderResolve is the JSC hook for static imports and the entry point — every module resolution that isn't a dynamic import() flows through it. The change restructures its control flow and moves the ThrowScope declaration, which is exactly the class of change (JSC exception-scope discipline in a hot path) where subtle mistakes manifest as hard-to-reproduce crashes. The old code demonstrably read uninitialized memory, so the fix is clearly needed, but the reshape deserves human eyes.
Other factors
- The new
moduleLoaderResolveshape is verifiably identical to the existingmoduleLoaderImportModulepattern in the same file, which reduces risk considerably. markAsHandled()is the established idiom acrossModuleLoader.cpp,bindings.cpp, andNodeVMModule.cppfor consuming a rejection that's being rethrown.- Test coverage is unusually thorough (variant matrix, positive assertion of no unhandled rejections, entry-point crash repro) and the PR description includes before/after runs against
USE_SYSTEM_BUN=1. - No prior human reviews or outstanding comments on the PR.
|
The one red lane is Why it can't be this diff. Why it's a known flake. The test shows up in 22 of the last 40 Buildkite builds. 21 of those are Buildkite's own Reproduced against the parent commit. Reverting Everything else on the build is green: 281 jobs passed, including I have not pushed a retrigger, since that test fails in more than half of recent builds anyway and a re-run would cost a full CI cycle. Happy to push one if you'd prefer a clean build before merging. |
There was a problem hiding this comment.
I didn't find any issues — the fix looks correct and matches the sibling moduleLoaderImportModule shape — but since this reworks exception-scope placement and control flow in moduleLoaderResolve (the entry-point/static-import resolve hook), it's worth a human look before merging.
Extended reasoning...
Overview
Two native changes plus tests:
src/jsc/bindings/BunPlugin.cpp—OnResolve::run's rejected-promise arm now callsmarkAsHandled()andthrowException(..., promise->result())instead of the buggysetFlags(Fulfilled)+ return-rejection-as-result. The pending arm also marks the promise handled before throwing the existing TypeError.src/jsc/bindings/ZigGlobalObject.cpp—moduleLoaderResolvenow zero-initsres, declares the throw scope beforeZig__GlobalObject__resolve, and only readsres.result.errwhen no exception is already pending;RETURN_IF_EXCEPTIONhandles both the plugin-threw and resolve-set-err paths. The success path (identifier construction,res.result.value.deref(),queryString.deref()) is byte-for-byte the same as before, just de-nested. I verified the new shape matchesmoduleLoaderImportModuleat ZigGlobalObject.cpp:3505–3506, which already doesmemset(&resolved, 0, ...)under a scope declared at the top of the function.- Six new subprocess tests in
plugins.test.tscovering async/sync throw ×import()/require()/entry-point, plus a late-settling pending promise; and a tightened assertion in22199.test.ts(addsstdout === "") so the test can no longer pass via the leaked unhandled rejection.
Security risks
None identified. This is error-propagation plumbing for user-registered runtime plugins; no auth, crypto, permissions, or untrusted-input parsing is touched. The change strictly reduces attack surface by removing an uninitialized-stack read.
Level of scrutiny
High. moduleLoaderResolve is the JSC resolve hook for the entry point and every static import — it runs on essentially every program launch. Moving DECLARE_THROW_SCOPE above Zig__GlobalObject__resolve and restructuring the branches around RETURN_IF_EXCEPTION is the kind of change where a maintainer familiar with the Rust-side resolve_hook contract (which paths write res vs. leave a pending exception) should confirm the invariant that res.success && scope.exception() is unreachable, since that combination would now early-return without deref'ing res.result.value. The PR description argues it isn't reachable and the sibling hook already relies on the same assumption, but it warrants human sign-off.
Other factors
- The bug hunter found nothing; CodeRabbit had no actionable comments.
- CI is green except a known-flaky Windows napi GC-timing test that the PR body convincingly rules out as related.
- Test coverage is thorough and follows repo conventions (
tempDir,bunEnv, concurrent subprocess drain, positive assertion ofunhandled: []rather than absence-checking). - No prior review from me on this PR.
On the
|
| line | writes to *res |
success |
exception pending |
|---|---|---|---|
| 4972 | err(NameTooLong, js_err) |
false | no |
| 4999 | whatever on_resolve_jsc returned |
either | no (see below) |
| 5020 | ok(hardcoded alias path) |
true | no |
| 5117 | err(err, js_err) |
false | no |
| 5138 | ok(clone_utf8(result_path)) |
true | no |
| 4968, 5004, 5113 | nothing | false (memset) | yes |
The only path that could smuggle a pending exception alongside success == true is 4999, and it cannot. Inside plugin_runner_on_resolve_jsc every call that can throw propagates with ?, which becomes Err(_) => return false at line 5004. The two places that do produce an ErrorableString from a thrown value (VirtualMachine.rs:6704 and :6713) call global.try_take_exception(), which removes the exception from the global before stuffing it into the err payload, and those yield success == false anyway. The single ok(...) return (:6719) is reached only after every fallible step has already succeeded.
So:
success == trueimplies nothing is pending, and control reaches the deref.success == falseimplies either an exception is pending andreswas never written (harmless:memsetleftres.resultzeroed, nothing to deref), orres.result.errholds a realJSValueand we throw it.
For what it's worth, the old code was relying on the weaker half of this same contract, just incorrectly: it assumed success == false always meant res.result.err was populated, which is exactly the assumption the three return false paths violate.
CI
Pushed one ci:retrigger (bdb583c) to re-roll. The napi GC test recovered, and a different, also-unrelated set of lanes went red instead:
darwin 26 aarch64 - test-bun×2:buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Pure agent infrastructure, no test ran.bun-install > should handle --cwdon2019 x64-baseline. Package manager test; the same file was Buildkite'sflakyannotation on the previous build.spawn.test.tstimeout on2019 x64-baseline, recovered on retry.
A different failure set on each run, on lanes this diff does not touch, is flake rather than regression. Every lane that actually runs the new tests is green, including debian 13 x64-asan. Not going to keep re-rolling; the diff is ready for a human look.
|
One more datapoint on the
No delta beyond noise, and if anything slightly lower. (Both "fail" locally for an unrelated reason: |
bdb583c to
0b514f8
Compare
|
Rebased onto Worth calling out since #33409 ( It also happens to exercise the refcount side of my change for free. #33409's "onResolve can redirect a specifier to a real file in the file namespace" test hands a Both test suites green together on the rebased tree: Fail-before still holds after the rebase: 5 of the 6 new tests (all but the synchronous-throw control, which already worked) and the tightened 22199 assertion are red on the released binary. |
There was a problem hiding this comment.
I didn't find any issues, but this restructures the error/success control flow in moduleLoaderResolve — the resolve hook every static import and entry point goes through — and its correctness leans on an FFI invariant established by inspection of the Rust producer, so it's worth a human look.
Extended reasoning...
Overview
Two native changes plus tests:
src/jsc/bindings/BunPlugin.cpp: InOnResolve::run, a rejected promise from anonResolvecallback is nowmarkAsHandled()and its rejection reason thrown as an exception (previously the code clobbered the promise's flags word viasetFlagsand returned the rejection reason as if it were a{ path }result). A pending promise is alsomarkAsHandled()before the existing TypeError is thrown, so a late rejection doesn't reach the unhandled queue.src/jsc/bindings/ZigGlobalObject.cpp:moduleLoaderResolvenow zero-initializesErrorableString resviamemset, declares the throw scope before the FFI call toZig__GlobalObject__resolve, and only readsres.result.errwhen!res.success && !scope.exception(). The success path was flattened fromif (res.success) { … } else { … }to early-return-on-exception followed by an unconditional deref ofres.result.value.- Six new subprocess tests in
test/js/bun/plugin/plugins.test.tscovering async/sync throws, non-Error throws,require(), entry-point resolution, and a late-settling pending promise;test/regression/issue/22199.test.tstightened to assert stdout is empty.
Security risks
None identified. The change narrows behavior on the error path (propagates the plugin's error instead of leaking it as an unhandled rejection or reading uninitialized stack). No new user-controlled input reaches native code that wasn't already reaching it.
Level of scrutiny
High. moduleLoaderResolve is the JSC GlobalObjectMethodTable hook for static import and entry-point resolution — every module load goes through it. The refactor changes throw-scope placement across an FFI boundary and makes the success-path deref of res.result.value unconditional, relying on the invariant that res.success == true never coincides with a pending exception. The author established that invariant by enumerating the return paths of resolve_hook in src/runtime/jsc_hooks.rs (see the table in the PR thread) and empirically via the require-cache leak fixture, but it is still an FFI contract argued by inspection rather than enforced by a runtime check. JSC exception-scope semantics and cross-language ownership are exactly the areas Bun reviewers scrutinize most closely.
Other factors
- The bug hunting system found no issues.
- The new shape mirrors the sibling
moduleLoaderImportModule(samememset+scope.exception()guard pattern), which is reassuring. - Test coverage is thorough and each test is designed to fail for the right reason (subprocess prints
unhandled: []fromprocess.on('exit'), so absence of an unhandled rejection is a positive assertion). - CI is green modulo known flakes on unrelated lanes (napi GC timing, darwin artifact-download timeouts), which the author analyzed in detail.
- The author's own last comment says the diff is ready for a human look, which matches my read.
|
The red lanes on build 68814 are not from this PR, and the Windows one is not flake either. Writing it down so nobody has to re-derive it.
This reproduces on plain
The emitted value is the correct one (RFC 7231 IMF-fixdate, and
For the record, this branch has now had three CI runs with three disjoint failure sets (napi GC timing, |
An async onResolve whose promise was already rejected had its rejection swallowed: OnResolve::run flipped the promise's status to Fulfilled and returned the rejection reason as the result object. With no `path` property on it, resolution fell through to the default resolver, so the importer saw a generic "Cannot find package" ResolveMessage. Worse, setFlags() replaces the entire flags word rather than setting a bit, so isHandled was cleared and the plugin's error went on to fire as a process-level unhandledRejection. Mark the promise as handled and throw its rejection reason instead, the same way ModuleLoader.cpp handles a rejected virtual-module promise. A still-pending promise is marked handled too, since nothing will ever observe it once the pending-promise TypeError is thrown. That exposed a crash underneath. moduleLoaderResolve left ErrorableString uninitialized and, on failure, threw res.result.err unconditionally. The resolve hook returns false without writing res whenever a JS exception is already pending, which is exactly what a throwing onResolve does, so the wild value read off the stack segfaulted. Zero-initialize res and check for a pending exception first, as moduleLoaderImportModule already does.
0b514f8 to
2dc444e
Compare
There was a problem hiding this comment.
No issues found, but this restructures exception-scope placement and control flow in moduleLoaderResolve — the hot path for every static import and entry-point resolution — so it's worth a maintainer's eyes on the FFI contract with resolve_hook.
Extended reasoning...
Overview
Two native changes plus tests:
BunPlugin.cpp: inOnResolve::run, a rejected promise from a plugin callback is nowmarkAsHandled()+throwException(...)instead of the previoussetFlags(Fulfilled)+ return-result-as-value (which both leaked an unhandled rejection and handed the error object back to the resolver as if it were a{path}result). A still-pending promise is also marked handled before the existing TypeError is thrown.ZigGlobalObject.cpp:moduleLoaderResolvenowmemsets itsErrorableString, declares the throw scope before theZig__GlobalObject__resolveFFI call, and only readsres.result.errwhen!res.success && !scope.exception(), thenRETURN_IF_EXCEPTION. The success path (identifier construction,res.result.value.deref(),queryString.deref()) is preserved verbatim, just de-nested.- Six new subprocess tests covering async/sync throws, non-Error throws,
require(), entry-point resolution, and a late-settling pending promise; plus a tightened assertion in22199.test.tsthat was previously satisfied by the leaked unhandled rejection.
I verified the new shape matches the sibling moduleLoaderImportModule (which already does memset(&resolved, 0, ...) with a scope declared before the resolve call).
Security risks
None identified. The change strictly narrows behavior: a previously-uninitialized stack read is eliminated, and plugin errors that were being silently misrouted now propagate as thrown exceptions. No new user-controlled data reaches a trust boundary.
Level of scrutiny
High. moduleLoaderResolve is on the hot path for every static import and the entry point itself; the correctness of the refactored flow depends on a cross-FFI invariant with resolve_hook in src/runtime/jsc_hooks.rs (that res.success == true never coexists with a pending exception, and that the three return false-without-writing-res paths always leave one pending). The PR thread lays out that argument carefully and backs it with an RSS leak comparison and ASAN runs, but this is exactly the kind of contract a maintainer who owns that boundary should confirm.
Other factors
- No CODEOWNERS coverage on the touched files.
- Bug-hunting pass found nothing.
- CI failures across three runs are documented in-thread as unrelated (napi GC flake, artifact-download timeouts, and a pre-existing
cookie-map.test.tsbreakage on main fixed by #33425). - Test coverage is thorough and follows the harness conventions (subprocess isolation,
unhandled: []as a positive assertion, concurrent pipe drains).
|
Build 68982 (the rebased sha) is red, but no test ran. It is agent-pool starvation, not a failure. All 17 expired jobs are Since this branch has now had four builds and none of them was a clean run, here is the part that actually matters. Checking every annotation across all four:
My one re-roll is spent (the |
|
FYI #38273 needs the |
Repro
The plugin's actual error is lost, the importer gets a generic "cannot resolve" message, and the rejection escapes to the unhandled-rejection queue, which exits the process under the default policy.
Bun.build()with the same plugin reports the plugin's error correctly.A second repro, which segfaults on current
mainwith no async involved:Cause
BunPlugin::OnResolve::runhandled a rejected promise withpromise->setFlags(static_cast<uint16_t>(JSPromise::Status::Fulfilled))and thenreturned
promise->result()as if it were the{ path }object the callback issupposed to return.
JSPromise::setFlagsreplaces the whole 16-bit flags word, so rather than markingthe promise as handled it cleared
isHandled(andisFirstResolvingFunctionCalled,and the inline-reaction bits). The rejection reason was then handed back to the
resolver, which found no
pathproperty on it and fell through to defaultresolution. The promise, still unhandled, surfaced later as an unhandled rejection.
Fixing that routed a throwing
onResolveintomoduleLoaderResolve, the resolvehook for static imports and the entry point, where:
resolve_hook(src/runtime/jsc_hooks.rs) has threereturn falsepaths and allthree leave a JS exception pending without writing
res; a plugin'sonResolvethrowing is one of them. So
throwExceptionbuilt anExceptionaround a wildJSValue, and the first thing that touched it crashed:This is reachable on stock Bun today with a plain synchronous
throw, because theentry point is resolved through this hook. The sibling hook for dynamic imports,
moduleLoaderImportModule, already zero-initializes itsErrorableStringand checksscope.exception()before readingresult.err, which is whyawait import()andrequire()were unaffected.Fix
BunPlugin::OnResolve::run:markAsHandled()+throwException(globalObject, scope, promise->result())for a rejected promise, matchingModuleLoader.cpp's handling of a rejected virtual module. A still-pending promise is also marked handled, since nothing attaches a handler to it once the existingonResolve() doesn't support pending promises yetTypeError is thrown.moduleLoaderResolve: zero-initializeresand check for a pending exception before readingres.result.err, the same shapemoduleLoaderImportModuleuses.The invariant
moduleLoaderResolvenow relies onThe refactor makes the success-path deref of
res.result.valueunconditional, so it is worthstating the contract it depends on explicitly.
resolve_hook(src/runtime/jsc_hooks.rs) hasexactly five
return truepaths and threereturn falsepaths:*ressuccesserr(NameTooLong, js_err)on_resolve_jscreturnedok(hardcoded alias path)err(err, js_err)ok(clone_utf8(result_path))The only path that could pair
success == truewith a pending exception is 4999, and it cannot.Inside
plugin_runner_on_resolve_jscevery call that can throw propagates with?, which becomesErr(_) => return falseat 5004. The two places that turn a thrown value into anErrorableString(
VirtualMachine.rs:6704and:6713) callglobal.try_take_exception(), which removes theexception from the global before storing it, and those yield
success == falseanyway. The singleok(...)return (:6719) is reached only after every fallible step has succeeded.So
success == trueimplies nothing is pending and control reaches the deref;success == falseimplies either an exception is pending with
resnever written (harmless,memsetleft it zeroed)or
res.result.errholds a realJSValueand we throw it. The old code relied on the weaker halfof this same contract, just incorrectly: it assumed
success == falsealways meantres.result.errwas populated, which is exactly what those three
return falsepaths violate.Empirically, the
require-cachefile-path leak fixture (10,000require()round trips, which wouldleak one resolved path per import if the deref were ever skipped) reports 68 MB on this branch vs
70 MB on
main, i.e. no delta beyond noise.Verification
Six tests in
test/js/bun/plugin/plugins.test.tscoveringimport(),require(), anon-
Errorthrow, a synchronous throw, entry-point resolution, and a promise thatrejects after the resolver has already returned. Each spawns a subprocess that
collects
unhandledRejectionand prints it fromprocess.on("exit"), sounhandled: []is an assertion rather than an absence check.test/regression/issue/22199.test.ts's "onResolve with rejected promise should throwerror" passed for the wrong reason: it asserted only exit code 1 and the message on
stderr, both of which the leaked unhandled rejection satisfied while
index.jsran tocompletion anyway. It now also asserts stdout is empty.
Before / after
No regressions across
test/js/bun/plugin/,test/js/bun/resolve/,test/js/bun/module/,test/js/node/module/,test/bundler/bundler_plugin.test.ts, and the preload suites.