Bun.serve: don't finalize the request again when error() upgraded or ended it - #39409
Conversation
…he request server.upgrade(req) called from Bun.serve's error(err) handler released the RequestContext's ref and detached the response, then the error path went on to render the development error page and deref'd the context again (assertion 'ref_count > 0' / refcount underflow in release).
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 59 minutes Limit details: You’ve used all 1 included review currently available under your plan. You completed 72 included PR reviews in the past 7 days; at that activity level, included reviews refill at 1 review per hour. 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 9:52 PM PT - Aug 16th, 2026
✅ @Jarred-Sumner, your commit f689319d2121a112f3bdc94a98986ea0dc1f32ae passed in 🧪 To try this PR locally: bunx bun-pr 39409That installs a local version of the PR into your bun-39409 --bun |
There was a problem hiding this comment.
LGTM — the new guard mirrors the exact is_aborted_or_ended() || did_upgrade_web_socket() check that handle_resolve and on_response already apply after re-entering user JS, and the removed Fulfilled-arm check is provably dead behind it.
What was reviewed
- Traced the double-deref: upgrade nulls
resp, thenrender_default_error'sresp == Nonearm hitsfinalize_without_deinit(); deref()on an already-released ctx — the new early return cuts that path. - Confirmed the removed check in
process_on_error_promiseis redundant: its only caller is now guarded, andpromise.unwrap(.., MarkHandled)runs no user JS between the new guard and the old check. - Verified the async path (
Pending→ON_RESOLVE→handle_resolve) already has the same guard, so the sync/async paths now agree. - Test spawns a subprocess with
development: trueand asserts the full 101 → open → echo → close-1000 sequence, so it fails on the unfixed assert-crash rather than passing vacuously.
Extended reasoning...
Overview
Five-line guard added to run_error_handler_with_status_code_dont_check_responded in src/runtime/server/RequestContext.rs, returning early if the request was aborted/ended or upgraded to a WebSocket during the synchronous error() callback. An eight-line now-redundant did_upgrade_web_socket() check is removed from process_on_error_promise's Fulfilled arm. One subprocess test added to test/js/bun/websocket/websocket-server.test.ts.
Security risks
None. This is a lifecycle/refcount fix — the added guard only prevents a second finalize+deref on a context whose base ref was already released by server.upgrade(). No new user-controlled input is parsed and no auth/crypto/permissions surface is touched.
Level of scrutiny
High, because this is refcount-sensitive native code in the HTTP server request lifecycle. I traced both directions:
- Can the new early return leak? No — the paths that set
Upgradedor clearresp(upgrade, abort,server.stop(true)) each already release the context's base ref; returning here just stops the second release. - Is the removed check truly dead? Yes —
process_on_error_promiseis called only at RequestContext.rs:3510, immediately after the new guard, andpromise.unwrap()on an already-fulfilled promise reads state without invoking user JS, sodid_upgrade_web_socket()cannot flip between the two points. ThePendingarm still routes throughhandle_resolvewhich retains its own guard (line 730).
The change is not novel logic; it copies verbatim the guard shape from handle_resolve (line 730), on_response (lines 2563/2624), and the abort check at 998, which is exactly what REVIEW.md's "re-validate liveness guards after every callback" rule prescribes.
Other factors
- No CODEOWNERS entry covers
src/runtime/server/. - Test is placed in the existing suite file (not a new file), uses
it.concurrent, spawns viabunExe() -ewithbunEnv, drains stdout concurrently withexited, and asserts exact stdout before exit code — matches harness conventions.development: trueis set so the crashingrender_default_errorpath is exercised. - Bug-hunting system found nothing.
What
server.upgrade(req)called from insideBun.serve'serror(err)handler (afterfetchthrew for a WebSocket-upgrade request) completed the upgrade — which releases theRequestContext's base ref and detachesresp— and then the synchronous error-handler path fell through intofinish_running_error_handler→ (dev mode)render_default_error, whoseresp == Nonearm finalizes and derefs again:assertion failed: ref_count > 0on debug/assert builds, a refcount underflow on release. The async error-handler path already returned early ondid_upgrade_web_socket(); only the sync path was missing it.After calling
on_error,run_error_handler_with_status_code_dont_check_respondednow returns ifis_aborted_or_ended() || did_upgrade_web_socket()— the same guard every sibling site uses after re-entering user JS (handle_resolve,on_response,on_upgrade) — which also coverserror()handlers that end the request another way (e.g.server.stop(true)). The now-redundant check inprocess_on_error_promise'sFulfilledarm is removed. Semantics: upgrading fromerror()works (101 +openfires), matching what the async path already did.Repro (before)
Tests
test/js/bun/websocket/websocket-server.test.ts— "server.upgrade() from the error() handler after fetch() threw completes the handshake". Fails on the ASan canary and debug main; passes here.