Skip to content

Bun.serve: close the listen socket in finalize() so worker terminate releases the port - #36094

Closed
robobun wants to merge 5 commits into
mainfrom
farm/a16dd5a7/serve-worker-terminate-fd-leak
Closed

Bun.serve: close the listen socket in finalize() so worker terminate releases the port#36094
robobun wants to merge 5 commits into
mainfrom
farm/a16dd5a7/serve-worker-terminate-fd-leak

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Repro

const w = new Worker(
  "data:text/javascript," +
  encodeURIComponent("const s = Bun.serve({ port: 0, fetch: () => new Response('W') }); postMessage(s.port);"),
);
const port = await new Promise(r => w.addEventListener("message", e => r(e.data), { once: true }));
w.terminate();
await new Promise(r => w.addEventListener("close", r, { once: true }));
Bun.serve({ port, fetch: () => new Response("x") });
// → EADDRINUSE: Failed to start server. Is port <N> in use?

After worker.terminate() the worker's Bun.serve listen socket stays open for the life of the process: the port is permanently bound (EADDRINUSE on any rebind), new clients are accepted by the kernel into a socket nothing services, and every terminate cycle leaks one fd. Same for a worker that process.exit()s or exits naturally with an unref()'d server.

Cause

WebWorker::shutdown()'s close_all_socket_groups() deliberately skips listen sockets (us_loop_close_all_groups passes also_listeners = 0) because the owning Rust object holds a raw *mut us_listen_socket_t and closing it there would dangle that pointer once drain_closed_sockets() frees the queued struct. The design relies on the owner's GC finalize() closing the listener, which lastChanceToFinalize() reaches during teardownJSCVM:

https://github.com/oven-sh/bun/blob/4eb6f99c1a/src/jsc/web_worker.rs#L1327-L1330

Bun.listen's Listener::finalize() honours that contract. Bun.serve's NewServer::finalize() did not: it called deinit_if_we_can(), which early-returns while has_listener() is true, so the listen fd, the uWS App, and the NewServer allocation were all stranded.

Fix

Close the listener (and h3_listener) in NewServer::finalize() before dispatching to deinit_if_we_can(), matching Listener::finalize(). A still-listening server can only reach finalize() from lastChanceToFinalize(): while listener is set the Strong js_value root keeps the JS wrapper alive through every ordinary GC, so the only sweep that reaches it with a live listener is VM teardown. us_listen_socket_close() is JS-free (poll stop + close(fd) + queue onto loop->data.closed_head), and WebWorker::shutdown() already runs drain_closed_sockets() right after teardownJSCVM to free the queued struct.

Verification

# before (stock 1.4.0)
fds leaked: 5   rebind: EADDRINUSE
# after
fds leaked: 0   rebind: OK

New test in test/js/web/workers/worker-terminate-lifetime.test.ts spawns 5 serve-in-worker → terminate cycles and asserts each port can be immediately rebound, plus a /proc/self/fd delta check on Linux. Fails on main with EADDRINUSE, passes with this change.


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-terminate-lifetime.test.ts

@coderabbitai

coderabbitai Bot commented Jul 27, 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: 12 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: 4981e5c9-b6b6-4947-8a71-42256b9dd7ff

📥 Commits

Reviewing files that changed from the base of the PR and between 59242d6 and 3237ab8.

📒 Files selected for processing (4)
  • src/jsc/web_worker.rs
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

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

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on stock 1.4.0: 5 serve-in-worker → terminate cycles leak 5 listen fds and every port stays EADDRINUSE. With this change: 0 fds leaked, every port immediately rebindable.

USE_SYSTEM_BUN=1 bun test test/js/web/workers/worker-terminate-lifetime.test.ts -t 'releases the listening port'   # fails: EADDRINUSE (all 3 exit modes)
bun bd test test/js/web/workers/worker-terminate-lifetime.test.ts -t 'releases the listening port'                  # passes (all 3 exit modes)

Test covers terminate(), worker process.exit(), and worker server.unref() + drain via test.each; all three converge on WebWorker::shutdown()lastChanceToFinalize()NewServer::finalize(). H3 listener is intentionally left alone there since us_quic_listen_socket_close can dispatch JS on_abort for live QUIC conns.

Re-verified 2026-07-29 after rebase onto main@59242d6:

stock 1.4.0 this change
rebind same port EADDRINUSE OK
fetch to port HANG (black hole) ConnectionRefused
control (explicit stop(true) before terminate) OK OK

CI (build 84783): new test passes on all lanes. Remaining red is unrelated:

  • filesystem_router.test.ts SIGSEGV in resolver.rs:5346 on alpine aarch64 (resolver/bundler, reported to triage)
  • darwin 14 x64 lane (darwin-naan-x64-1) instability: require-cache/serve-body-leak/node-http2/request-clone-leak all failed only there; serve-body-leak passes locally with this change and request-clone-leak times out identically with src/ reverted to main; main build 84716's darwin x64 jobs are WAITING_FAILED
  • rest are retry-passed flakes

Ready for review.


