Skip to content

Bun.serve: keep the server alive while an HTML route is building - #37813

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/c1e8ecf1/html-route-build-holds-server
Aug 13, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/c1e8ecf1/html-route-build-holds-server

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An HTML route served without the DevServer (development: false or { hmr: false }) bundles on its first request. If the only client disconnects and server.stop(true) is called while a [serve.static] plugin still has that build parked, stop() settles, the next GC frees the server, and the build then finishes against the freed server.
  • Debug build: AddressSanitizer: heap-use-after-free in html_bundle::Route::on_complete (parked in onLoad) or Route::on_plugins_resolved (parked in the plugin's setup()). A release build reads the freed NewServer with no report.
  • Cause: while a route is building, nothing counts as keeping the server alive. The clients waiting on the build only count as connections, so once they drop the server's idle check sees no pending work.
  • The other route kinds already count their asynchronous work in the server's pending-request counter; the HTML build was the one piece of in-flight work that did not.

Fix

  • Entering the building state now takes one pending request on the server; both ways out of it (build finished, plugin load rejected) answer the waiting clients and then release it.
  • This holds the server for exactly the window in which the route will call back into it. The release runs the server's idle pass, so a server stopped mid-build is freed right after the build lands.
  • Visible change: server.pendingRequests is 1 while an HTML route bundles and await server.stop() waits for the bundle, as it already does for a fetch handler still running. A build cancelled by VM teardown is not covered; at exit it leaves the same state an in-flight fetch handler does.
  • Verification: a new test parks the route in the build, in the plugin load, and in a plugin load that rejects. Unfixed debug build: all three report 0 pending requests and an early-settled stop(), and the first two die with the ASAN reports above. Fixed: all three pass, as do the existing HTML-serve tests that do not need the DevServer.

Background

  • HTML routes: Bun.serve({ routes: { "/": html } }) with an imported .html file. Without the DevServer the route bundles the page once, on the first request, registers the outputs as static routes, and holds requests that arrive during the build.
  • [serve.static] plugins: a bunfig entry naming bundler plugins for these routes, loaded on the first request. Both the plugin load and the bundle finish on later event-loop turns and complete by calling back into the server through a raw pointer stored on the route.
  • Pending requests: the server's count of in-flight work, exposed as server.pendingRequests. stop() settles and the server can be torn down only when the count is zero; static and file routes already raise it when a response goes asynchronous.
  • Server lifetime: stopping a server does not free it. Once nothing is pending, the JS wrapper becomes collectable and the native server is freed on a later GC, so a stale pointer to it only fails after a GC.
Original description

Repro

HTML route served without the DevServer (development: false or { hmr: false }), with a [serve.static] plugin whose onLoad parks on a promise. Request the route, drop the client, server.stop(true), drop the server, Bun.gc(true) plus a couple of event-loop turns, then let onLoad resolve. Debug (ASAN) build:

==1165==ERROR: AddressSanitizer: heap-use-after-free on address 0x73defa800738 ...
READ of size 8 at 0x73defa800738 thread T0
    #0 in <bun_runtime::server::NewServer<false, false>>::global_this src/runtime/server/mod.rs:451
    #1 in <bun_runtime::server::AnyServer>::global_this src/runtime/server/mod.rs:3847
    #2 in <bun_runtime::server::html_bundle::Route>::on_complete src/runtime/server/HTMLBundle.rs
    #3 in JSBundleCompletionTask::on_complete src/runtime/api/js_bundle_completion_task.rs:642
freed by thread T0 here:
    ...
    #11 in <bun_runtime::server::NewServer<false, false>>::deinit src/runtime/server/mod.rs:2122
    #12 in NewServer::schedule_deinit::{closure#1} src/runtime/server/mod.rs:1957

Parking in the plugin's setup() instead (so the route is still waiting for the plugin load when the server goes away) gives the same report one step earlier:

READ of size 1 ... in <bun_runtime::server::html_bundle::Route>::on_plugins_resolved src/runtime/server/HTMLBundle.rs
    #1 in <bun_runtime::server::server_body::ServePlugins>::handle_on_resolve src/runtime/server/server_body.rs:1150
    #2 in bun_runtime::server::server_body::on_resolve_impl

On a release build the same sequence reads a freed NewServer (its config, then append_static_route / reload_static_routes on it) without a report.

Cause

html_bundle::Route keeps a raw server back-pointer and bundles on its first request. Both the plugin load and the build finish on later event-loop turns and call back into the server through that pointer (on_plugins_resolved reads the config, on_complete registers the output files as static routes and reloads the route table). While the route is in State::Building, nothing holds the server on its behalf: on_plugins_resolved only refs the route itself, and the clients waiting in pending_responses only count as connections, which they can drop at any time. So once the last client disconnects and the server is stopped, deinit_if_we_can sees no pending requests, settles stop(), downgrades the wrapper, and the next GC frees the NewServer with the build still in flight.

StaticRoute / FileRoute / DirectoryRoute already handle their asynchronous work with the server's pending_requests counter (on_pending_request when a response goes async, on_static_request_complete when it finishes); the route's build is the same kind of in-flight work and was the one thing not counted.

Fix

schedule_bundle calls server.on_pending_request() whenever the route enters State::Building (plugins ready, or plugins still loading), and the two ways out of that state (on_complete, on_plugins_rejected) go through a new finish_building, which answers the pending responses and then calls on_request_complete(). That keeps the server allocated for exactly the window in which the route will call back into it, and on_request_complete runs the idle pass, so a server that was stopped while building is downgraded and freed right after the build lands (the stop() promise now settles then as well, matching what happens for a fetch handler that is still running when stop() is called). With that invariant, on_complete no longer needs its Option handling of the back-pointer; it takes the server once at the top, the same way on_plugins_resolved already did.

A visible consequence: server.pendingRequests is 1 while an HTML route is bundling, and await server.stop() waits for the bundle. A build whose plugin never settles therefore keeps the server allocated, as an unsettled fetch handler already does. Not covered: a build cancelled by VM teardown never reaches Route::on_complete (the completion task returns early on cancelled), so at exit the route keeps its ref and, now, its pending request; that is the same state an in-flight fetch handler leaves a server in at exit and nothing observes it. The DevServer's own plugin wait uses a different back-pointer and is not changed here.

Verification

test/js/bun/http/bun-serve-html-build-holds-server.test.ts (separate small file; bun-serve-html.test.ts is too slow under the debug ASAN build for a lifetime test, as bun-serve-html-hot-reload-drop.test.ts notes). One fixture, parked in turn in the build (onLoad), in the plugin load (setup()), and in a plugin load that then rejects (the on_plugins_rejected exit has to release the request too). Each child reports server.pendingRequests while parked, whether stop(true) settled across ten event-loop turns before the route was released, and whether the wrapper became collectable afterwards; the test expects { pendingRequestsWhileParked: 1, stopBeforeRelease: "pending", collectedAfterwards: true } plus a clean exit. If stop() did settle early, the fixture lets the server get collected before releasing the route, which is the sequence above.

Unfixed debug build: all three report pendingRequestsWhileParked: 0, stopBeforeRelease: "settled", and the first two children die with the ASAN reports above (the rejection case has no use-after-free to hit; it fails on the report). Fixed: the three pass in under a second each. Also run on the fixed debug build: bun-serve-html-405.test.ts, bun-serve-html-hot-reload-drop.test.ts, test/bake/serve-plugins-dev-server.test.ts (all pass), and bun-serve-html.test.ts, where everything that does not need the DevServer passes, including serve plugins > concurrent requests to multiple routes during plugin load; its development: true cases fail in this container with EMFILE while initializing file watcher for development server (inotify instance limit) before reaching any of this code.

An HTML route served without the DevServer bundles on its first request.
The plugin load and the build finish on later event-loop turns and call
back into the server through the route's raw back-pointer, but nothing
held the server for the route: the waiting clients only count as
connections. Once they disconnected, stop() settled, the wrapper became
collectable and the NewServer was freed, and the completion then used it
(heap-use-after-free in Route::on_complete / Route::on_plugins_resolved).

A route in State::Building now counts as a pending request on the server,
like an in-flight StaticRoute/FileRoute response does, and releases it
when it leaves that state (build complete or plugin load rejected).
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 6 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: 4dac2d3f-7328-4627-8fec-ca121984cf05

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and f908be0.

📒 Files selected for processing (2)
  • src/runtime/server/HTMLBundle.rs
  • test/js/bun/http/bun-serve-html-build-holds-server.test.ts

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.

I reviewed this PR and didn't find any bugs. Because it changes Bun.serve lifecycle accounting (a UAF fix that also alters when server.stop() settles and what pendingRequests reports), a human look would still be worthwhile.

What was reviewed:

  • Traced on_pending_request/on_request_complete balance across all four schedule_bundle outcomes (Err, Ready, Pending→resolved, Pending→rejected) — each path pairs exactly once.
  • Confirmed on_plugins_rejected/on_plugins_resolved are only reached via server_body.rs after the Pending arm took its ref, and the dev-mode state reset only touches Html/Err, so Building is never dropped without finish_building.
  • Checked that removing the Option guards in on_complete is safe: self.server is always Some once schedule_bundle runs, and finish_building is the last server access before deinit_if_we_can.
  • resume_pending_responses visibility narrowed to fn — no external callers remain.
Extended reasoning...

Overview

This PR fixes a heap-use-after-free in Bun.serve's non-DevServer HTML route path. html_bundle::Route holds a raw AnyServer back-pointer and bundles lazily; the plugin load and build completion call back through that pointer on later event-loop turns, but nothing kept the server alive on the route's behalf. The fix: entering State::Building bumps the server's pending_requests counter (via on_pending_request()), and both exits from that state (on_complete, on_plugins_rejected) release it via a new finish_building(). This matches how StaticRoute/FileRoute already account for their async work. A three-variant subprocess test asserts the observable contract (pendingRequests == 1 while parked, stop(true) stays pending, wrapper collectable afterwards) and reproduces the ASAN crash on the unfixed build.

Security risks

None. This is an internal lifecycle-accounting fix; no new user-controlled input is parsed and no trust boundaries change.

Level of scrutiny

High. This is memory-safety code in the HTTP server's deinit path — exactly the "most-blocked category" REVIEW.md calls out. The counter must be provably balanced on every terminal path or the server either UAFs (under-count, the bug being fixed) or leaks / never settles stop() (over-count). I traced every entry into and exit from State::Building:

  • GetOrStartLoadResult::Err — no ref taken, no finish_building; state goes to Err and the current request handles it synchronously.
  • Ready — ref taken, on_plugins_resolved sets Building(Some), later on_complete → finish_building. The ? on on_plugins_resolved only propagates OOM (caller is handle_oom), which aborts.
  • Pending — ref taken, Building(None); server_body.rs later invokes exactly one of on_plugins_resolved (→ Building(Some) → on_complete → finish_building) or on_plugins_rejected (→ finish_building).
  • The dev-mode-without-HMR reset in on_any_request only replaces Html/Err, never Building, so a build in flight cannot lose its ref.
  • Route::drop while Building cannot happen: the route is intrusively ref'd by the completion task (Building(Some)) or by ServePlugins' stored callback (Building(None)).

The removal of the if let Some(server) / else { return } guards in on_complete is justified by the new invariant (the pending-request hold guarantees the server is still allocated), and self.server is never cleared once set. finish_building calls on_request_complete last, after resume_pending_responses, so the server's deinit_if_we_can cannot schedule teardown before the pending clients have been answered.

Other factors

The PR carries a user-visible behavior change the author calls out: server.pendingRequests now reads 1 during an HTML build, and await server.stop() waits for that build. That is consistent with how in-flight fetch handlers already behave, but it is a semantics change a maintainer should sign off on. The author also explicitly scopes out the VM-teardown-cancels-build case, arguing it matches existing fetch-handler-at-exit behavior; that argument reads correctly to me but is another judgment call. The test is well-constructed (fails on the unfixed build per the description, covers all three exit paths, uses test.concurrent, hermetic subprocess), so the mechanical correctness is well-covered — the human review is for the design/semantics call, not for bug-hunting.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:37 AM PT - Aug 12th, 2026

✅ @robobun, your commit f908be079c293b9ee9230e80546641a7180c55b0 passed in Build #93125! 🎉


🧪   To try this PR locally:

bunx bun-pr 37813

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

bun-37813 --bun

@Jarred-Sumner
Jarred-Sumner merged commit 8a1cd8d into main Aug 13, 2026
41 of 42 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/c1e8ecf1/html-route-build-holds-server branch August 13, 2026 00:58
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.

2 participants