Skip to content

hot: reuse Bun.listen/Bun.serve listeners across --hot reloads - #30906

Open
robobun wants to merge 3 commits into
mainfrom
farm/b730f384/hot-reload-bun-listen
Open

hot: reuse Bun.listen/Bun.serve listeners across --hot reloads#30906
robobun wants to merge 3 commits into
mainfrom
farm/b730f384/hot-reload-bun-listen

Conversation

@robobun

@robobun robobun commented May 17, 2026

Copy link
Copy Markdown
Collaborator

What

Under --hot, re-evaluating the entry module re-runs Bun.listen() on an address that is still bound by the previous evaluation's listener, failing with EADDRINUSE:

error: Failed to listen at 127.0.0.1
 syscall: "listen",
   errno: 98,
 address: "127.0.0.1",
    port: 22345,
    code: "EADDRINUSE"

Bun.serve avoids this via the per-VM HotMap: each server registers under a computed key, and a subsequent Bun.serve() with the same address swaps handlers on the live server instead of re-binding. Bun.listen had no HotMap wiring at all.

(Two adjacent bugs this PR originally also fixed, VirtualMachine::hot_map() never lazy-initializing and Bun.serve inserting under the empty key because config.id was read after NewServer::init() took the config, have since landed on main via #31783. This PR now contains only the Bun.listen work plus hardening of the shared registry.)

Fix

Bun.listen mirrors Bun.serve:

  • Compute a [tcp]-tcp:host:port / [tls]-... / [tcp]-unix:path key from the requested address, look it up before binding, and on a match swap handlers/data in place on the existing listener (same swap as listener.reload()), returning the existing JS wrapper.
  • Insert on first bind; remove on stop()/deinit(). Non-TLS listen fds are also registered for --watch cleanup, matching the HTTP server path.
  • An id?: string | null option (matching Bun.serve) disambiguates multiple listeners on the same address; id: null/"" opts out. User-supplied ids are namespaced with a [listen]- prefix so listener keys are structurally disjoint from Bun.serve's. A listen and a serve can never reuse each other (different tags), so sharing a key would only leave whichever registers second untracked.
  • HotMap::insert_raw now returns bool instead of panicking on a duplicate key. Its only two callers (Bun.serve and the new listener registration) skip registration on false; Bun.serve additionally flips allow_hot so its stop() does not evict the entry that is there. This covers the contrived Bun.serve({id: "[listen]-..."}) overlap without a second lookup.
  • The reuse path only returns the existing wrapper when its JsRef is strong (a registered listener holds it strong while listening); anything else closes the stale socket and rebinds rather than handing back a possibly dead weak value.
  • Types for id added to TCPSocketListenOptions and a new UnixSocketListenOptions.

Verification

New describe("should reuse the listening socket on hot reload") in test/cli/hot/hot.test.ts spawns bun --hot with a Bun.listen/Bun.serve fixture on port: 0, drives three reloads, and asserts the same resolved port is reported every time and a client connect succeeds after each reload. On the unfixed build each reload binds a different random port, so the port-stability assertion fails; with the fix all three generations reuse one socket.

Also checked manually under --hot: listen+serve sharing a colliding key in both orders (listener reused, server untracked and rebound, server.id intact, no panic), id: null/"" opt-out, distinct-id isolation, stop()-then-relisten, and unref()-then-reload (same port reused).

Fixes #26036.

Rebase notes

Rebased onto main after roughly 1100 commits landed; squashed to one commit. Three textual conflicts, all resolved by keeping both sides:

  • Listener::listen named-pipe path: main added active_handles registration next to where this PR adds register_for_hot_reload.
  • Listener::deinit: main switched this_ref to a shared borrow; unregister_for_hot_reload takes &Self so it slots in unchanged.
  • hot.test.ts imports: main added isWindows, this PR adds tempDir.

Main also changed Listener::unref() to keep this_value strong while the socket is still listening (it is downgraded in do_stop/mark_inactive instead). That makes the non-strong fallback in the reuse path purely defensive. Full hot.test.ts passes after the rebase.


no test proof · iteration 18 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/hot/hot.test.ts

@robobun

robobun commented May 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:02 PM PT - Aug 15th, 2026

@robobun, your commit 6e137cb6b08f975c7112d4dabcb571618eeff8e1 passed in Build #99059! 🎉


🧪   To try this PR locally:

bunx bun-pr 30906

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

bun-30906 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Hotreload Hono server, Crash. and restart hotreload, Crash #19143 - Hono server crashes on --hot reload; the HotMap was never initialized so servers weren't reused across reloads, causing dangling references and segfaults

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

Fixes #19143

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Detect and reuse existing listening sockets on hot reload: lazily-initialize hot-map, compute per-listen hot_id, register/unregister listeners, swap handlers for matched listeners preserving active connections, register servers under internal config id, add listen id types, and add regression tests.

Changes

Hot-reload socket reuse

Layer / File(s) Summary
Hot-reload map lazy initialization
src/jsc/VirtualMachine.rs
VirtualMachine::hot_map now calls the lazy-initializing accessor to ensure the hot-reload registry is populated on first access.
Listener identity and tag
src/runtime/socket/Listener.rs
Adds HOT_MAP_TAG_LISTENER and a pub hot_id: JsCell<Box<[u8]>> field to Listener to namespace and track listener registrations.
Listener reuse and hot_id computation
src/runtime/socket/Listener.rs
Listener::listen computes a stable hot_id (compute_hot_id), looks up VirtualMachine::hot_map for a matching listener, and when found swaps handlers and preserves active_connections, returning the existing JS wrapper.
Listener registration in allocation paths
src/runtime/socket/Listener.rs
Named-pipe and non-pipe allocation paths initialize hot_id and register the listener after JS wrapper creation; non-pipe path updates watch-mode fd tracking for non-TLS listeners.
Listener lifecycle helpers and cleanup
src/runtime/socket/Listener.rs
Adds register_for_hot_reload/unregister_for_hot_reload, modifies do_stop/finalize/deinit to unregister hot state and clean watch-mode fds to avoid stale entries and EADDRINUSE on restart.
Server hot-reload registration with config ownership
src/runtime/api/BunObject.rs
Bun.serve updates HotMap insertion to use server_ref.config.allow_hot and server_ref.config.id after init transfers ownership, registering the server under its internal config id and avoiding duplicate-key panics.
Types and hot-reload regression test
packages/bun-types/bun.d.ts, test/cli/hot/hot.test.ts
Adds optional `id?: string
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully addresses issue #26036 by fixing the three root causes (lazy-init, config handling, listener wiring) and enabling proper listener reuse/handler swapping to prevent EADDRINUSE errors.
Out of Scope Changes check ✅ Passed All changes are directly in scope: VirtualMachine hot_map initialization, Bun.serve hot-map insertion logic, Bun.listen hot-map wiring, TypeScript types for the new id field, and comprehensive test coverage for hot reload.
Title check ✅ Passed The title clearly and concisely describes the main change: reusing Bun.listen and Bun.serve listeners across --hot reloads.
Description check ✅ Passed The description explains the problem, implementation, verification steps, issue reference, and rebase notes with sufficient detail.

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

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/socket/Listener.rs (1)

878-901: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the watch-mode fd on the finalize path as well.

listen() registers non-TLS sockets with add_listening_socket_for_watch_mode(), but this cleanup path only removes the hot-map entry. If an unref'ed listener is GC-finalized instead of stop()'d, the socket gets closed while its fd stays in the VM watch registry, which can later target an unrelated descriptor once the OS reuses that number. Route finalize() through the same fd-unregister helper as do_stop() before self.listener is cleared.

Also applies to: 966-969

🤖 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/socket/Listener.rs` around lines 878 - 901, In finalize(), before
clearing self.listener (before calling self.listener.replace(...)) call the same
fd-unregister helper used by do_stop() (e.g.
remove_listening_socket_for_watch_mode or the project's equivalent) to remove
the watch-mode fd registration for the socket, then proceed with
unlink_unix_socket_path and closing; ensure the unregister call happens while
you still have the socket/fd (i.e. before ListenerType::Uws is
replaced/consumed). Apply the same change to the other finalize path at the
referenced location (lines 966-969) so GC-finalized listeners also unregister
from the VM watch registry.
🤖 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 `@test/cli/hot/hot.test.ts`:
- Around line 851-862: The background stderr reader promise (stderrDone) can
reject when runner.kill() aborts runner.stderr; modify the IIFE invocation that
assigns stderrDone so the promise is swallowed by appending .catch(() => {}) to
the IIFE (same pattern used in other hot-reload stream readers), keeping the
existing for-await-of over runner.stderr and the EADDRINUSE/Failed-to checks but
preventing an unhandled rejection after assertions pass.

---

Outside diff comments:
In `@src/runtime/socket/Listener.rs`:
- Around line 878-901: In finalize(), before clearing self.listener (before
calling self.listener.replace(...)) call the same fd-unregister helper used by
do_stop() (e.g. remove_listening_socket_for_watch_mode or the project's
equivalent) to remove the watch-mode fd registration for the socket, then
proceed with unlink_unix_socket_path and closing; ensure the unregister call
happens while you still have the socket/fd (i.e. before ListenerType::Uws is
replaced/consumed). Apply the same change to the other finalize path at the
referenced location (lines 966-969) so GC-finalized listeners also unregister
from the VM watch registry.
🪄 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: 1bb1f6f8-8bd6-4c2d-bcea-1db4464d3fc1

📥 Commits

Reviewing files that changed from the base of the PR and between e750984 and 954e4ff.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/socket/Listener.rs
  • test/cli/hot/hot.test.ts

Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread test/cli/hot/hot.test.ts Outdated
@robobun
robobun requested a review from alii as a code owner May 17, 2026 06:01

@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: 4

🤖 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 `@packages/bun-types/bun.d.ts`:
- Around line 6477-6488: The id property is currently declared on
UnixSocketOptions<Data> and thus appears on both Bun.connect() and Bun.listen();
update the typings so id is only present on the listen overload: split
UnixSocketOptions<Data> into separate UnixConnectOptions<Data> and
UnixListenOptions<Data> (or make id optional only on the listen overload) and
use UnixConnectOptions<Data> for Bun.connect() and UnixListenOptions<Data> for
Bun.listen(); adjust the type references where UnixSocketOptions<Data> is used
(including any overload signatures) to ensure Bun.connect() no longer exposes id
while Bun.listen() retains it.

In `@src/runtime/socket/Listener.rs`:
- Around line 225-229: The user-provided id is used verbatim which can collide
with Bun.serve keys; update the Some(id) branch in Listener.rs (where hot_id is
set using opts.get and compute_hot_id) to namespace IDs: convert
id.to_slice(...) to a string, detect whether it already starts with the listener
prefix ("[tls]-" when ssl_enabled true or "[tcp]-" when false), and if missing
prepend the correct prefix, then convert back into a Box<[u8]> for hot_id; keep
the existing None/null handling and reuse compute_hot_id only for the default
case.

In `@test/cli/hot/hot.test.ts`:
- Around line 782-802: The test currently resolves the Promise inside the
Bun.connect socket.data callback on the first chunk which is flaky because TCP
can split "v1"/"v2" across chunks; change the client logic created via
Promise.withResolvers and Bun.connect so socket.data appends incoming chunks to
a buffer string and only resolves when the stream ends (socket end/close) or
when the accumulated buffer matches the full expected token (e.g., starts with
"v" followed by digits), and keep socket.error / socket.connectError rejecting
as-is; also ensure you close the socket after resolving (sock.end) as before.
🪄 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: a60d8a76-1120-4de3-9ffd-340e687bd19c

📥 Commits

Reviewing files that changed from the base of the PR and between 954e4ff and f32a938.

📒 Files selected for processing (3)
  • packages/bun-types/bun.d.ts
  • src/runtime/socket/Listener.rs
  • test/cli/hot/hot.test.ts

Comment thread packages/bun-types/bun.d.ts
Comment thread src/runtime/socket/Listener.rs
Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread packages/bun-types/bun.d.ts
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/api/BunObject.rs Outdated
@robobun

robobun commented May 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Head is 6e137cb, rebased onto main a42889a (squashed; rebase notes are in the PR description). All review threads resolved.

Since the last status

  • HotMap::insert_raw now returns bool instead of panicking on a duplicate key, replacing the lookup-then-insert guards on both the Bun.serve and Bun.listen registration paths.
  • Comments in src/ trimmed to single lines.
  • Test: the client socket rejects on close so a premature FIN fails fast instead of timing out.

Reproduction

bun bd test test/cli/hot/hot.test.ts -t "should reuse the listening socket"

Fails with EADDRINUSE on released bun; passes with this branch for both Bun.listen and Bun.serve. Full hot.test.ts (14 tests) passes locally on the rebased build.

Comment thread test/cli/hot/hot.test.ts
@robobun
robobun force-pushed the farm/b730f384/hot-reload-bun-listen branch from f1153ba to b7b996a Compare May 21, 2026 05:52
@robobun
robobun force-pushed the farm/b730f384/hot-reload-bun-listen branch 2 times, most recently from 6719d39 to 0c8e0ca Compare June 5, 2026 17:36
@robobun
robobun force-pushed the farm/b730f384/hot-reload-bun-listen branch from 0c8e0ca to 1389d3c Compare July 9, 2026 05:06
Comment thread src/runtime/socket/Listener.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.

No new findings on a735408 — the is_strong() gate addresses the last concern. Deferring to a human for the new public id API and the native listener/hot-map lifecycle wiring.

Extended reasoning...

Overview

This PR wires Bun.listen into the per-VM HotMap so --hot reloads reuse the existing listening socket instead of failing EADDRINUSE. It touches:

  • src/runtime/socket/Listener.rs (~200 lines): new hot_id field, compute_hot_id, hot-map lookup with unsafe { &*entry.ptr.cast::<Listener>() }, handler swap via a new copy_callbacks_from, register/unregister_for_hot_reload, and do_stop/deinit/watch-mode-fd cleanup wiring.
  • src/runtime/socket/{Handlers.rs, JSSocketHandlers.rs}: new copy_callbacks_from / callbacks() helpers.
  • src/runtime/api/BunObject.rs: guards Bun.serve's hot.insert_raw against duplicate keys and flips allow_hot = false on skip.
  • packages/bun-types/bun.d.ts: new public id?: string | null on TCPSocketListenOptions and a new UnixSocketListenOptions.
  • test/cli/hot/hot.test.ts: new describe block driving three --hot reloads for both Bun.listen and Bun.serve.

Security risks

No auth/crypto/permissions surface. The main risk class is memory safety: raw-pointer deref from a shared registry, and returning a JS wrapper whose liveness depends on JsRef state. The latter was flagged and fixed in a735408 (gate on is_strong() so a downgraded/possibly-GC-dead Weak wrapper is never returned). The unsafe cast is guarded by a tag check and by unregister_for_hot_reload running in both do_stop and deinit before the allocation is freed.

Level of scrutiny

High. This is native runtime lifecycle code with GC interaction, adds public API surface (id option), and went through 14 iterations with several correctness fixes during review — including a design reversal (verbatim user ids → [listen]- namespaced) and a memory-safety fix in the final commit. That churn is exactly why a human should sign off on the settled shape.

Other factors

All 20 prior review threads (mine and CodeRabbit's) are resolved. The bug-hunting system found nothing on the current head. The test gates the headline fix (same port across reloads) but, as acknowledged in the resolved thread on line 896, the responses assertion doesn't strictly prove handler-swap vs. shared-global read — the author accepted this as a follow-up. One acknowledged-and-deferred limitation remains (TLS toggle with a pinned id keeps the old protocol, matching Bun.serve's existing behavior). None of these block, but they're worth a human being aware of.

@robobun
robobun force-pushed the farm/b730f384/hot-reload-bun-listen branch from a735408 to ea28168 Compare August 16, 2026 02:53
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/socket/Handlers.rs Outdated
Comment thread src/runtime/socket/JSSocketHandlers.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/socket/Handlers.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread test/cli/hot/hot.test.ts

@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 the automated bug-hunting pass found no issues on 6e137cb. Because it adds user-facing API (id on Bun.listen) and threads a raw *mut Listener through the shared HotMap with GC-lifetime reasoning around JsRef strong/weak states, a human look is still worthwhile.

What was reviewed:

  • The is_strong() gate on the reuse path — a downgraded/weak this_value falls through to do_stop + rebind rather than returning a possibly-dead wrapper.
  • insert_raw now returns false on collision; both callers skip registration and Bun.serve flips allow_hot = false so its stop() won't evict the foreign entry.
  • unregister_for_hot_reload runs on both do_stop and deinit before the allocation is freed, and hot_id is mem::taken so a second call is a no-op.
  • The new test drives reloads via stdout events (no sleeps), buffers to newline framing, and wires close/error/connectError to reject.
Extended reasoning...

Overview

Wires Bun.listen into the per-VM HotMap so --hot re-evaluations reuse the bound socket instead of failing EADDRINUSE, mirroring what Bun.serve already does. Touches rare_data.rs (insert_raw panic → bool), BunObject.rs (serve's collision handling), Listener.rs (~130 new lines: key computation, lookup + handler swap, register/unregister, watch-mode fd tracking), Handlers.rs/JSSocketHandlers.rs (a copy_callbacks_from helper and its callbacks() reader), bun.d.ts (new id?: string | null option + UnixSocketListenOptions), and a new describe block in hot.test.ts.

Security risks

None identified. The hot-map is per-VM and only populated under --hot; keys are derived from the caller's own listen options; the tag check gates the pointer cast. No auth/crypto/permission surface.

Level of scrutiny

High. This is not a mechanical change: it introduces a new raw-pointer side channel (HotMapEntry.ptr&Listener) whose lifetime is decoupled from the JS wrapper's GC lifetime, and gets that right only via the is_strong() gate plus unregister_for_hot_reload running before every free path. A prior revision of this exact PR had a real GC-window bug here (JsRef::Weak returning a dead value), fixed after review. It also adds public API (id on TCPSocketListenOptions/UnixSocketListenOptions) that a maintainer should sign off on.

Other factors

The PR has been through many review rounds; every inline thread is resolved and the head commit addresses the last nit (missing close reject in the test's Bun.connect client). The bug hunter found nothing on the current head. The comment-cop bot fired ~15 identical warnings on comments that are now single-line; those threads are marked resolved. The new test is hermetic (port: 0, local connect, tempDir), asserts port stability across three reloads, and fails on the unfixed build. Given the unsafe-pointer/GC surface and the new public option, I'm deferring rather than approving.

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.

Bun --hot flag not working with Bun.listen: EADDRINUSE error on hot reload

1 participant