jsc: check for exceptions before using a stored constructEmptyArray result - #37448
jsc: check for exceptions before using a stored constructEmptyArray result#37448robobun wants to merge 6 commits into
Conversation
…esult
constructEmptyArray returns null with an exception pending (out of memory, or
a VM trap such as worker termination serviced by its internal
RETURN_IF_EXCEPTION). JSC__JSValue__upsertBunStringArray and the
process.binding('http_parser') methods/allMethods builders dereferenced the
result before checking the scope.
MatchedParams::to_js, the only caller of upsertBunStringArray, now propagates
the error instead of unwrapping it; both DevServer callers already return
JsResult.
Add a source lint requiring an exception check as the statement following
every stored constructEmptyArray call.
Same change as #37441, included so the new lint passes on this branch. It is byte-identical to that PR's hunk, so it merges cleanly in either order.
|
Status: reproduced by analysis only (the window is between the preceding exception check and the one inside Head 625ce60: http_parser builders propagate with |
WalkthroughChangesException propagation hardening
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/bake/DevServer.rs`:
- Line 2688: Update the request handling flow around params.to_js(global)? so
conversion failures complete resp before propagating or reporting the JavaScript
exception. End resp directly or invoke the existing HTTP error-page helper,
ensuring on_saved_request is not reached with an incomplete response.
In `@test/internal/source-lints/construct-empty-array-unchecked.test.ts`:
- Around line 68-72: The isExceptionCheck scanner currently accepts exception
conditions without verifying control-flow termination. Update isExceptionCheck
and its surrounding statement analysis to require the exceptional branch to
return, throw, or otherwise terminate before treating the check as safe;
preserve existing recognized macro and negated-identifier cases. Add a detects
fixture covering a branch that only logs before later using the potentially null
array.
🪄 Autofix
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: 4f497824-292e-4a56-8c14-98140c12fc4e
📒 Files selected for processing (6)
src/jsc/bindings/BunString.cppsrc/jsc/bindings/ProcessBindingHTTPParser.cppsrc/jsc/bindings/ProcessBindingUV.cppsrc/runtime/bake/DevServer.rssrc/runtime/bake/FrameworkRouter.rstest/internal/source-lints/construct-empty-array-unchecked.test.ts
There was a problem hiding this comment.
LGTM — mechanical exception-check additions following the tree's established pattern, with a source lint pinning the invariant.
What was reviewed:
- BunString.cpp / ProcessBindingUV.cpp:
RETURN_IF_EXCEPTIONafterconstructEmptyArray/putDirectIndexmatches the shape used at the other 84 sites; ProcessBindingUV hunk is identical to already-merged #37441. - ProcessBindingHTTPParser.cpp:
constructMethodsArrayhelper is a verbatim copy of theProcess_stubEmptyArrayPropertyCallback pattern in BunProcess.cpp (TopExceptionScope + tryClearException + reportUncaughtExceptionAtEventLoop). - FrameworkRouter.rs:
to_js→JsResult<JSValue>— grepped for callers, both DevServer sites updated with?and already returnJsResult. - Source lint: self-test fixtures cover each detected/accepted shape and guard against a vacuous scan;
globAllSources().cxxand the git-tracked filter match sibling lints intest/internal/source-lints/.
Extended reasoning...
Overview
This PR adds missing exception checks after JSC::constructEmptyArray at four call sites that stored the result and dereferenced it before checking the throw scope. It touches three C++ binding files (BunString.cpp, ProcessBindingHTTPParser.cpp, ProcessBindingUV.cpp), propagates a now-reachable error through MatchedParams::to_js in FrameworkRouter.rs / DevServer.rs, and adds a new source-lint test that scans every C++ source for stored constructEmptyArray results not immediately followed by an exception check.
Security risks
None. This is purely defensive exception handling in JSC binding code — no auth, crypto, parsing of untrusted input, or permission changes. The only behavioral change on the success path is zero (extra RETURN_IF_EXCEPTION macros that don't fire).
Level of scrutiny
Low-to-medium. The C++ changes are mechanical additions of RETURN_IF_EXCEPTION(scope, {}) following the exact shape used at 84 other sites in the tree. The constructMethodsArray helper in ProcessBindingHTTPParser.cpp is a line-for-line copy of Process_stubEmptyArray in BunProcess.cpp (same TopExceptionScope / tryClearException / reportUncaughtExceptionAtEventLoop sequence), which is the established pattern for PropertyCallback lazy builders that cannot propagate. The ProcessBindingUV.cpp hunk is identical to #37441 which is already on main. The Rust signature change (JSValue → JsResult<JSValue>) is compile-time enforced; I grepped for params.to_js( and confirmed both callers are updated and both enclosing functions already return JsResult.
Other factors
- The new source lint follows the conventions of the 16 existing lints in
test/internal/source-lints/: it usesglobAllSources().cxx, filters to git-tracked non-symlink files, has self-test fixtures for every detected shape (6) and every accepted shape (11), and assertsscanned > 0so a broken glob cannot make the tree-wide check pass vacuously. The regex correctly excludes==via(?!=), strips comments while preserving line numbers, and skips closing braces / preprocessor directives when finding the next statement. - Success paths were verified on the debug build with
BUN_JSC_validateExceptionChecks=1per the PR description; the counts (methods.length35,allMethods.length47,getErrorMap().size85) are unchanged. - No outstanding reviewer comments; only the robobun status note is on the timeline.
…tion A check whose branch only asserts or logs does not protect the use after it, so follow if-statements into their bodies and require a return, goto, break or continue, allowing non-leaving checks to be followed by one that leaves. Also accept returning the result untouched, and blank string literals so braces in messages do not affect matching.
|
Updated 7:47 AM PT - Aug 11th, 2026
✅ @robobun, your commit 625ce60436e7bdf30884146c3a90014983619fdd passed in 🧪 To try this PR locally: bunx bun-pr 37448That installs a local version of the PR into your bun-37448 --bun |
There was a problem hiding this comment.
I reviewed this PR and the bug-hunting system found no issues. The exception-check additions and the JsResult propagation look correct and follow existing patterns, but since this touches JSC bindings (the memory-safety hot zone), refactors jsGetErrorMap beyond a pure check-insertion, adds a ~350-line source lint, and coordinates with #37441, a human look would still be worthwhile.
What was reviewed:
constructMethodsArraymatchesProcess_stubEmptyArrayin BunProcess.cpp (TopExceptionScope + tryClearException + report +jsUndefined()).- Both
params.to_js(global)?call sites in DevServer.rs returnJsResult, so?propagates to the sameon_requesterror arm as the neighboringcompute_arguments_for_framework_request(..)?. - The
jsGetErrorMapconstexpr-table refactor preserves the entry set (sameBUN_UV_ERRNO_MAPexpansion) and now has a real ThrowScope where the old code only had a// RETURN_IF_EXCEPTIONcomment. - The lint's accepted shapes match live tree patterns (
RETURN_IF_EXCEPTION,if (scope.exception())with a leaving body, hands-offreturn/RELEASE_AND_RETURN);realpathSyncdedup andgit ls-treefiltering follow the sibling source-lint conventions.
Extended reasoning...
Overview
Adds RETURN_IF_EXCEPTION after stored constructEmptyArray results in three C++ files (BunString.cpp, ProcessBindingHTTPParser.cpp, ProcessBindingUV.cpp), converts MatchedParams::to_js to JsResult<JSValue> with ? propagation at its two DevServer callers, and adds a 352-line source lint under test/internal/source-lints/ that requires an exception check as the next statement after every stored constructEmptyArray.
Security risks
None identified. This is defensive hardening: it converts potential null-pointer dereferences (segfault at 0x4 on putDirectIndex) into propagated JS exceptions or reported-and-swallowed errors in lazy property builders. No new attack surface, no user-controlled input handling changes.
Level of scrutiny
High. The touched code is JSC bindings, which REVIEW.md calls out as the most-blocked category. The changes themselves are mechanical (adding checks that ~84 other sites already have), but ProcessBindingUV.cpp also restructures a lambda-with-macro-expansion into a static constexpr table + loop, and ProcessBindingHTTPParser.cpp introduces a shared helper with a TopExceptionScope. Both follow established in-tree patterns (Process_stubEmptyArray, BunString__createArray), and I verified the pattern match, but a maintainer should confirm the tryClearException + reportUncaughtExceptionAtEventLoop + return jsUndefined() shape is what they want for these two PropertyCallback builders.
Other factors
- The source-lint test is substantial (350 lines of regex/bracket-matching C++ scanning) but sits in an established directory of similar lints and self-tests via
detects/ignoresfixtures for each recognized shape. - All CodeRabbit and comment-cop review threads are resolved: the lint now requires the exception branch to leave the function (1220859), and the helper comment was shortened to one line (9ad12db).
- The
ProcessBindingUV.cpphunk intentionally overlaps open PR #37441; a human should confirm the merge-order plan. - The
.expect("unreachable")removals inFrameworkRouter.rswere demonstrably reachable (via the sameconstructEmptyArrayOOM/termination path and via the pre-existinggetIfPropertyExists/array->pushinupsertBunStringArray), so theJsResultconversion is a strict improvement.
…ead of reporting it reifyStaticProperty's callers (Lookup.cpp, JSObject::reifyAllStaticProperties) check for a pending exception after a PropertyCallback and leave the property unreified, so the builders can use the same RETURN_IF_EXCEPTION shape as constructVersions. Termination is deferred around these callbacks, so the only failure that reaches them is out of memory. Reword the lint accordingly.
MatchedParams::to_js and JSFrameworkRouter.match copy each param with clone_utf8 (+1) and never released it; toJS and upsertBunStringArray take their own references. The Zig version deferred a deref on each. Wrap them in OwnedString, which also covers the new early returns.
|
Two changes after a self-review pass, both pushed:
The description is updated accordingly. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The exception-check additions follow the established constructVersions/BunString__createArray shapes, the OwnedString leak fix is the idiomatic RAII wrapper, and both DevServer callers already return JsResult. Because the change bundles a JSC bindings refactor (ProcessBindingUV.cpp), a signature change propagated across two crates, a mid-review leak fix, and a new 350-line source-lint scanner, a human look would still be worthwhile.
What was reviewed:
OwnedStringderefs to&Stringforput_bun_string_one_or_arrayand drops on?early return — no double-free, no leak on the new error paths.- ProcessBindingHTTPParser.cpp
TopExceptionScope+RETURN_IF_EXCEPTION(scope, {})matches BunProcess.cpp:222 exactly;reifyStaticPropertycallers propagate the empty return. - ProcessBindingUV.cpp constexpr-table refactor preserves the 85-entry output and keeps the code-size win the old lambda comment referenced.
- Source lint's
nextStatement/unprotectedUsehandle brace-lessif,[[unlikely]], and preprocessor lines; the 27 fixtures cover each accepted/rejected shape and the tree passes.
Extended reasoning...
Overview
The PR adds RETURN_IF_EXCEPTION checks after four stored constructEmptyArray results that previously dereferenced null on OOM/termination: JSC__JSValue__upsertBunStringArray (BunString.cpp), two PropertyCallback builders in ProcessBindingHTTPParser.cpp, and jsGetErrorMap in ProcessBindingUV.cpp (shared hunk with #37441). It propagates the resulting fallibility through MatchedParams::to_js (.expect("unreachable") → JsResult) and its two DevServer callers. During self-review the author found and fixed a clone_utf8 leak in the same loop (and its sibling in match()) by wrapping in OwnedString, with an RSS regression test. A new 353-line source lint pins the tree-wide invariant.
Security risks
None. No user-input parsing, auth, crypto, or trust-boundary changes. The exception checks harden crash paths (null deref → propagated error) and the leak fix reduces resource exhaustion risk. The source lint runs git ls-tree on the local repo only.
Level of scrutiny
Medium-high. JSC exception handling in C++ bindings is the most-blocked review category per REVIEW.md, and getting scope semantics wrong (missing RELEASE_AND_RETURN, wrong scope type) causes debug-assert failures or silent swallowing. The ProcessBindingUV.cpp change is a structural refactor (lambda+macro → constexpr table+loop), not a pure addition. The Rust signature change touches two crates. The source lint is a regex-based C++ scanner that will run on every future commit — false positives would block unrelated PRs.
Other factors
- All three CodeRabbit/github-actions review threads are resolved: the lint now requires the exception branch to leave the function (commit 1220859), the DevServer error-path concern was withdrawn as consistent with sibling
?s, and the long http_parser comment was cut then removed entirely when the propagation approach changed (bc84ba9). - CI was green (190/190) on the previous head; the latest push (625ce60) adds only the leak fix and http_parser propagation change.
- The PR description documents
BUN_JSC_validateExceptionChecks=1runs on all affected success paths and unchanged output sizes (35/47/85). - The scope expanded mid-review (leak fix in 625ce60) — justified under REVIEW.md's "re-auditing that loop for the new early returns" rule, but adds surface a human should confirm.
Given the breadth (7 files, ~400 net lines, JSC bindings + Rust signature change + new tree-wide lint), this exceeds the "simple, mechanical, or obvious" bar for auto-approval.
What
JSC::constructEmptyArrayreturnsnullptrwith an exception pending in two cases:JSArray::tryCreatefailed and it threw out of memory, or its ownRETURN_IF_EXCEPTION(JSGlobalObjectInlines.h) fired. That macro goes throughvm.hasExceptionsAfterHandlingTraps(), so aworker.terminate()(or avmtimeout) whose trap is first serviced inside the call also produces the null return. Three builders in the tree stored the result and dereferenced it before checking the scope:JSC__JSValue__upsertBunStringArray(BunString.cpp), in the branch that turns a repeated key into a two element array. Its only caller isMatchedParams::to_js(bakeFrameworkRouter.rs), reached for every dev server request whose catch-all route (pages/[...slug].tsx) matches two or more segments. Out of memory or termination applies here.process.binding('http_parser').methodsand.allMethods(ProcessBindingHTTPParser.cpp). These are static-tablePropertyCallbackbuilders, evaluated once per global;node:httpreads both at load. JSC defers termination around these callbacks (Lookup.cpp,JSObject::reifyAllStaticProperties), so out of memory is the only failure that reaches them.process.binding('uv').getErrorMap()(ProcessBindingUV.cpp), where the window is wide enough to hit on demand. process.binding('uv'): fix segfault when worker.terminate() lands inside getErrorMap() #37441 fixes that one with a runtime test; its hunk is included here unchanged so the lint below passes on this branch. The two PRs merge cleanly in either order.The crash is the same in all three:
panic: Segmentation fault at address 0x4(member call on a nullJSC::JSObjectat the firstputDirectIndex).BUN_JSC_validateExceptionChecks=1does not flag the first two sites.putDirectIndexinto a pre-sized array andputDirect(vm, ..)open no ThrowScope, so theRETURN_IF_EXCEPTIONat the end ofupsertBunStringArraysatisfies the validator while the dereference sits before it, and the http_parser builders had no scope at all.Fix
RETURN_IF_EXCEPTIONafterconstructEmptyArrayand after eachputDirectIndex, the shape every other storedconstructEmptyArrayin the tree uses (84 of 88 sites; the other four are the ones above).TopExceptionScopeandRETURN_IF_EXCEPTION(scope, {}), asconstructVersionsin BunProcess.cpp does.reifyStaticPropertyleaves the property unreified when a builder returns empty and both of its callers propagate the exception, so the access throws the out of memory error and a later access retries. TheputDirectIndexcalls that follow cannot throw: the arrays are pre-sized to the 35 and 47 entries of the two method maps.MatchedParams::to_jsreturnsJsResultinstead of.expect("unreachable")on a result that is reachable (and already was, via thegetIfPropertyExistscheck andarray->push). Both DevServer callers already returnJsResult, and the early return takes the same path as thecompute_arguments_for_framework_requesterror a few lines below. Re-auditing that loop for the new early returns turned up that the twoclone_utf8copies per param were never released on any path (toJSandupsertBunStringArraytake their own references; the Zig version had adefer deref()on each), so every params object leaked its keys and values. They are nowOwnedStrings, and the same one-line fix is applied to thematch()binding in the same file, which had the same leak.Test
There is no reliable runtime reproduction for the BunString or http_parser crashes: the termination has to be observed between the preceding exception check and the one inside
constructEmptyArray, and the http_parser builders run once per global and only fail on out of memory. The fix is instead pinned by a source lint,test/internal/source-lints/construct-empty-array-unchecked.test.ts: after every storedconstructEmptyArraycall, the following statements have to be checks of the scope or of the result ending in one that leaves (RETURN_IF_EXCEPTION, or anifonscope.exception()/!resultwhose body returns or jumps), or a plain return of the result. Comments do not count, which is what the old// RETURN_IF_EXCEPTIONline in ProcessBindingUV.cpp relied on, and neither does a branch that only asserts or logs. It has fixtures for each detected and accepted shape, and onmainit reports exactly the four sites listed above:lint output on main
This covers the stored-result flavor only; #37296 adds a lint for the inline argument flavor of the same contract.
The leak has a regression test in
test/bake/framework-router.test.ts: 200match()calls with four 256 KiB params grow RSS by 204 MB on the current release and by 12 MB on the fixed ASAN debug build (quarantine disabled for the measurement, as the other RSS tests do), against a 64 MB bound.MatchedParams::to_jsitself is only reachable through a dev server request, so the production site is covered by the identical one-line change rather than a test.Success paths were run on the debug build with
BUN_JSC_validateExceptionChecks=1:test/bake/dev/ssg-pages-router.test.ts -t catch-all(exercises the put, new-array and push branches ofupsertBunStringArraythroughMatchedParams::to_js),test/bake/framework-router.test.ts,test/js/node/process-binding.test.ts,test/js/node/http/node-http-parser.test.ts,test-uv-errmap.jsandtest-http-parser-lazy-loaded.js;methods.length,allMethods.lengthandgetErrorMap().sizeare unchanged (35, 47, 85).