Skip to content

hot: stop Bun.serve instances the new generation did not re-adopt - #32957

Open
robobun wants to merge 3 commits into
mainfrom
farm/9c37fecb/fix-hot-orphaned-server
Open

hot: stop Bun.serve instances the new generation did not re-adopt#32957
robobun wants to merge 3 commits into
mainfrom
farm/9c37fecb/fix-hot-orphaned-server

Conversation

@robobun

@robobun robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What

Under --hot, a Bun.serve() from a previous module generation is never shut down when the reloaded code no longer calls Bun.serve (or moves to a different host/port). The orphaned listener keeps serving the old generation's fetch handler indefinitely, and nothing else can ever bind the port. There is also no handle to close it from the new generation.

This contradicts docs/runtime/watch-mode.mdx ("your HTTP server will be reloaded with the updated code without the process being restarted"): when the updated code has no server at all, one keeps running with the old handler.

Reproduction

D=$(mktemp -d); cd "$D"
cat > s.ts <<'EOF'
declare global { var n: number }
globalThis.n = (globalThis.n ?? 0) + 1;
const g = globalThis.n;
const server = Bun.serve({ port: 0, fetch() { return new Response("gen " + g) } });
console.log("up gen", g, "port", server.port);
EOF
bun --hot s.ts & sleep 1
# note the port P printed above
curl -s localhost:$P        # => "gen 1"
cat > s.ts <<'EOF'
console.log("NO SERVER IN THIS VERSION OF THE SOURCE");
setInterval(() => {}, 1e9);
EOF
sleep 1
curl -s localhost:$P        # still "gen 1" -- nothing should be listening

After the reload the source on disk has no Bun.serve, but the port still answers with gen 1.

Cause

Each Bun.serve under --hot registers in the per-VM HotMap keyed by its computed host:port id. When the next generation calls Bun.serve with a matching id, the entry is found and the existing server is adopted (handlers swapped in place). That is the only path that touches the entry. If the new generation never calls Bun.serve again, or binds a different address (a different id), nothing removes or stops the old server. VirtualMachine::reload() has no symmetric teardown for it.

Fix

  • HotMapEntry gains a generation: u32 stamped from vm.hot_reload_counter whenever an entry is inserted or adopted by Bun.serve.
  • Once the reloaded entry point's internal promise fulfills, sweep the map and gracefully stop (same semantics as server.stop(): close the listener, let in-flight requests drain) every server whose stamp predates the current counter.
  • The sweep runs only under --hot, at most once per generation, and is skipped when the reloaded module throws, so the last good generation keeps serving. That matches the existing error-recovery behavior.

The sweep body lives in bun_runtime and is reached from bun_jsc through a new RuntimeHooks::hot_stop_orphaned_servers slot, the same forward-dep pattern cron_clear_all_reload already uses on this code path.

Verification

Four new tests in test/cli/hot/hot.test.ts under --hot Bun.serve orphaned server:

  • the new generation has no Bun.serve: the old port stops answering
  • the new generation's server has a different id: the old port stops answering while the new one keeps serving
  • the new generation adopts the same server: it keeps serving across repeated reloads (the sweep must leave it alone)
  • the new generation throws during load: the previous server keeps running

The first two fail on the unfixed build (the old port keeps answering gen 1); all four pass with the fix. The last two also pass without the fix and guard against the sweep being over-eager.

The probes send Connection: close so each opens a fresh TCP connection. The sweep closes the listener, not sockets that are already established, and a pooled keep-alive socket from an earlier fetch would otherwise still get answers.

Note: this is specific to Bun.serve. Bun.listen never had HotMap wiring at all; that is #30906, which is the inverse problem (the old listener is never found, so the new one gets EADDRINUSE).

Behavior notes for review

These are the deliberate semantic edges of the sweep, for the lifecycle sign-off:

  • "Finished evaluating" is the entry-point internal promise fulfilling. Top-level await before Bun.serve is covered (the promise stays pending until it resolves). A Bun.serve deferred past module evaluation (for example inside a setTimeout) now gets a fresh server instead of adopting the previous generation's, because the sweep already stopped it. That matches the report's framing ("after the new generation finishes evaluating") and the server still ends up listening; I don't think anyone is relying on adopting a listener from a timer callback, but it is a behavior change for that pattern.
  • The stop is graceful, so it has exactly server.stop()'s semantics. The listener closes (no new connections can reach the old handler), in-flight requests drain, and idle keep-alive sockets are reaped by the server's idle timeout. Full teardown happens once GC finalizes the now-unreferenced JS Server object, the same lifecycle as calling server.stop() and dropping the reference.
  • Only servers that opted into hot reuse are swept. allow_hot: false servers never enter the HotMap, so they are invisible to both the existing adoption path and this sweep; their (pre-existing) behavior is unchanged. The sweep covers exactly the set of servers the adoption path covers.

Under --hot, each Bun.serve registers in the per-VM HotMap keyed by its
computed host:port id. On soft reload, a Bun.serve call with a matching
id adopts the existing server (swapping its handlers in place), but if
the reloaded module never calls Bun.serve again, or binds a different
address, nothing ever stops the previous generation's server. Its
listener stays bound and keeps invoking the old generation's fetch
closure indefinitely, and the port can never be reclaimed.

Stamp every HotMap entry with the hot_reload_counter at the time it is
inserted or adopted. Once the reloaded entry point's internal promise
fulfills, sweep the map and gracefully stop (same semantics as
server.stop()) every server whose stamp predates the current counter.
The sweep is skipped when the reload throws, so the last good generation
keeps serving, matching the existing error-recovery behavior.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

More reviews will be available in 6 minutes and 40 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1051a6a4-c585-4146-a8bb-378ee89b1c17

📥 Commits

Reviewing files that changed from the base of the PR and between 0f9331d and 25c574a.

📒 Files selected for processing (5)
  • src/jsc/VirtualMachine.rs
  • src/jsc/rare_data.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/jsc_hooks.rs
  • test/cli/hot/hot.test.ts

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

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:53 AM PT - Jun 28th, 2026

@robobun, your commit 25c574a has 2 failures in Build #66050 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32957

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

bun-32957 --bun

Comment thread test/cli/hot/hot.test.ts Outdated
/^gen [2-9]\d*$/ rejected gen 10-19, gen 100-199, etc., contradicting
the "any generation past 1" comment and the waitFor predicate above it.

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

Thanks for fixing the regex — no further issues from me. This changes user-visible --hot lifecycle semantics for Bun.serve (stopping servers the new generation didn't re-adopt), so I'll leave the design sign-off to a human.

Extended reasoning...

Overview

This PR adds an orphan-sweep step to --hot reloads: HotMapEntry gains a generation stamp, Bun.serve stamps it on insert/adopt, and after the reloaded entry-point promise fulfills, hot_stop_orphaned_servers (wired through a new RuntimeHooks slot) gracefully stops any Bun.serve instance whose stamp predates the current hot_reload_counter. Touches src/jsc/VirtualMachine.rs, src/jsc/rare_data.rs, src/runtime/api/BunObject.rs, src/runtime/jsc_hooks.rs, plus four new tests in test/cli/hot/hot.test.ts.

Prior feedback

My earlier inline comment about /^gen [2-9]\d*$/ rejecting gen 10..19 was addressed in 3101795 — the test now uses /^gen ([2-9]|[1-9]\d+)$/, which correctly matches any integer ≥ 2. The bug-hunting pass on the updated revision found nothing.

Security risks

None identified. The change is gated on hot_reload == HOT_RELOAD_HOT, only narrows what stays listening (it stops servers, never starts or exposes anything), and reuses the existing server.stop(false) graceful-stop path.

Level of scrutiny

Medium-high. The implementation is small and follows the established RuntimeHooks / cron_clear_all_reload pattern, and the four tests cover adopt / no-adopt / different-id / throw-during-load. But it is a deliberate change to --hot's observable lifecycle: servers that previously kept running will now be stopped. That's a product/semantics decision (e.g., interaction with servers created asynchronously after the entry-point promise fulfills, or with allow_hot/reusePort edge cases) that a maintainer should sign off on rather than a bot.

Other factors

The sweep snapshots stale entries before iterating (avoiding iterator invalidation when stop removes its own key), runs at most once per generation via hot_reload_orphan_swept_at, and is skipped on rejection so the last good generation keeps serving — all of which look correct. I'm deferring purely on scope, not on any concrete concern with the code.

Re-run CI. Build 65927 had no real failures: the linux x64-asan build-rust,
freebsd x64 build-rust, and linux x64-baseline build-bun jobs expired in the
agent queue without running, and the same steps passed on every lane that got
an agent.
@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Update now that build 66050 has finished: 240 jobs passed, and test/cli/hot/hot.test.ts passed on every lane that ran it (it appears in none of the failure annotations, and all 20 ASAN test-bun lanes are green). Every remaining red is external infrastructure. The diff is ready to review.

Full accounting for build 66050:

  • 240 passed, including all 20 ASAN test lanes.
  • 1 expired: linux aarch64 build-cpp was never assigned a Buildkite agent within the scheduled timeout, so it never ran, and the 42 linux-aarch64 lanes downstream of it went waiting_failed. This diff does not touch C++.
  • 3 failed, fully explained by the only 2 failure annotations, both external:
    • test/js/bun/test/parallel/test-docker-build-debian.ts (both darwin aarch64 lanes): Docker Hub rate-limited the unauthenticated debian:trixie-slim pull. Verbatim: 429 Too Many Requests ... You have reached your unauthenticated pull rate limit.
    • test/js/sql/sql-mysql.auth.test.ts (alpine 3.23 x64-baseline): the MySQL service container never passed its health check. Verbatim: Failed to start service mysql_native_password ... application not healthy after 1m0s. This diff does not touch src/sql/.
  • The pipeline's own flaky annotation lists 4 Windows tests that failed once and passed on retry (update_interactive_install, the bun-server idle-websocket CPU test, bun-install --cwd, napi). None is related.

The previous build, 65927 (3101795), was red for the same class of reason: three different jobs (linux x64-asan build-rust, freebsd x64 build-rust, linux x64-baseline build-bun) expired in the agent queue without running. build-rust passed on every lane that got an agent.

Verification, against the local ASAN debug build:

  • The full test/cli/hot/hot.test.ts passes, 16/16.
  • The two new orphan tests fail on the unfixed build (USE_SYSTEM_BUN=1: the old port keeps answering gen 1) and pass with the fix. The two guard tests (an adopted server keeps serving across repeated reloads; a throwing reload leaves the last good server running) pass on both, by design.
  • Adjacent suites are green under the same build: HTTPServerAgent.test.ts, bun-serve-routes.test.ts, bun-serve-static.test.ts, in-process-cron.test.ts.
  • On the PR, cargo clippy, Format, and Lint JavaScript are green at head (25c574a).

I already pushed one empty ci: retrigger (25c574a) for the 65927 expirations and would rather not keep stacking them. If the expired and rate-limited lanes need to be green for merge, rebuilding them from the Buildkite UI should be enough. My API token is read-only (403: missing write_builds), so I cannot do it myself.

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