Skip to content

bake: deref html_bundle in DevServer Drop - #32213

Closed
alii wants to merge 1 commit into
mainfrom
ali/serve-route-deref-leak
Closed

alii wants to merge 1 commit into
mainfrom
ali/serve-route-deref-leak

Conversation

@alii

@alii alii commented Jun 12, 2026

Copy link
Copy Markdown
Member

get_or_put_route_bundle takes an intrusive ref on Route when it creates a RouteBundle; the raw html_bundle field has no Drop and DevServer::Drop never released it. Zig RouteBundle.deinit:112 does the deref; the Rust port missed it. One Route per HTML route per dev-server leaked.

get_or_put_route_bundle takes an intrusive ref when it creates a
RouteBundle, but the raw html_bundle pointer has no Drop and DevServer
Drop never released it. One Route per HTML route per dev-server leaked.
Zig RouteBundle.deinit:112 does the deref; the Rust port missed it.
@robobun

robobun commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:48 PM PT - Jun 12th, 2026

❌ @alii, your commit 6728c23 has 1 failures in Build #62172 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32213

That installs a local version of the PR into your bun-32213 executable, so you can run:

bun-32213 --bun

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR refactors how HTML cached bundle resources are released during DevServer deinitialization. The drop implementation changes to explicitly dereference the raw HTMLBundleRoute reference using bun_ptr::RefCount::deref, and comments are updated to document that the refcount bump is paired with DevServer drop rather than RouteBundle::deinit.

Changes

HTML Bundle Resource Deinitialization

Layer / File(s) Summary
HTML bundle drop refactoring with comment updates
src/runtime/bake/DevServer.rs, src/runtime/bake/dev_server/route_bundle.rs
DevServer::drop now explicitly dereferences the raw html.html_bundle field via bun_ptr::RefCount::<HTMLBundleRoute>::deref instead of StaticRoute::deref_, with inline and file-level comments updated to clarify that the intrusive refcount bump is paired with DevServer drop and split between Rust Drop handling and explicit DevServer::Drop logic.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description lacks the required template sections (What does this PR do? and How did you verify your code works?) and is provided as a single paragraph explanation. Reformat the description to follow the template structure with explicit 'What does this PR do?' and 'How did you verify your code works?' sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding html_bundle dereferencing to DevServer's Drop implementation.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

Actionable comments posted: 1

🤖 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`:
- Around line 1250-1253: Clear HTMLBundleRoute.dev_server_id before releasing
the intrusive reference: in the Drop/teardown path where you call unsafe {
bun_ptr::RefCount::<HTMLBundleRoute>::deref(html.html_bundle) } (the same
location referenced by get_or_put_route_bundle), set
html.html_bundle.dev_server_id = None (or the equivalent sentinel) immediately
prior to the deref so any external cache that calls get_or_put_route_bundle
cannot observe a stale bundle index; ensure the mutation happens on the
HTMLBundleRoute instance owned by html before the deref and mirror the same
change at the other referenced tear-down site (lines ~5383-5386).
🪄 Autofix (Beta)

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: e6f1a8a8-60dd-4cf6-8a0c-e4a40ed808aa

📥 Commits

Reviewing files that changed from the base of the PR and between a0e221e and 6728c23.

📒 Files selected for processing (2)
  • src/runtime/bake/DevServer.rs
  • src/runtime/bake/dev_server/route_bundle.rs

Comment on lines +1250 to +1253
// SAFETY: paired with the `ref_` taken in
// `get_or_put_route_bundle` when this bundle was created; the
// raw `html_bundle` field has no Drop, so release it here.
unsafe { bun_ptr::RefCount::<HTMLBundleRoute>::deref(html.html_bundle) };

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear HTMLBundleRoute.dev_server_id before dropping the intrusive ref.

get_or_put_route_bundle() trusts HTMLBundleRoute.dev_server_id before it touches self.route_bundles, but Drop only decrements the intrusive ref. If the HTMLBundleRoute survives this DevServer, that external cache can still point at a stale bundle index. Reset the back-reference before deref() so teardown leaves the surviving route object in a reusable state.

Suggested fix
             if let route_bundle::Data::Html(html) = &mut rb.data {
                 if let Some(cached) = html.cached_response.take() {
                     // SAFETY: stored ref from `init_from_any_blob`; no live borrow.
                     unsafe { StaticRoute::deref_(cached.as_ptr()) };
                 }
+                // SAFETY: `html_bundle` is still alive under this route bundle's intrusive ref.
+                unsafe { (*html.html_bundle).dev_server_id.set(None) };
                 // SAFETY: paired with the `ref_` taken in
                 // `get_or_put_route_bundle` when this bundle was created; the
                 // raw `html_bundle` field has no Drop, so release it here.
                 unsafe { bun_ptr::RefCount::<HTMLBundleRoute>::deref(html.html_bundle) };
             }

As per coding guidelines, "Store state on the object whose lifetime matches it" and "Update every lifecycle method (reset/init/drop/clone) when adding mutable state."

Also applies to: 5383-5386

🤖 Prompt for 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.

In `@src/runtime/bake/DevServer.rs` around lines 1250 - 1253, Clear
HTMLBundleRoute.dev_server_id before releasing the intrusive reference: in the
Drop/teardown path where you call unsafe {
bun_ptr::RefCount::<HTMLBundleRoute>::deref(html.html_bundle) } (the same
location referenced by get_or_put_route_bundle), set
html.html_bundle.dev_server_id = None (or the equivalent sentinel) immediately
prior to the deref so any external cache that calls get_or_put_route_bundle
cannot observe a stale bundle index; ensure the mutation happens on the
HTMLBundleRoute instance owned by html before the deref and mirror the same
change at the other referenced tear-down site (lines ~5383-5386).

Source: Coding guidelines

@alii

alii commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

Folded into #32215.

@alii alii closed this Jun 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Release Bun.serve handler Strongs once the server is idle (native↔JS cycle leak) #32086 - Also adds the missing RefCount::<HTMLBundleRoute>::deref(html.html_bundle) call in DevServer::Drop with the same approach
  2. Remove lifetime-erased and aliased-pointer patterns across install, router, ini, shell IO, and bake #31834 - Fixes the same leak by converting html_bundle to RefPtr<HTMLBundleRoute> (which handles the deref automatically), as part of a broader aliased-pointer cleanup

🤖 Generated with Claude Code

Comment on lines +167 to +172
// Zig `RouteBundle.deinit` equivalent, split across two mechanisms:
// - Drop: `Framework` StrongOptional fields (= .deinit()) and
// `Html.bundled_html_text` Box<[u8]> (= allocator.free()).
// - `DevServer`'s `Drop` (explicit): `client_bundle`, `Html.cached_response`
// (BackRef, no Drop) and `Html.html_bundle` (raw ptr, no Drop) each hold
// an intrusive ref that is deref'd there.

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.

🔴 This trailer now correctly documents that cached_response is (BackRef, no Drop) and must be explicitly deref'd — but two sites in DevServer.rs still rely on the old Arc-drop assumption and leak the StaticRoute ref on every hot reload. Site A (DevServer.rs:4578-4582, had_adjusted_edges path): if let Some(blob) = html.cached_response.take() { let _ = blob; } — let _ = blob on a Copy BackRef is a no-op, and the inline comment is the verbatim stale text this PR just deleted. Site B (DevServer.rs:4226-4231, HTML chunk re-emit): .cached_response.take().is_some() discards the BackRef, then invalidate_client_bundle sees None and skips its own deref branch. Both should call unsafe { StaticRoute::deref_(cached.as_ptr()) } like DevServer.rs:1248 / route_bundle.rs:113 — same bug class as the html_bundle leak this PR fixes.

Extended reasoning...

What the bug is

This PR rewrites the route_bundle.rs trailer comment to assert that Html.cached_response is a BackRef with no Drop, holding an intrusive ref that must be released explicitly in DevServer::Drop. That assertion is correct: BackRef<T> is Copy (src/ptr/lib.rs:179) and Copy types cannot impl Drop, so dropping a BackRef<StaticRoute> releases nothing. But two existing call sites in DevServer.rs were written under the old "Arc<StaticRoute> drop = .deref()" assumption that this PR just deleted, and they leak one StaticRoute per HTML route per hot-reload.

The two leak sites

Site A — DevServer.rs:4578-4582 (had_adjusted_edges hot-reload path):

route_bundle::Data::Html(html) => {
    if let Some(blob) = html.cached_response.take() {
        // Arc<StaticRoute> drop = .deref()
        let _ = blob;
    }
}

blob is BackRef<StaticRoute> (Copy); let _ = blob; is a no-op. Note the inline comment is verbatim the stale text this PR removes from route_bundle.rs:168 — a smoking gun that this site was written under the old Arc assumption and was missed in the sweep.

Site B — DevServer.rs:4226-4235 (HTML chunk re-emit):

if route_bundle.data.html_mut().cached_response.take().is_some() {
    route_bundle.invalidate_client_bundle(unsafe { &mut (*dev_ptr).source_maps });
}

.take().is_some() empties the slot and discards the BackRef. invalidate_client_bundle then re-checks html.cached_response.take() (route_bundle.rs:111) — now None — and skips its own StaticRoute::deref_ branch. The condition should be .is_some() without .take(), letting invalidate_client_bundle do the take+deref, or it should deref the taken value explicitly.

Why existing code doesn't prevent it

The slot is ref-owning. StaticRoute::init_from_any_blob "returns a fresh ref_count=1 box" (DevServer.rs:1825), and DevServer.rs:2846 stores that pointer directly into cached_response via BackRef::from(NonNull) — the +1 from init is the slot's ref. The two correct release sites confirm the contract: DevServer::Drop (line 1248) and invalidate_client_bundle (route_bundle.rs:113) both call unsafe { StaticRoute::deref_(cached.as_ptr()) }. Sites A and B bypass both: they .take() the value out of the slot (so Drop/invalidate_client_bundle find None) and then drop the Copy handle without calling deref_.

Step-by-step proof (Site A)

  1. User loads an HTML route. on_html_request calls init_from_any_blob → route_ptr with refcount=1, stored into html.cached_response = Some(BackRef(route_ptr)) (DevServer.rs:2835-2848).
  2. User edits a file; hot-reload runs with had_adjusted_edges = true.
  3. DevServer.rs:4579 executes html.cached_response.take() → returns Some(BackRef(route_ptr)), slot now None.
  4. let _ = blob; drops a Copy value — no destructor runs, refcount still 1.
  5. Next request repopulates cached_response with a new StaticRoute. The old one is unreachable with refcount=1 → leaked.
  6. Repeat per hot-reload. DevServer::Drop only derefs whatever is currently in the slot, not the N previously-leaked routes.

Site B is identical except step 3 happens at DevServer.rs:4226-4231 and step 4 is invalidate_client_bundle finding cached_response == None at route_bundle.rs:111.

Impact and fix

One StaticRoute (boxed HTML payload + headers) leaked per HTML route per hot-reload — exactly the same bug class (missed intrusive deref in the Zig→Rust port) as the html_bundle leak this PR fixes. Fix both sites with the same pattern already used at DevServer.rs:1248 and route_bundle.rs:113:

// Site A
if let Some(blob) = html.cached_response.take() {
    // SAFETY: stored ref from `init_from_any_blob`; no live borrow.
    unsafe { StaticRoute::deref_(blob.as_ptr()) };
}

// Site B — drop the .take(), let invalidate_client_bundle handle it
if route_bundle.data.html().cached_response.is_some() {
    route_bundle.invalidate_client_bundle(unsafe { &mut (*dev_ptr).source_maps });
}

These leaks pre-date this PR, but the PR is explicitly an intrusive-ref-leak sweep on RouteBundle, rewrites the doc comment to assert the exact invariant these sites violate, and leaves Site A's inline comment as the literal stale text it just removed elsewhere — so they belong in this PR.

Comment on lines +1250 to +1253
// SAFETY: paired with the `ref_` taken in
// `get_or_put_route_bundle` when this bundle was created; the
// raw `html_bundle` field has no Drop, so release it here.
unsafe { bun_ptr::RefCount::<HTMLBundleRoute>::deref(html.html_bundle) };

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.

🟡 nit: per CLAUDE.md ("Landing PRs" → tests), leak fixes are expected to ship with a regression test in the same PR — either an ASan repro that fails on the unfixed build or a refcount-baseline check. HTMLBundleRoute already has #[ref_count(debug_name = "HTMLBundleRoute")], so a test that constructs a DevServer with HTML routes, drops it, and asserts the tracked count returns to baseline should be feasible. Maintainer call on whether the harness cost is worth it for a one-line Drop fix, but the convention is explicit.

Extended reasoning...

What this is

This is a process/convention finding, not a code defect. The deref added in DevServer::Drop is correct and properly paired with the ref_ taken in get_or_put_route_bundle at DevServer.rs:5386. The issue is that the PR ships only the two .rs source files and no accompanying test.

Why it applies

CLAUDE.md:259 states verbatim:

Every behavioral change ships an automated test in the same PR... UAF/leak fixes need a repro that consistently fails under ASan on the unfixed build, or a leak regression test (Bun.gc(true) + heapStats objectTypeCounts filtered to the exact leaked type; RSS thresholds need ~2x headroom).

The section header at CLAUDE.md:247-249 notes "everything here has blocked merges". This PR is squarely in the named category — it fixes an intrusive-refcount leak (one HTMLBundleRoute per HTML route per dev-server lifetime, as the PR description itself states) — and the changed-files list contains only src/runtime/bake/DevServer.rs and src/runtime/bake/dev_server/route_bundle.rs.

Step-by-step: why a test is feasible here

  1. HTMLBundleRoute is declared with #[ref_count(debug_name = "HTMLBundleRoute")] (HTMLBundle.rs:149), which registers it with the intrusive-refcount tracker by name.
  2. A test can record the baseline live count for "HTMLBundleRoute", construct a DevServer with N HTML routes (which calls get_or_put_route_bundle → ref_ N times), then drop the DevServer.
  3. On the unfixed build, the tracked count after drop would sit at baseline + N because DevServer::Drop never released html.html_bundle. On the fixed build it returns to baseline.
  4. Alternatively, an ASan run of the same construct/drop sequence would report the leaked allocations on the unfixed build.

Note that HTMLBundleRoute is a native intrusive-refcounted type rather than a JSC heap object, so the literal heapStats objectTypeCounts path may not apply directly — the debug_name refcount tracker or the ASan-repro option from the same CLAUDE.md sentence is the more applicable mechanism.

Why this might reasonably be waived

One verifier objected that this is generic "missing test" review commentary rather than a code bug, and that standing up a DevServer-with-HTML-routes harness from JS test code is a significant undertaking relative to a one-line Drop fix. That's a fair point — the fix is obviously correct by inspection (the ref_/deref pair is now symmetric, and the updated comment at route_bundle.rs:167-172 documents the split-ownership model). Whether the harness cost is justified is a maintainer judgment call.

Suggested action

Either add a small regression test asserting the HTMLBundleRoute refcount returns to baseline after constructing+dropping a DevServer with HTML routes (or an ASan repro), or note in the PR why the test is being skipped for this change. Filed as nit since the fix itself is correct and the convention is process-level.

Comment on lines +167 to +172
// Zig `RouteBundle.deinit` equivalent, split across two mechanisms:
// - Drop: `Framework` StrongOptional fields (= .deinit()) and
// `Html.bundled_html_text` Box<[u8]> (= allocator.free()).
// - `DevServer`'s `Drop` (explicit): `client_bundle`, `Html.cached_response`
// (BackRef, no Drop) and `Html.html_bundle` (raw ptr, no Drop) each hold
// an intrusive ref that is deref'd there.

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.

🟡 While you're here updating the html_bundle lifecycle docs: the field comment at route_bundle.rs:39-41 still says HTMLBundleRoute "does not yet impl bun_ptr::RefCounted (gated server-side)" with a TODO to switch to RefPtr once it does. That's stale — Route has #[derive(bun_ptr::RefCounted)] (HTMLBundle.rs:148, no cfg gate), and the RefCount::<HTMLBundleRoute>::ref_/deref calls at DevServer.rs:5386/:1253 wouldn't compile otherwise. Pre-existing, but worth fixing alongside the cleanup-mechanism comment this PR rewrites 130 lines down for the same field.

Extended reasoning...

What's stale

route_bundle.rs:39-41 documents the Html.html_bundle field as:

Stored as raw ptr because HTMLBundleRoute does not yet impl bun_ptr::RefCounted (gated server-side).
// TODO: switch to bun_ptr::RefPtr once the RefCounted impl is real.

Both the stated reason and the TODO predicate are false today.

Proof that HTMLBundleRoute: RefCounted holds

Step through the types:

  1. HTMLBundleRoute is Route. src/runtime/server/HTMLBundle.rs:137: pub(crate) type HTMLBundleRoute = Route;.
  2. Route derives RefCounted. src/runtime/server/HTMLBundle.rs:148-149: #[derive(bun_ptr::RefCounted)] #[ref_count(debug_name = "HTMLBundleRoute")] pub struct Route { ... }. There is no #[cfg(...)] on the derive — it is not "gated server-side".
  3. RefCount<T> requires T: RefCounted. src/ptr/ref_count.rs:248: pub struct RefCount<T: RefCounted>; :258: impl<T: RefCounted> RefCount<T>.
  4. This PR (and the existing code) call RefCount::<HTMLBundleRoute> methods. Pre-existing DevServer.rs:5386 calls RefCount::<HTMLBundleRoute>::ref_(html); this PR adds DevServer.rs:1253 calling RefCount::<HTMLBundleRoute>::deref(html.html_bundle). By (3), neither would type-check unless HTMLBundleRoute: RefCounted.

So the comment's claim — that the impl "does not yet" exist / is gated — is provably contradicted by code that compiles in the same crate.

Why it's in scope for this PR

The staleness pre-dates this PR (the ref_ at :5386 already required the trait). But this PR is specifically a doc-correctness pass for html_bundle's lifecycle: it rewrites the cleanup-mechanism comment block at route_bundle.rs:167-172 to describe where html_bundle's ref is released, and updates the pairing comment at DevServer.rs:5380-5381 to point at the new Drop. The contradictory field doc sits 130 lines above the block this PR just rewrote, in the same file, describing the same field. Per the doc-sweep guidance (CLAUDE.md — "sweep the same PR for everything describing the old state... a stale refcount comment invites a future maintainer to restore unref()"), this is the natural place to fix it.

Impact

Doc-only. A future reader of :39-41 would conclude (a) HTMLBundleRoute lacks RefCounted and (b) the raw pointer is a temporary stopgap pending that impl — both wrong. Worst case, someone "completes" the TODO by swapping to RefPtr without checking whether RefPtr actually has a Drop (per ref_count.rs it does not), or assumes the ref/deref pairing here is provisional. The new :167-172 block already contradicts it ("raw ptr, no Drop ... deref'd there"), so the file is internally inconsistent after this PR.

Suggested fix

Replace the stale rationale with the real one, e.g.:

/// Stored as a raw ptr (not `RefPtr`) because `RefPtr` has no `Drop` impl
/// anyway; the intrusive ref taken in `get_or_put_route_bundle` is released
/// explicitly in `DevServer`'s `Drop` — see the cleanup note at the bottom
/// of this file.
pub html_bundle: *mut HTMLBundleRoute,

and drop the TODO.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants