Skip to content

bake: release SavedRequest ctx ref when a deferred framework request is aborted - #36811

Open
robobun wants to merge 6 commits into
mainfrom
claude/farm/8c15a488/saved-request-deinit
Open

robobun wants to merge 6 commits into
mainfrom
claude/farm/8c15a488/saved-request-deinit

Conversation

@robobun

@robobun robobun commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A framework-route request that is deferred while its bundle is building (stored as a SavedRequest inside DeferredRequest::ServerHandler) leaks its RequestContext when the client disconnects mid-bundle. The server's pending_requests never returns to zero, so deinit_if_we_can() never frees the NewServer and the bake harness graceful-exit check fails with:

error: Failed to trigger deinit. Check with BUN_DEBUG_Server=1 and see why it does not free itself.

Repro

// framework route with a plugin that blocks bundling
// 1. GET / over raw TCP           -> request is deferred (SavedRequest created, ref_count=2)
// 2. client closes the socket     -> RequestContext::on_abort runs the DeferredRequest abort cb
// 3. unblock + GET / again        -> bundle drains; aborted entry is skipped
// 4. server.stop(true)            -> pending_requests stuck at 1, deinit never fires

Cause

SavedRequest carries an AnyRequestContext (a Copy tagged pointer), so Rust Drop does nothing for the ref it owns; SavedRequest::deinit() exists to release it but three sites dropped the struct without calling it:

  • DeferredRequest::abort() just did drop(saved) after set_signal_aborted. On client disconnect RequestContext::on_abort runs this callback and then drops one ref via its own RequestContextRef guard, leaving the count at 1; the drain loop later sees Handler::Aborted and skips the entry.
  • on_saved_request's defensive respond_stopped_503 early-return for SavedRequestUnion::Saved (documented-unreachable today but would leak if it ever fires).
  • on_framework_request_with_bundle's ? on compute_arguments_for_framework_request drops req on error before reaching on_saved_request; the drain-loop scopeguard only balances the defer_request +1.

Fix

Call saved.deinit() at all three sites. SavedRequest::deinit() releases the js_request strong (idempotent with StrongOptional::Drop) and ctx.deref(); with RequestContext::on_abort's existing RequestContextRef drop the count reaches zero on the client-disconnect path, on_request_complete() runs, and pending_requests decrements.

Why not impl Drop for SavedRequest?

A Drop that calls self.ctx.deref() is the structurally right shape and would make future early-returns safe by construction. I kept this PR to targeted deinit() calls because the existing bundle-failure drain (DevServer.rs:4636-4643) and DeferredRequest::__deinit() rely on double-deinit() to balance both the initial and defer_request refs; moving to Drop changes that pattern and the on_plugins_rejected / finalize_bundle OOM paths (which still leave the defer_request +1 unbalanced and never write a response, tracked separately) belong in the same refactor. Happy to follow up with the Drop + take_ctx() change across all sites.

Test

test/bake/dev/bundle.test.ts gains a test that uses an onLoad plugin to hold bundling open deterministically, opens a raw TCP request to /, waits for the plugin to signal it has been entered (request is deferred), destroys the socket, round-trips a plain harness route so the server has processed the FIN, then unblocks bundling and fetches again. The bake harness gracefulExit() asserts getDevServerDeinitCount() increments after stop(true); without the fix that check throws.

Also re-ran test/bake/deinitialization.test.ts and test/bake/dev/plugins.test.ts with the change.

…is aborted

DeferredRequest::abort() dropped the SavedRequest without calling
deinit(), so the RequestContext refcount that
prepare_and_save_js_request_context seeded never reached zero. On the
client-disconnect path RequestContext::on_abort's own RequestContextRef
drop balances one of the two refs, and the orphaned one left
pending_requests stuck, preventing DevServer deinit.

Also release the same ref in on_saved_request's defensive 503 early
return for the Saved variant (unreachable today but would leak if the
Finalized-wrapper gate ever fires).
@github-actions github-actions Bot added the claude label Aug 3, 2026
@robobun

