Skip to content

Bun.serve: close WebSockets with 1001 on server.stop() and let stop(true) follow stop(false) - #34961

Open
robobun wants to merge 6 commits into
mainfrom
claude/farm/6213778d/serve-stop-websocket-1001
Open

Bun.serve: close WebSockets with 1001 on server.stop() and let stop(true) follow stop(false)#34961
robobun wants to merge 6 commits into
mainfrom
claude/farm/6213778d/serve-stop-websocket-1001

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

server.stop() is blind to open WebSockets:

const s = Bun.serve({
  port: 0,
  fetch(req, sv) { if (sv.upgrade(req)) return; return new Response("http"); },
  websocket: { message(ws, m) { ws.send("echo:" + m); }, close(_w, code) { log("server close", code); } },
});
const w = new WebSocket(`ws://127.0.0.1:${s.port}/`);
await new Promise(r => (w.onopen = r));
w.onclose = e => log("client close", e.code, e.wasClean);

await s.stop(false);   // B: never resolves; w still echoes; readyState stays OPEN
s.stop(true);          // C: after stop(false): no-op; w still open
// A (control): stop(true) alone -> both sides see code 1006, wasClean=false

Observed on main (release and release-asan, deterministic):

  • B stop(false) never resolves with a WebSocket connected; the socket keeps serving traffic.
  • C stop(true) after stop(false) is a no-op (same listener gate as Bun.serve: make stop(true) force-close after a prior graceful stop #33662), so a "graceful then force" shutdown can never complete once a WebSocket is connected.
  • A stop(true) kills WebSockets by raw socket close: the server close callback and the peer both see 1006 (abnormal, no close frame) instead of 1001 Going Away.

Cause

  • stop_listening on the graceful path only calls listener.close(). Open WebSockets are untouched, so deinit_if_we_can (which gates on !has_active_web_sockets()) never resolves the returned promise.
  • stop_listening on the abrupt path calls app.close(), which walks the WebSocket groups via us_socket_group_close_all and calls us_socket_close on each: raw fd close, no close frame, and WebSocketContext::onClose hard-codes 1006 for that path.
  • stop_from_js / dispose_from_js only call stop() when has_listener(); a prior graceful stop has already taken the listener, so a later stop(true) returns without touching the app.
  • get_all_closed_promise fast-path returns a resolved promise when the listener is gone and there are no pending HTTP requests, ignoring open WebSockets.

Fix

  • Add TemplatedApp::endAllWebSockets(code, message) (packages/bun-uws/src/App.h) which walks every WebSocket socket group and calls WebSocket::end(code, message) on each open socket: sends the close frame, fires the close handler, and FINs. Exposed to Rust via uws_app_end_all_websockets and NewApp::end_all_websockets.
  • stop_listening now calls end_all_websockets_going_away() (which sends 1001 "Server closed") before closing the listener or terminating the app, under the existing deinit_running re-entrance guard so the synchronous on_close defers do not dispatch deinit_if_we_can while this frame still holds &mut self.
  • stop_listening also terminates the app on an abrupt stop when the listener was already taken (via the new terminate_app() helper, guarded by TERMINATED), so stop(true) after stop(false) still force-closes in-flight HTTP connections. stop_from_js / dispose_from_js now enter stop() in that state.
  • get_all_closed_promise fast-path also checks !has_active_web_sockets().

After:

A  stop(true)               server close cb 1001, client 1001 clean=true, promise resolves
B  stop(false)              server close cb 1001, client 1001 clean=true, promise resolves
C  stop(false); stop(true)  both promises resolve, ws closed 1001

Verification

New server.stop() with open WebSockets block in test/js/bun/websocket/websocket-server.test.ts covers all three cases plus the "client already closing when stop(false) is called" edge.

bun bd test test/js/bun/websocket/websocket-server.test.ts -t "with open WebSockets"   # 4 pass
USE_SYSTEM_BUN=1 bun test <same>                                                       # 4 fail (timeouts + 1006)

Full websocket-server.test.ts is green (111 pass). serve.test.ts has the same environment-only failures as main (IPv6, root-port, egress).

This overlaps with #33662 (the stop(true)-after-stop(false) gate) and extends it to the WebSocket path.


no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-server.test.ts test/js/bun/websocket/websocket-server.test.ts

Closes #25722

…rue) follow stop(false)

server.stop() was blind to open WebSockets in three ways:

- stop(false) only closed the listen socket. Open WebSockets stayed
  connected and kept serving traffic, and the returned promise never
  resolved because deinit_if_we_can() is gated on
  has_active_web_sockets().
- stop(true) closed WebSockets by raw us_socket_close, so both the
  server close handler and the peer observed code 1006 (abnormal) with
  no close frame instead of 1001 Going Away.
- stop_from_js wrapped the whole call in has_listener(); after a prior
  graceful stop the listener is gone, so a following stop(true) did
  nothing. Combined with the first point, a "graceful then force"
  shutdown could never complete once a WebSocket was connected.

Fix: add TemplatedApp::endAllWebSockets(code, reason) which walks every
WebSocket group and calls WebSocket::end() (sends the close frame, fires
the close handler, FIN). stop_listening now calls it with 1001 before
closing the listener (graceful) or the app (abrupt), under the existing
deinit_running re-entrance guard. stop_from_js/dispose_from_js also run
when the listener is already gone but the app has not been terminated,
and get_all_closed_promise's fast-path now checks
has_active_web_sockets().
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 18 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: 103dbe67-c48f-4768-aede-dcc38f592a84

📥 Commits

Reviewing files that changed from the base of the PR and between c9ba870 and fae66be.

📒 Files selected for processing (3)
  • src/runtime/server/mod.rs
  • test/js/bun/http/bun-server.test.ts
  • test/js/bun/websocket/websocket-server.test.ts

Walkthrough

WebSocket shutdown now exposes an end-all-connections API through uWS, integrates close handshakes and abrupt termination into server lifecycle paths, updates closed-state checks, and adds Bun and Node HTTP regression tests for closure and garbage-collection behavior.

Changes

WebSocket shutdown lifecycle

Layer / File(s) Summary
uWS end-all-WebSockets API
packages/bun-uws/src/App.h, src/uws_sys/App.rs, src/uws_sys/libuwsockets.cpp
Adds endAllWebSockets across the C++, Rust FFI, and Rust wrapper layers, forwarding close codes and messages to active sockets.
Server stop and disposal integration
src/runtime/server/mod.rs, src/runtime/server/server_body.rs
Server shutdown sends close code 1001, separates abrupt app termination, handles detached listeners, and waits for WebSockets before resolving all-closed state.
Shutdown and liveness regression coverage
test/js/bun/websocket/websocket-server.test.ts, test/js/bun/http/bun-server.test.ts, test/js/node/http/node-http-with-ws.test.ts
Adds coverage for graceful and abrupt stops, nested socket termination, WebSocket liveness, garbage collection, and Node HTTP server closure behavior.

Possibly related issues

  • oven-sh/bun issue 34158 — Concerns WebSocket shutdown and server teardown behavior overlapping with these stop and liveness changes.

Possibly related PRs

  • oven-sh/bun#34346 — Refactors related NewServer WebSocket liveness and stop/deinitialization reentrancy logic.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement the requested WebSocket close-frame shutdown behavior for Bun.serve on stop and process end.
Out of Scope Changes check ✅ Passed The added tests and runtime changes all support the WebSocket shutdown work and do not appear unrelated.
Title check ✅ Passed The title clearly captures the main change: graceful WebSocket close handling on server.stop(), including the stop(false) then stop(true) flow.
Description check ✅ Passed The description is thorough and includes the problem, fix, and verification, though it uses custom headings instead of the template sections.

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

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Jul 21st, 2026

@robobun, your commit fae66be168f4015b9a70956d26275b7c4584f1d0 passed in Build #77000! 🎉


🧪   To try this PR locally:

bunx bun-pr 34961

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

bun-34961 --bun

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced all three behaviours on main with the script in the PR body; with this branch:

A stop(true)               server close cb 1001, client 1001 clean=true, resolved
B stop(false)              server close cb 1001, client 1001 clean=true, resolved
C stop(false); stop(true)  both resolved, ws closed 1001

bun bd test test/js/bun/websocket/websocket-server.test.ts: 111 pass, 0 fail. New tests time out / assert 1006 under USE_SYSTEM_BUN=1.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Provide a mechanism to close Bun.serve active connections on process end #25722 - Requests a mechanism to close active WebSocket connections on server end; this PR now sends close code 1001 to all open WebSockets during server.stop()
  2. --watch does not exit on sigint when ref'd ressources exist #32400 - --watch does not exit on SIGINT when ref'd resources exist; this PR fixes server.stop() to properly close WebSockets and resolve the stop promise so the process can exit

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

Fixes #25722
Fixes #32400

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.serve: make stop(true) force-close after a prior graceful stop #33662 - Both fix server.stop(true) after a prior stop(false) by refactoring the same stop_listening/terminate_app logic in mod.rs and server_body.rs; this PR extends that work with WebSocket close-code-1001 handling

🤖 Generated with Claude Code

stop() now ends open websockets with 1001 before closing the listener,
so the "stopped server with a live websocket" state these tests relied
on no longer exists. Rewrite them to exercise the same invariants
(wrapper survives GC while connected; error handler copied before user
JS; no wrapper leak) via a WeakRef handle so the test can call stop()
without itself rooting the wrapper.
Comment thread packages/bun-uws/src/App.h
Comment thread src/runtime/server/mod.rs
Comment thread test/js/bun/websocket/websocket-server.test.ts
…ning guard

endAllWebSockets walked head_sockets with a pre-captured next pointer,
but end() fires the close handler synchronously and user JS there can
terminate() a later socket, which rewrites its ->next into the loop's
closed_head and derails the walk. Snapshot into a vector first.

end_all_websockets_going_away/terminate_app set deinit_running with
set(true)/set(false); a nested server.stop(true) from a close handler
would clear the outer frame's guard. Use replace(true)/set(prev), and
gate stop_from_js/dispose_from_js on !deinit_running so a nested stop()
during an outer drain is a no-op rather than a re-entrant &mut borrow.

Also wire test error events to reject the awaited promise, and add
coverage for a close handler that terminates other sockets and calls
stop(true) during the drain.
Comment thread src/runtime/server/mod.rs
Comment thread test/js/bun/http/bun-server.test.ts Outdated
Comment thread src/runtime/server/mod.rs Outdated
robobun added 2 commits July 21, 2026 16:29
node:http Server#close() must leave upgraded sockets to the user (Node
only stops accepting and closes idle keep-alives). Gate
end_all_websockets_going_away() on !on_node_http_request so the
Bun-native stop() behaviour is the only path that closes WebSockets.
Add a node-http-with-ws regression test that keeps the ws open across
server.close() and then observes a user-chosen close code.

Also restore the let-else in stop_listening and drop an unread field
from the bun-server.test.ts GC subprocess output.

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

🤖 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/server/mod.rs`:
- Around line 1696-1702: Trim the newly added explanatory comments in
end_all_websockets_going_away and terminate_app to three lines or fewer each.
Preserve only the essential re-entrancy and ordering invariants, including the
guard spanning the drain and stop() performing the idle pass, without adding
broader documentation.

In `@test/js/bun/http/bun-server.test.ts`:
- Around line 879-883: Shorten the three explanatory comment blocks near the
websocket test and the referenced sections to no more than three lines each.
Preserve only the essential test intent and behavior, including GC survival
while connected and collectability after stop where relevant; do not alter the
tests.

In `@test/js/bun/websocket/websocket-server.test.ts`:
- Around line 1745-1748: Update the serverCodes sorting in the assertion to use
an explicit numeric comparator instead of the default lexicographic sort, while
preserving the expected close-code values and pendingWebSockets check.
🪄 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: 9b509e63-4373-4b7e-b798-26cbdebf84f2

📥 Commits

Reviewing files that changed from the base of the PR and between e550f2c and c9ba870.

📒 Files selected for processing (8)
  • packages/bun-uws/src/App.h
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/uws_sys/App.rs
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/http/bun-server.test.ts
  • test/js/bun/websocket/websocket-server.test.ts
  • test/js/node/http/node-http-with-ws.test.ts

Comment thread src/runtime/server/mod.rs Outdated
Comment thread test/js/bun/http/bun-server.test.ts Outdated
Comment thread test/js/bun/websocket/websocket-server.test.ts Outdated
Jarred-Sumner pushed a commit that referenced this pull request Aug 5, 2026
…#35130)

## Problem

The `server.stop(false)` drain promise resolved while keep-alive HTTP
connections were still open and still serving.

```js
// bun stopcensus.mjs [idle|inflight]
import net from "node:net";
const mode = process.argv[2] || "idle";
const server = Bun.serve({ port: 0, hostname: "127.0.0.1",
  async fetch(req) { const p = new URL(req.url).pathname; if (p === "/slow") await Bun.sleep(600); return new Response("resp:" + p + ";"); } });
const c = net.connect(server.port, "127.0.0.1");
// ... one GET, then await server.stop(false), then a second GET on the same socket
```

`idle` → `stopResolvedAfterMs: 0, connFinAt: null, servedAfterResolve:
1`; `inflight` → resolves at response-finish (~450 ms), connection open,
`/second` served. Deterministic on 1.4.0.

Separately, `server.stop(true)` after an earlier `server.stop(false)`
was a silent no-op: `stop_from_js` only entered `stop()` while
`has_listener()`, and a prior graceful stop had already taken the
listener.

## Cause

`deinit_if_we_can` (and the `get_all_closed_promise` early-return, and
the `stop_listening` unref gate) tested `pending_requests == 0 &&
!has_listener() && !has_active_web_sockets()`. Idle keep-alive HTTP
connections are not in any of those terms; the predicate had no
connection count, so it was satisfied while sockets were open and uWS
kept routing requests on them.

## Fix

- New `active_connection_count: Cell<u32>` on `NewServer`, fed by a uWS
`filter` registered in `listen()` (fires `+1` on accept /
post-TLS-handshake, `-1` from `HttpContext::onClose`). On WebSocket
upgrade the socket is `us_socket_adopt`-ed out of the HTTP group and
`HttpContext::onClose` never fires for it, so `note_websocket_opened`
moves the count to the existing WebSocket tally.
- The drain predicate, the `get_all_closed_promise` early-return and the
`stop_listening` unref gate now include `!has_active_connections()`. The
early-return also gains `!has_active_web_sockets()`: after an upgrade
the connection count is 0, so on a websocket-only server this term is
what keeps a repeat `stop()` call from returning a fresh resolved
promise while the stored one is still pending. `stop(false)` does
**not** close existing connections (per the review on the previous
revision of this PR); the promise waits for them to close via
`idleTimeout`, client disconnect, `server.closeIdleConnections()` or
`server.stop(true)`.
- `stop_from_js` / `dispose_from_js` enter `stop()` for an abrupt stop
whenever the app has not yet been terminated, and `stop_listening`
performs the `app.close()` teardown in that state, so `stop(true)` after
`stop(false)` force-closes the surviving connections.

## Memory safety

The deferred `js_value` downgrade is also a use-after-free fix. On
`main`, once `pending_requests` hits 0 after a graceful `stop()`,
`deinit_if_we_can` downgrades the wrapper to `Weak` while surviving
keep-alive connections can still dispatch. The wrapper's slots are the
only GC root of the configured handlers, and `JsRef::try_get()` returns
the raw `JSValue` of a `Weak` ref with no liveness check, so after a GC
pass a late request on such a connection calls swept cells:

- release build: a freshly allocated object can reuse the swept handler
cell and be invoked as the fetch handler. When the occupant is the fetch
handler of another `Bun.serve` instance created after the stop, a
request on the stopped server's surviving keep-alive connection is
answered by that other instance's handler, crossing any in-process
boundary between listeners (public vs admin, per-tenant servers). Other
occupants surface as `error: Expected a Response object, but received
'6'` (also `''` / `undefined`), response bodies resolving to unrelated
objects, or a segfault
- debug/ASAN build: UBSan `Structure.h: member call on null pointer of
type 'JSC::ClassInfo'` in `Bun__JSValue__call`, reached from
`NewServer::on_request` via `us_internal_dispatch_ready_poll` (a loop
dispatch against the collected wrapper, not a finalizer-ordering
problem)

With the connection count in the predicate, the wrapper stays `Strong`
until the last connection is gone, so a late dispatch always sees live
cells.

A standalone stress driver that creates fresh `Bun.serve` instances
after every graceful stop confirms this: on unfixed builds it produces
corrupted responses in release (about 1 per 120 stops over 72k rounds)
and swept-cell sanitizer crashes under ASAN within 500 rounds, while
this branch runs 1,000+ rounds under ASAN with zero reports.

## Verification

New `server.stop() drain promise counts open connections` block in
`test/js/bun/http/bun-server.test.ts`:

- `idle keep-alive connection holds the promise until the client closes`
/ `in-flight request's connection holds the promise past response end`:
fail-before `resolvedEarly: true, resolvedWhileOpen: true`; after
`false, false` and the promise resolves once the client destroys the
socket.
- `stop(true) after stop(false) force-closes the surviving connection`:
fail-before `closed: false`; after `closed: true`.

New `request on a connection surviving graceful stop() never reaches a
collected handler` stress test: parks pooled keep-alive connections
across `stop()`, drops the server binding, churns the heap and forces
GC, then sends late requests on the surviving connections. Rounds
alternate between a plain `fetch` handler and a `routes:` param-route
server, because the route dispatch reads the wrapper's `ServerRouteList`
cell, a second collected-cell site (UBSan member call on null
`TrailingArray<...ServerRouteList::IdentifierRange>` in
`paramsObjectForRoute`, reached from `on_user_route_request`). Fails
consistently on `main`: 6/6 with the release build (wrong bodies,
responses from an already-collected server, segfaults) and 9/9 with the
debug ASAN build across both shapes of the test, hitting both UBSan
sites. Passes repeatedly with this PR (~35 s under ASAN, ~8 s release).

The `late keep-alive WebSocket upgrade after stop()` test is updated:
the wrapper downgrade is now deferred while the connection is open, so a
pipelined upgrade on that connection reaches a live handler and
`server.upgrade()` succeeds (previously it was refused because
`handler.server` had been cleared).

```
bun bd test test/js/bun/http/bun-server.test.ts -t "drain promise counts open connections"  # 3 pass
USE_SYSTEM_BUN=1 bun test <same>                                                            # 3 fail
```

`bun-server.test.ts`, `serve.test.ts`, `node-http.test.ts` and
`websocket-server.test.ts` are unchanged apart from the usual
environment-only failures that also fail on `main`. `node:http`'s
`server.close()` calls `closeIdleConnections()` itself, so its
observable behaviour is the same before and after.

The `stop(true)`-after-`stop(false)` gate overlaps #33662 and #34961;
this PR carries it because the connection-count term makes it the only
way to force the promise through when a client keeps the socket open.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 24 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/http/bun-server.test.ts

<!-- robobun:evidence:end -->
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.

Provide a mechanism to close Bun.serve active connections on process end

1 participant