#36097 takes a different approach to the same bug: register every server in the existing per-VM IsolationHandle::Server set unconditionally and stop(true) them from WebWorker::shutdown() step 2 (before close_all_socket_groups, VM still live). That runs the full stop_listening(true) path (H3 included, on_close callbacks safe) and reuses the registry the test-isolation teardown already walks. This PR is the narrower finalizer-level close; either lands, not both (same test file).

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36097, which runs stop(true) through the existing per-VM IsolationHandle::Server registry in WebWorker::shutdown() step 2 (before close_all_socket_groups, VM still live) rather than closing the listener in finalize(). That covers the accepted-connection fds too and reuses the same registry the test-isolation swap already walks.

Comment thread src/runtime/server/server_body.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
Comment thread src/runtime/server/server_body.rs
Comment thread src/jsc/web_worker.rs
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/server/server_body.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:07 PM PT - Jul 28th, 2026

@robobun, your commit 3237ab8 has 1 failures in Build #84783 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36094

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

bun-36094 --bun

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

All four earlier findings (H3 close re-entering JS, unix-socket unlink, exit-mode variant coverage, fd-delta poll race + slow-build scaling) are addressed and I found nothing new, but this is GC-finalizer + worker-VM-teardown code where the correctness argument hinges on lastChanceToFinalize ordering and us_listen_socket_close being JS-free — worth a human pass, and there's the open question of whether #36097's registry-walk approach supersedes this.

What was reviewed:

  • NewServer::finalize now closes only the TCP listener (JS-free) and nulls h3_listener without closing — traced us_quic_listen_socket_closeon_abort to confirm the asymmetry is required.
  • deinit_if_we_can() reachability from lastChanceToFinalize after has_listener() goes false: the all_closed_promise enqueue is gated on !vm.is_shutting_down(), and schedule_deinit()'s enqueue_task calls land after release_queued_tasks_for_shutdown — verifiers concluded this doesn't crash.
  • Test: test.each covers all three exit modes; port-await promise not wiring worker error → reject was checked and ruled out (worker body is trivial, no error path).
Extended reasoning...

Overview

Three files: NewServer::finalize() in src/runtime/server/server_body.rs gains a TCP listener close + unix-path unlink + h3_listener = None before dispatching to deinit_if_we_can(); src/jsc/web_worker.rs has a one-word doc-comment update naming NewServer alongside Listener in the drain_closed_sockets invariant; and test/js/web/workers/worker-terminate-lifetime.test.ts adds a test.each over three worker-exit modes that spawns 5 serve-in-worker cycles, rebinds each port, and (on Linux) polls /proc/self/fd with a slow-build-scaled deadline.

Security risks

None identified. The change closes a resource leak; no new attack surface, no auth/crypto/permissions.

Level of scrutiny

High. finalize() runs inside JSC's lastChanceToFinalize() heap sweep during worker VM teardown — the exact context REVIEW.md's memory-safety section is written about. The PR itself already went through one round where an earlier revision would have re-entered JS from the H3 close path during the sweep; the current revision deliberately leaks the H3 listener to avoid that. The soundness of the TCP close depends on the Strong js_value root guaranteeing that a still-listening server can only reach finalize() from VM teardown (never ordinary GC), which I believe is correct but is a load-bearing invariant a maintainer should confirm. The newly-reachable schedule_deinit() path (once has_listener() goes false) enqueues two ManagedTasks onto a VM whose event loop will never tick again; verifiers examined this and concluded it does not crash, but a human should confirm the leaked-task-box tradeoff is acceptable versus #36097's approach of walking the IsolationHandle::Server registry before teardown.

Other factors

  • The author's own comment says this is "superseded by #36097" (which stops servers via the per-VM registry in shutdown step 2, before JSC teardown, and also covers accepted-connection fds). Commits continued on this PR afterwards, so it's unclear which approach is intended to land — that's a human call.
  • Previous review rounds: I raised four issues across two passes; all were addressed in dba40ef / 18e395f / e0dd43d and the threads are resolved. The comment-cop bot's length nags were also addressed.
  • Test coverage looks solid: fails on main with EADDRINUSE on all three exit modes, port-rebind is the primary race-free assertion, fd-delta poll now scales via the file's existing slow factor.

robobun added 4 commits July 29, 2026 02:53
…releases the port

Terminating a Worker that was running Bun.serve() left the listening
socket open for the life of the process: the port stayed bound
(EADDRINUSE on any rebind), new connections were accepted by the kernel
into a socket nothing serviced, and every terminate cycle leaked one fd.

WebWorker::shutdown's close_all_socket_groups() deliberately skips
listen sockets because the owning object holds a raw pointer to the
us_listen_socket_t and closing it there would UAF after
drain_closed_sockets(). The design relies on the owner's GC finalize()
closing the listener, which the lastChanceToFinalize() sweep reaches
during teardownJSCVM. Bun.listen's Listener::finalize() does that;
Bun.serve's NewServer::finalize() did not: it called deinit_if_we_can(),
which early-returns while has_listener() is true.

A still-listening NewServer can only reach finalize() from
lastChanceToFinalize(): the Strong js_value root keeps the wrapper
alive through every ordinary GC while the listener is set. So close the
listener (and h3_listener) synchronously there, mirroring
Listener::finalize(). deinit_if_we_can() then sees has_listener() ==
false and proceeds to schedule_deinit().
…exit-mode variants; poll fdCount

us_quic_listen_socket_close walks every live QUIC conn with
lsquic_conn_abort + us_quic_process, which synchronously fires
on_stream_close -> RequestContext::on_abort (JS abort listeners). That
is unsound inside lastChanceToFinalize(); close_all_socket_groups never
drained QUIC conns, so they can still be live there. Drop the H3 close
(back to pre-PR state for that narrow path) and document why.

Unlink the unix-socket path before closing the listener, matching both
Listener::finalize and stop_listening.

Test: cover terminate() / process.exit() / unref()+drain with test.each,
and poll /proc/self/fd with a bounded deadline instead of relying on
Bun.gc(true) as an implicit sleep (the close event fires before the
detached worker thread closes its epoll/eventfd in on_thread_exit).
@robobun
robobun force-pushed the farm/a16dd5a7/serve-worker-terminate-fd-leak branch from 07e26b4 to d4aec73 Compare July 29, 2026 03:00
Comment thread src/runtime/server/server_body.rs
Mirrors the guard on the adjacent ServerAllConnectionsClosedTask block:
schedule_deinit() enqueues two ManagedTask boxes that no event-loop tick
will ever drain once this is reached from lastChanceToFinalize().
EventLoop::deinit() drops them without running the callbacks, so the
NewServer/App leaked pre-PR too; this just avoids the transient
allocation now that finalize() clears listener/h3_listener and makes the
schedule_deinit branch reachable from that path.
Comment thread src/runtime/server/mod.rs

@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 issues found this pass, but this closes a listen socket from inside a GC finalizer during VM teardown — memory-safety-critical ordering that relies on the Strong js_value root guaranteeing listener.is_some() in finalize()lastChanceToFinalize(). That invariant plus the choice between this finalizer-level close and #36097's registry-based stop(true) in shutdown() step 2 are both maintainer calls.

What was reviewed

  • finalize() block: us_listen_socket_close is JS-free (poll stop + close + queue), unix-path unlink matches stop_listening/Listener::finalize, h3_listener deliberately left unclosed since us_quic_listen_socket_close can dispatch JS on_abort.
  • deinit_if_we_can is_shutting_down() guard mirrors the adjacent ServerAllConnectionsClosedTask block; ordinary-GC finalize() still reaches schedule_deinit().
  • Test: test.each covers all three exit modes, rebind assertion is race-free (listen fd closed in step 3 pre-dispatchExit), fd-poll deadline scaled via the file's existing slow factor.
Extended reasoning...

Overview

Four files: server_body.rs adds a listener-close block to NewServer::finalize() (unix unlink + us_listen_socket_close, nulls h3_listener), mod.rs gates schedule_deinit() on !is_shutting_down(), web_worker.rs updates the step-3 comment to name NewServer alongside Listener, and a new test.each regression covers terminate / process.exit() / unref()+drain.

Security risks

None. This is resource-lifecycle cleanup on worker teardown; no auth/crypto/parsing/user-input surface touched.

Level of scrutiny

High. finalize() runs from JSC's GC sweep during lastChanceToFinalize(), and correctness depends on (a) the Strong js_value root making listener.is_some() in finalize() reachable only from VM teardown, (b) us_listen_socket_close never re-entering JS, and (c) WebWorker::shutdown()'s post-teardown drain_closed_sockets() reclaiming what was just queued. All three check out against the current source, but this is exactly the layer REVIEW.md flags as most-blocked ("anything that can run user JS can synchronously free your state", finalizer ordering, refcount balance). The H3 asymmetry (fd deliberately still leaked to avoid JS re-entry) is a documented tradeoff a maintainer should sign off on.

Other factors

  • The PR thread itself notes #36097 as an alternative that runs stop(true) via the per-VM IsolationHandle::Server registry in shutdown() step 2 (VM still live, H3 covered, accepted-connection fds covered). "Either lands, not both" — a human needs to pick.
  • All four prior inline findings (H3 close hazard, unix unlink, exit-mode variant matrix, fd-poll race + deadline scaling) were addressed in follow-up commits and are verified in the current diff.
  • The is_shutting_down() guard on schedule_deinit() is defensive symmetry with the adjacent block, not a behavior change users can observe — checked that ordinary-GC finalize() (is_shutting_down() == false) still reaches schedule_deinit().

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this is fixed on current main by the Worker teardown rewrite in #37075, which stops every registered Bun.serve server during the VM teardown stop phase, so a terminated (or exited) worker no longer leaves its listen socket behind. The three cases this PR adds to test/js/web/workers/worker-terminate-lifetime.test.ts (port rebinds after terminate(), process.exit() and unref() + drain, plus the /proc/self/fd delta check) pass unmodified against a debug build of main at 04148c8, three runs in a row. #36097, which superseded this PR, is being closed for the same reason.

@robobun robobun closed this Aug 13, 2026
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