robobun commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Three same-class sites covered (DeferredRequest::abort(), on_saved_request 503 gate, on_framework_request_with_bundle args-construction error). Self-review concerns addressed: test synchronization tightened, poll deadline scales with WAIT_MULTIPLIER, socket wrapped in try/finally. The impl Drop for SavedRequest refactor is noted in the PR body as a follow-up.

CI: test/bake/dev/bundle.test.ts (including the new test) has passed on every lane across builds 88026 / 88041 / 88058. Remaining reds are the bun-add.test.ts SCP-style git-SSH failure (reported as a main break) and assorted [flaky] lanes that passed on retry; none touch bake or the server request path.

Reproduced with:

bun bd test test/bake/dev/bundle.test.ts -t "client disconnect on a deferred framework route"

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Intermittent ASAN heap-use-after-free in bake dev-server deinit (test/bake/deinitialization.test.ts) #34850 - Intermittent ASAN heap-use-after-free in bake dev-server deinit; the leaked RequestContext refcount from un-deinited SavedRequest on abort could cause the teardown race described in this issue

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #34850

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 21731d68-08b7-4337-9417-d314a9564e65

📥 Commits

Reviewing files that changed from the base of the PR and between 1ada084 and ed1f565.

📒 Files selected for processing (1)
  • test/bake/dev/bundle.test.ts

Walkthrough

Changes

The server now explicitly deinitializes saved requests on abort, argument-computation errors, and finalized-server dispatch. A regression test covers client disconnects during deferred framework-route bundling.

Deferred request cleanup

Layer / File(s) Summary
Request cleanup paths
src/runtime/bake/DevServer.rs, src/runtime/server/mod.rs
Abort, argument-error, and finalized-server paths now explicitly deinitialize owned SavedRequest values.
Disconnect regression coverage
test/bake/dev/bundle.test.ts
The test pauses deferred bundling, destroys a raw TCP client, completes bundling, verifies later requests succeed, and exercises graceful shutdown.

Possibly related PRs

  • oven-sh/bun#36567: Modifies server request-abort cleanup and pending-request accounting.
  • oven-sh/bun#36618: Modifies RequestContext lifecycle management for aborted or asynchronous requests.
  • oven-sh/bun#36808: Modifies deferred and saved-request handling in src/runtime/server/mod.rs.

Suggested reviewers: jarred-sumner, alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main fix: releasing the SavedRequest context reference when a deferred framework request is aborted.
Description check ✅ Passed The description explains the problem, cause, fix, affected paths, regression test, and verification results, although it does not use the template headings.

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

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

Beyond the inline test-race nit, I traced the refcount accounting for both changed sites and it checks out: at DeferredRequest::abort() the count is 2 (initial +1 from prepare_and_save_js_request_context, +1 from defer_request's ctx.ref_() at DevServer.rs:2212), so saved.deinit() + RequestContext::on_abort's RequestContextRef drop reaches zero — same pattern __deinit and the failed-bundle drain (DevServer.rs:4641-4643) already use. For the on_saved_request 503 early-return, the only Saved caller (drain loop at DevServer.rs:4843-4857) already arms scopeguard::defer! { ctx.deref() }, so saved.deinit() there is the second of two required derefs, not a double-free.

Extended reasoning...

Verified the two-site fix balances refs on every terminal path: abort → 2→0 via saved.deinit + on_abort's RequestContextRef; 503 gate → 2→0 via saved.deinit + caller scopeguard. StrongOptional::deinit-then-Drop is an existing idiom at DevServer.rs:3094 and :4643. Deferring because this is refcount-lifecycle code and the test has the flagged ordering gap.

Comment thread test/bake/dev/bundle.test.ts Outdated
…e gate

Await the local close event and round-trip a plain route so the server's
event loop has processed the FIN (and DeferredRequest::abort has run)
before the plugin gate is released. Without this the plugin's poll could
in principle observe the unblock file before the server handled the
disconnect, letting the drain loop take the normal ServerHandler path
and masking the regression.
Comment thread src/runtime/bake/DevServer.rs Outdated
Comment thread src/runtime/server/mod.rs Outdated

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/bake/DevServer.rs:3119-3125 — Same-class miss: on_framework_request_with_bundle (DevServer.rs:2671) does compute_arguments_for_framework_request(...)? while holding req: SavedRequestUnion by value — on Err the ? early-returns and req drops without saved.deinit(), leaking the same prepare_and_save_js_request_context +1 this PR fixes here and in on_saved_request. The drain-loop caller's scopeguard::defer! { ctx.deref() } only balances the defer_request +1, so pending_requests sticks. Trigger is JS OOM during args construction (same reachability tier as the 503 gate this PR patches), but per REVIEW.md "fix the whole class in the same PR" it should get the same if let SavedRequestUnion::Saved(mut saved) = req { saved.deinit() } guard — or better, move the release into a Drop/scopeguard so no early-return can skip it.

    Extended reasoning...

    What the bug is

    This PR fixes two sites where a SavedRequest is dropped without deinit(), leaking the RequestContext refcount it owns: DeferredRequest::abort() and on_saved_request's respond_stopped_503 early-return. A third same-class site is not covered: on_framework_request_with_bundle (DevServer.rs:2607-2692) receives req: SavedRequestUnion by value and, at line 2671, calls:

    let args = unsafe {
        Self::compute_arguments_for_framework_request(...)
    }?;

    compute_arguments_for_framework_request returns JsResult<FrameworkRequestArgs> and has multiple internal ? sites on JS-allocation calls (create_utf8_for_js at 2472, create_empty_array at 2499, put_index at 2514/2528, transfer_to_js at 2514/2531, to_js at 2559, generate_css_js_array at 2579). If any of them returns Err — reachable at least on JS OOM or a termination trap — the ? at 2671 early-returns from on_framework_request_with_bundle and req is dropped by fall-through.

    The specific code path

    The only caller that passes SavedRequestUnion::Saved is the drain loop at DevServer.rs:4841-4860:

    Handler::ServerHandler(saved) => {
        let response = saved.response;
        let ctx = saved.ctx;
        scopeguard::defer! { ctx.deref() };   // balances defer_request's ctx.ref_()
        dev.on_framework_request_with_bundle(
            req.route_bundle_index,
            SavedRequestUnion::Saved(saved),  // moves the initial +1 in
            response,
        )?
    }

    At this point ctx holds two refs: the initial +1 from prepare_and_save_js_request_context (owned by saved) and the +1 from defer_request's ctx.ref_() at DevServer.rs:2212 (balanced by the scopeguard::defer!). When on_framework_request_with_bundle returns Err, saved has already been moved in and drops there; the caller's scopeguard fires and drops the count 2→1. Nothing releases the remaining +1.

    Why existing code doesn't prevent it

    SavedRequest has no Drop impl — only an explicit deinit() (mod.rs:4037). Its js_request: StrongOptional releases via Drop, but ctx: AnyRequestContext is Copy, so plain drop is a no-op for the refcount — exactly the mechanism this PR's own comment at DevServer.rs:3119-3125 documents. On the success path this ref is consumed by on_saved_request handing ctx into the RequestContext lifecycle (on_response → deinit), but the ?-error path bypasses on_saved_request entirely. The drain loop is the first request to a route, so the cached_* lookups in compute_arguments_for_framework_request are all None and every fallible ? site is live. The other caller at DevServer.rs:1877 passes SavedRequestUnion::Stack, which does not own a ctx ref, so only the drain-loop path is affected.

    Step-by-step proof

    1. Client requests a framework route whose bundle is not yet built → prepare_and_save_js_request_context creates RequestContext at ref_count=1, defer_request bumps to 2 and stores Handler::ServerHandler(saved).
    2. Bundle finishes; drain loop takes saved (owning the initial +1), arms scopeguard::defer! { ctx.deref() } for the defer_request +1, and calls on_framework_request_with_bundle(SavedRequestUnion::Saved(saved), ...).
    3. Inside, compute_arguments_for_framework_request hits e.g. JS OOM in create_empty_array → Err.
    4. ? at line 2671 returns Err from on_framework_request_with_bundle; req (holding saved) drops with no deinit() → ctx ref not released.
    5. Back in the drain loop, the scopeguard fires: ref_count 2→1. The ? propagates out of the drain loop.
    6. ref_count is stuck at 1; on_request_complete() never runs; pending_requests never decrements; deinit_if_we_can() never frees the server.

    Impact

    Identical to the bug this PR fixes: a leaked RequestContext pool slot and a pending_requests count that never reaches zero, so the server never deinits on graceful stop. The trigger (JS OOM / termination trap during args construction) is in the same reachability tier as the respond_stopped_503 gate this PR patches — the PR description itself calls that gate "documented-unreachable today" but fixes it anyway for the class.

    How to fix

    Mirror the on_saved_request fix — either wrap the ? site:

    let args = match unsafe { Self::compute_arguments_for_framework_request(...) } {
        Ok(a) => a,
        Err(e) => {
            if let SavedRequestUnion::Saved(mut saved) = req { saved.deinit(); }
            return Err(e);
        }
    };

    or (preferable, given three sites now need the same manual call and the comment-cop bot is already flagging the paragraph-long justifications) move the ctx.deref() into a Drop impl / scopeguard on SavedRequest so no early-return can skip it. Per REVIEW.md "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep)", this sibling belongs in the same change.

robobun added 2 commits August 3, 2026 08:20
Same class as the other two sites: the ? at DevServer.rs:2671 drops
req (and its Saved payload) without deinit() if args construction
fails, leaking the prepare_and_save_js_request_context +1. The
drain-loop scopeguard only balances the defer_request +1.
Comment thread test/bake/dev/bundle.test.ts Outdated

@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 didn't find any bugs, but this is refcount balancing in RequestContext lifecycle across bake and server, with an explicitly deferred design question (impl Drop for SavedRequest) and acknowledged same-class sites (on_plugins_rejected / finalize_bundle OOM) left for follow-up — worth a human look.

What was reviewed:

  • DeferredRequest::abort() → saved.deinit(): verified __deinit() and the drain loop see Handler::Aborted afterward, so no double-deref; on_abort's RequestContextRef guard still holds a ref while ctx.deref() runs (2→1, then 1→0).
  • on_framework_request_with_bundle error path and on_saved_request 503 gate: the added saved.deinit() pairs with the drain-loop scopeguard::defer!{ ctx.deref() } to release both refs; StrongOptional::deinit() is idempotent with its Drop (handle.take()).
  • Test: prior sync and try/finally nits are addressed in the current diff; poll deadline scales with WAIT_MULTIPLIER.
Extended reasoning...

Overview

Three-site SavedRequest::deinit() fix for a leaked RequestContext refcount when a deferred bake framework-route request is aborted or errors before reaching on_saved_request's normal path. Touches src/runtime/bake/DevServer.rs (DeferredRequest::abort, on_framework_request_with_bundle), src/runtime/server/mod.rs (on_saved_request 503 early-return), and adds a bake dev test that gates bundling with an onLoad plugin, aborts a raw-TCP request mid-bundle, and relies on the harness's gracefulExit() deinit-count check.

Security risks

None. This is internal refcount bookkeeping on the dev-server request path; no parsing of untrusted input, no auth/crypto.

Level of scrutiny

High. AnyRequestContext is a Copy tagged pointer whose refcount is balanced manually across bake's deferred-request pool, the server's on_abort RequestContextRef guard, and the drain-loop scopeguard. I traced each new deinit() against the existing paths: after abort() swaps self.handler to Aborted, both __deinit() (via deref_()) and the finalize_bundle drain loop match Handler::Aborted and skip, so the added deref is the only one for the SavedRequest-owned +1; on_abort's guard releases the caller's +1. On the compute_arguments_for_framework_request error path, the drain-loop scopeguard captured ctx by Copy before the move, so its deref (defer_request +1) plus the new saved.deinit() (initial +1) balance. StrongOptional::deinit() uses handle.take(), so the subsequent Drop is a no-op — no double-destroy of the JS strong.

Other factors

The PR body explicitly defers the structurally-right fix (impl Drop for SavedRequest) because it intersects the bundle-failure double-deinit() pattern at DevServer.rs:4636-4643 and the separately-tracked on_plugins_rejected path — a maintainer should weigh in on whether to land this targeted patch or fold it into that refactor. My two prior inline nits (destroy/unblock synchronization, socket try/finally) are both addressed in the current diff. The test looks deterministic now (awaits close, round-trips a plain harness route before releasing the gate, poll deadline scaled by WAIT_MULTIPLIER). Given this is manual refcount work in the most-blocked review category, deferring rather than auto-approving.

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

Both earlier nits are addressed and I have nothing further on the test. The three saved.deinit() additions trace correctly against the SavedRequest.ctx +1 and the defer_request ctx.ref_() +1; the two other abort() callers (finalize_bundle OOM defer and the plugins-rejected drain) go from leaking both refs to leaking one, which matches the follow-up scoping in the PR body. Deferring to a maintainer only for sign-off on that scoping and the deferred impl Drop for SavedRequest refactor.

What was reviewed:

  • Traced ctx refcount on the abort path (initial +1 via SavedRequest, +1 from defer_request line 2212) — saved.deinit() + on_abort's RequestContextRef guard reach 0; __deinit() sees Aborted so no double-deref.
  • Checked the on_framework_request_with_bundle error arm against the drain-loop scopeguard::defer! { ctx.deref() } at DevServer.rs:4858 — two derefs balance two refs.
  • Confirmed the bundle-failure double-deinit() at DevServer.rs:4647 + __deinit() is untouched.
  • StrongOptional::deinit() idempotent with Drop, so saved.deinit() followed by scope-exit is safe.
Extended reasoning...

Overview

Three-site refcount fix in the bake dev server: DeferredRequest::abort(), on_framework_request_with_bundle's error return, and on_saved_request's finalized-server 503 gate now call SavedRequest::deinit() (releases js_request strong + ctx.deref()) where they previously dropped the struct without releasing the Copy AnyRequestContext ref. Plus a bake-harness regression test that gates bundling with an onLoad plugin, aborts a raw-TCP client mid-bundle, and relies on the harness gracefulExit() deinit-count assertion.

Security risks

None. Dev-server request-lifecycle refcounting; no parsing of untrusted input, no auth/crypto surface.

Level of scrutiny

High — this is native refcount management, REVIEW.md's most-blocked category. I traced each changed site against the two refs on ctx (the SavedRequest-owned +1 and the defer_request ctx.ref_() +1 at DevServer.rs:2212):

  • abort() (DevServer.rs:3129): saved.deinit() releases the SavedRequest +1; RequestContext::on_abort's RequestContextRef guard releases the other; the later drain-loop __deinit() sees Handler::Aborted and does nothing. Net 2 derefs, no double-free. The two non-on_abort callers of abort() (DevServer.rs:3856, 6157 — the OOM cleanup defer and the plugins-rejected drain) previously leaked both refs and now leak one; the PR body scopes those to a follow-up alongside the impl Drop refactor.
  • on_framework_request_with_bundle error arm (DevServer.rs:2673-2679): saved.deinit() releases the SavedRequest +1; the drain-loop caller's scopeguard::defer! { ctx.deref() } at 4858 releases the defer_request +1. Stack variant has no owned ctx, so the if let Saved is right.
  • on_saved_request 503 gate (mod.rs:896): documented-unreachable defensive path; matches the other two.
  • The existing bundle-failure double-deinit() pattern at DevServer.rs:4647 (explicit saved.deinit() + __deinit() via deref_() scopeguard) is not touched, and StrongOptional::deinit() is idempotent with its Drop, so no new double-free there.

Other factors

Both of my earlier test nits (destroy/unblock ordering, socket try/finally) and the comment-cop paragraph-length flags were addressed; all threads are resolved. The test now round-trips a plain harness route after await once(sock, "close") so the server has processed the FIN before the plugin gate opens, and the poll deadline scales with WAIT_MULTIPLIER. The Rust change matches the existing __deinit() pattern at DevServer.rs:3104 exactly. I'm deferring rather than approving only because the PR explicitly scopes out two sibling leak sites and a structural Drop refactor for a follow-up — a maintainer familiar with the DeferredRequest pool should confirm that scoping.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant