Skip to content

serve: trace handler callbacks from the wrapper instead of rooting them as Strong - #32215

Closed
alii wants to merge 48 commits into
mainfrom
ali/serve-handlers-gc-traced-v2
Closed

serve: trace handler callbacks from the wrapper instead of rooting them as Strong#32215
alii wants to merge 48 commits into
mainfrom
ali/serve-handlers-gc-traced-v2

Conversation

@alii

@alii alii commented Jun 12, 2026

Copy link
Copy Markdown
Member

A handler that closes over server forms a cycle the GC can't see through because ServerConfig/NewServer/Handler hold it as a Strong/gcProtect root: box → Strong(handler) → closure env → wrapper → m_ctx → box. The wrapper never finalizes and the box is never freed. Per-route handlers already avoid this via ServerRouteList's WriteBarrier vector — a route handler closing over server is collectable today. This applies the same to the 11 server-level callbacks.

Handler storageserver.classes.ts values: gains onRequest/onError/onNodeHTTPRequest/onClientError/7×wsOn*/allClosedPromise (H2FrameParser already ships with 18). Native fields stay raw JSValue shadows for hot-path reads. JSServerWebSocket's unused m_socket slot becomes m_server so a connected websocket keeps the server wrapper reachable. with_async_context_if_needed is deferred from from_js to slot-write so the wrapped function is rooted by the slot the moment it exists. WebSocketServerHandler::protect/unprotect are deleted (never balanced on stop today).

Wrapper liveness (the existing route-handler post-stop bug) — js_value.downgrade() moves from stop() into deinit_if_we_can()'s idle predicate; the live-websocket count moves onto NewServer and on_close calls deinit_if_we_can() via a Handler.server backref; dispatch trampolines refuse with 503 once js_value is no longer strong.

Also: DevServer::Drop derefs html_bundle (Zig RouteBundle.deinit:112; the Rust port missed it).

Tests: handler closing over server is collected after stop (heapStats; fails on the released binary), reload swap, ws-connected liveness, AsyncLocalStorage init-window stress, BUN_JSC_collectContinuously=1 stress, late keep-alive request after stop+GC doesn't crash.

Supersedes #32086.


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/bun/http/bun-server.test.ts

alii added 8 commits June 12, 2026 12:15
Per-route handlers are stored as WriteBarriers reachable only via the
Server JS wrapper. stop() previously downgraded js_value immediately,
so a late keep-alive request after stop+drop+GC would hit
js_value_assert_alive() with a Finalized ref and panic. Downgrade
inside the deinit_if_we_can idle predicate instead so the wrapper stays
rooted until pending_requests/listener/active websockets are all clear.

The websocket close path does not yet call deinit_if_we_can; the next
commit threads an AnyServer backref through Handler so the last close
can trigger it.
The previous commit moved the JsRef downgrade into deinit_if_we_can's
idle predicate, but nothing calls that when the last websocket closes
after a graceful stop. Thread an AnyServer backref through Handler so
on_close can trigger it; move the live-socket count onto NewServer
(where reload's context swap can no longer reset it). on_close also
copies the close handler to a stack local before sig.signal() so a GC
between the test and the call cannot collect it.
…per finalize

Idle keep-alive sockets are not counted in pending_requests, so the
wrapper can downgrade and be collected while one such socket can still
deliver another request. js_value_assert_alive() panics on Finalized;
the dispatch entry points now check first and close the connection
with 503 instead.
JsRef::Weak holds a raw JSValue, not a JSC::Weak: try_get() can return
the address of a dead-but-unswept cell, so the Finalized→503 check
alone leaves a window where dispatch reads an unrooted handler shadow.
Closing idle connections at stop() removes the late-request source;
in-flight requests are not idle and drain normally.

The on_open error-path websocket-close accounting already runs after
run_error_callback as of 65c76a2, so the live-socket count stays
nonzero across that read; no further change needed there.
JsRef::Weak holds a raw JSValue: try_get() on Weak returns the address
even when the cell is dead-but-unswept. Gating on Strong means
trampolines refuse the moment the server goes idle (downgrade) rather
than only after the wrapper destructor has run.
@coderabbitai

coderabbitai Bot commented Jun 12, 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

Refactors server handler storage from Strong references to JSValue write-barrier slots, defers async-context wrapping to serve/reload, expands cached js_gc_* accessors, gates dispatch on wrapper strong-root (503 fallback), rewires websocket back-references and lifetime accounting, and adds GC/liveness tests.

Changes

Server Handler Storage and GC Refactoring

Layer / File(s) Summary
Handler Storage Type Changes
src/runtime/server/ServerConfig.rs, src/runtime/server/mod.rs
ServerConfig fields on_error, on_request, on_node_http_request and NewServer::on_clienterror change from Option<Strong> / StrongOptional to raw JSValue, initialized to JSValue::ZERO. Reload cloning copies these directly.
Configuration Parsing Without Eager Wrapping
src/runtime/server/ServerConfig.rs
ServerConfig::from_js stores callback handlers as raw JSValue without eager async-context wrapping. Validation checks switch from is_none() to is_empty().
Async Context Wrapping at Serve Initialization
src/runtime/api/BunObject.rs
During Bun.serve(), handlers are wrapped with async-context support via with_async_context_if_needed, stored in GC write-barrier slots, and config references are updated.
Handler Reading with New Semantics
src/runtime/bake/DevServer.rs, src/runtime/server/RequestContext.rs, src/runtime/server/server_body.rs, src/runtime/server/mod.rs
Handler presence checks use is_empty() semantics; handlers are read directly from config as JSValue instead of unwrapping Option<Strong>. Affects dev server dispatch, error handler invocation, request routing, websocket upgrade, and fetch handling.
WebSocket Handler Server Back-reference
src/runtime/server/WebSocketServerContext.rs, src/runtime/server/ServerWebSocket.rs, src/runtime/server/mod.rs
Websocket Handler gains optional server field. Initialization caches server JS value in ServerWebSocket. Route setup wires back-reference via websocket.handler.server = Some(any_server).
WebSocket Protection/Unprotection Removal
src/runtime/server/WebSocketServerContext.rs
Removes Handler::protect(), Handler::unprotect(), WebSocketServerContext::protect(), and WebSocketServerContext::unprotect() methods and the server.protect() call from on_create, shifting rooting responsibility to GC infrastructure.
GC Cached Accessors and Write-Barrier Wiring
src/runtime/server/mod.rs, src/runtime/server/server.classes.ts
Expands route_list_cached to generate js_gc_* get/set implementations for request/error/client-error and websocket handlers. Adds AnyServer::js_value() method. Updates server.classes.ts to declare broader handler callback values for codegen.
Handler Reload and Promise Caching
src/runtime/server/server_body.rs
on_reload_from_zig re-wraps handlers with async-context and updates GC roots. Websocket reload adopts handlers when non-empty and writes handler slots. get_all_closed_promise caches promise and registers JS GC root.
Client Error Handler Storage and Rooting
src/runtime/server/server_body.rs, src/runtime/server/mod.rs
on_clienterror changes from StrongOptional to JSValue. on_client_error_callback checks is_empty(). server_set_on_client_error_ assigns directly and wires GC root via js_gc_on_client_error_set.
Import Adjustments
src/runtime/server/server_body.rs
Updates bun_jsc re-exports to use StrongOptional instead of Strong.
Deinitialization Test Infrastructure
test/bake/deinitialization.test.ts, test/bake/fixtures/deinitialization/test.ts
Extends test timeout. Adds heapStats()-based GC accounting to count live server wrapper objects, establish baseline, and verify post-test wrapper counts return to baseline.
Handler Liveness and GC Tracing Tests
test/js/bun/http/bun-server.test.ts
Adds comprehensive test suite: reload handler swapping with GC eligibility, in-flight request completion after stop/GC, websocket close callbacks during stop, server.fetch() dispatch after stop, heap statistics assertions for handler collection, websocket server alive-ness verification, reload-driven handler collection, concurrent GC stress testing, and AsyncLocalStorage handler survival.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the PR’s main change: tracing server handler callbacks from the wrapper instead of rooting them as Strong.
Description check ✅ Passed The description covers the PR’s purpose and verification, but it doesn’t use the exact template headings.

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

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator
Updated 8:10 AM PT - Jul 9th, 2026

@robobun, your commit b9275c7 has 4 failures in Build #71031 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32215

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

bun-32215 --bun

alii added 13 commits June 12, 2026 13:42
Per-route handlers already use this pattern (ServerRouteList held in
m_routeList). Extend to fetch/error/nodeHTTPRequest/clientError/ws
handlers and the all-closed promise so the native↔JS cycle becomes
all-JS-heap once the corresponding Strong/gcProtect roots are dropped
in a follow-up. The unused JSServerWebSocket m_socket slot is
repurposed to hold the server wrapper so a connected websocket keeps
it (and its handler slots) reachable.

No behavior change yet — the slots are declared but not written.
The slots are populated alongside the existing routeList write after
ptr_to_js. ServerConfig still holds Strong handles; the slot writes are
additive until the Strong fields are dropped in a follow-up. wsHandlers,
allClosedPromise and onClientError are written from their respective
set sites in later commits.
with_async_context_if_needed wraps each callback in a fresh function;
storing the original websocket: {...} object would not root the
wrappers (the H2FrameParser bug a5af485 fixed). Match rdr-impl.
The unused m_socket slot is repurposed. Handler gains an AnyServer
backref (same field PR2 adds for the on_close trigger) so init can
reach the server wrapper. With the handler callbacks moving to wrapper
slots, this edge keeps the server wrapper — and its m_ws* handler
slots — reachable while any websocket is connected.
The wrapper slot is now the sole GC root for fetch/error/nodeHTTP
handlers. ServerConfig holds the raw JSValue as a shadow for hot-path
dispatch reads; reload writes both the shadow and the slot.
heapStats wrapper-count assertions for the cycle (handler closing over
server is collected; control case unchanged), websocket-connected
liveness, reload swap, and the AsyncLocalStorage init-window
collectContinuously stress.
Both branches added the same `server: Option<AnyServer>` field at
different struct positions; the rebase auto-merge kept both. Keep one,
merge the doc comment to cover both uses (m_server slot tracing and
live-socket accounting).
get_or_put_route_bundle takes an intrusive ref when it creates a
RouteBundle, but the raw html_bundle pointer has no Drop and DevServer
Drop never released it. One Route per HTML route per dev-server leaked.
Zig RouteBundle.deinit:112 does the deref; the Rust port missed it.
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Release Bun.serve handler Strongs once the server is idle (native↔JS cycle leak) #32086 - Both fix the same Bun.serve() GC memory leak caused by Strong-rooted handler callbacks creating cycles; Release Bun.serve handler Strongs once the server is idle (native↔JS cycle leak) #32086 releases Strongs when idle, while this PR replaces them with WriteBarrier slots

🤖 Generated with Claude Code

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

Caution

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

⚠️ Outside diff range comments (1)
src/runtime/server/ServerConfig.rs (1)

1276-1299: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Root these callbacks through the parse→slot-write gap.

arg.get_truthy(...) can run a getter and return a fresh closure. After these assignments, the callback only lives in args.on_* as a raw JSValue, but this function still does more JS-visible work afterward (for example the tls reads/parsing on Lines 1329-1388). That creates a GC window where getter-produced handlers are untraced before serve_with! / on_reload_from_zig installs the wrapper slot, so the callback can be reclaimed before it ever becomes the rooted handler.

Keep a temporary root for these values until the wrapper slot write completes, then drop that temporary owner once the WriteBarrier slot becomes the long-lived root. As per coding guidelines, "Root or copy every JSValue held beyond the current call" and "Anything that can run user JS can synchronously free your state."

🤖 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/server/ServerConfig.rs` around lines 1276 - 1299, The getters
(arg.get_truthy) can produce fresh closures that are only stored in
args.on_error / args.on_node_http_request / args.on_request, creating a GC
window before the serve_with! / on_reload_from_zig WriteBarrier slot is written;
to fix, create temporary rooted holders for each callback returned by
arg.get_truthy (e.g., local Root/Rooted/JSValueRoot variables) immediately after
retrieval and use those temporaries when validating and assigning, keep the
temporaries alive until after the wrapper slot write (the serve_with! /
on_reload_from_zig installation), then move the value into the long‑lived
WriteBarrier slot and drop the temporary roots; ensure this pattern is applied
for all places using arg.get_truthy in this function so no getter-produced
handler can be reclaimed prematurely.

Source: Coding guidelines

🤖 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/api/BunObject.rs`:
- Around line 1651-1683: The wrapper JS object (obj) must be rooted before
allocating wrapped callbacks or calling write_ws_handler_slots to prevent GC
from collecting the transient local JSValue during with_async_context_if_needed;
move the call to server_ref.js_value.set_strong(obj, global_object) to occur
before the on_request/on_error/on_node_http_request wrapping and before
server_ref.write_ws_handler_slots(obj, global_object) (and then update any
assignments that expect the wrapper to already be rooted), keeping the rest of
the logic (using with_async_context_if_needed and js_gc_*_set helpers)
unchanged.

In `@src/runtime/server/mod.rs`:
- Around line 2992-2995: The current write_ws_handler_slots in mod.rs returns
early when self.config.websocket is None, leaving the m_wsOn* WriteBarrier slots
on the wrapper still rooted; update write_ws_handler_slots to clear all
websocket-related GC slots on the wrapper when self.config.websocket is removed
— explicitly set/clear m_wsOnOpen, m_wsOnClose, m_wsOnError, m_wsOnMessage (and
any other m_wsOn* WriteBarrier fields) to an empty/null JSValue or equivalent so
the wrapper no longer traces old callbacks; keep the existing logic when
websocket is Some(...) but ensure the else branch performs the clearing to drop
references to server-capturing closures.

In `@src/runtime/server/server_body.rs`:
- Around line 2178-2189: The branch handling on_node_http_request must normalize
undefined/null to JSValue::ZERO (like on_request/on_error do) before wrapping:
check new_config.on_node_http_request for undefined or null and treat those as
JSValue::ZERO, then only call with_async_context_if_needed on non-empty callable
values; update the value passed to js_gc_on_node_http_request_set and to
self.config.on_node_http_request accordingly (use the same normalization logic
used by the on_request/on_error branches so with_async_context_if_needed and
later !is_empty() checks never receive non-callable undefined/null values).
- Around line 2199-2212: The branch that processes new_config.websocket can
leave previous websocket handler roots active when the new ws is rejected by the
on_message/on_open gate; update the logic in the block handling
new_config.websocket.take() so that if the new ws is not adopted you explicitly
clear existing roots: set self.config.websocket = None (or replace with an empty
value) and call write_ws_handler_slots(global) or another routine to null out
the wrapper's wsOn* slots so the old BackRef/global roots are unbound; ensure
you still drop or release the BackRef on the rejected ws and maintain the
existing use of handler.flags and write_ws_handler_slots when a ws is adopted.

In `@test/js/bun/http/bun-server.test.ts`:
- Around line 1524-1549: The test creates servers with Bun.serve(...) and delays
calling server.stop(), which can leak resources if an earlier await fails;
immediately register cleanup by either using a using/await using pattern with
the returned server or wrap the server creation in try/finally and call await
server.stop() in finally before any awaits (also ensure you await the Promise
returned by server.stop() in the `/after-stop` case); update all instances
referencing server, stop(), and responsePromise (and the websocket cases at the
noted ranges) to ensure stop() is scheduled/awaited before the first await so
tests remain hermetic.

---

Outside diff comments:
In `@src/runtime/server/ServerConfig.rs`:
- Around line 1276-1299: The getters (arg.get_truthy) can produce fresh closures
that are only stored in args.on_error / args.on_node_http_request /
args.on_request, creating a GC window before the serve_with! /
on_reload_from_zig WriteBarrier slot is written; to fix, create temporary rooted
holders for each callback returned by arg.get_truthy (e.g., local
Root/Rooted/JSValueRoot variables) immediately after retrieval and use those
temporaries when validating and assigning, keep the temporaries alive until
after the wrapper slot write (the serve_with! / on_reload_from_zig
installation), then move the value into the long‑lived WriteBarrier slot and
drop the temporary roots; ensure this pattern is applied for all places using
arg.get_truthy in this function so no getter-produced handler can be reclaimed
prematurely.
🪄 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: ad657845-5390-4895-8c68-2fc1cbfacf70

📥 Commits

Reviewing files that changed from the base of the PR and between a0e221e and 7b7022c.

📒 Files selected for processing (12)
  • src/runtime/api/BunObject.rs
  • src/runtime/bake/DevServer.rs
  • src/runtime/server/RequestContext.rs
  • src/runtime/server/ServerConfig.rs
  • src/runtime/server/ServerWebSocket.rs
  • src/runtime/server/WebSocketServerContext.rs
  • src/runtime/server/mod.rs
  • src/runtime/server/server.classes.ts
  • src/runtime/server/server_body.rs
  • test/bake/deinitialization.test.ts
  • test/bake/fixtures/deinitialization/test.ts
  • test/js/bun/http/bun-server.test.ts

Comment thread src/runtime/api/BunObject.rs
Comment thread src/runtime/server/mod.rs
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs
Comment thread test/js/bun/http/bun-server.test.ts
Comment thread src/runtime/server/mod.rs Outdated
Comment thread test/js/bun/http/bun-server.test.ts Outdated
alii and others added 6 commits June 12, 2026 17:09
- replace DEINIT_RUNNING bitflag with a Cell<bool> field; the scopeguard's
  raw *mut self.flags was invalidated under Stacked Borrows by the
  &mut self reborrows in the body. Clear inline at the tail (no early
  returns) instead of holding a borrow across them.
- write_ws_handler_slots now takes Option<JSValue> and is called
  unconditionally on reload, matching wrap_handler_slot's contract so
  the ws shadows get async-context-wrapped even when the wrapper is
  gone. Collapse the None/Some branches to one 7-setter list.
- bind let-Some(server_js)=js_value_for_dispatch() at each trampoline
  gate and reuse it instead of re-matching via js_value_assert_alive().
  Adds the gate to on_saved_request (defensive — pending_requests
  currently blocks the downgrade) and notes why on_web_socket_upgrade's
  id==0 gate is kept despite the inner re-check.
- unify on_reload_from_zig's three liveness spellings to
  js_value_for_dispatch(); fix its stale "declined to adopt" doc.
- clear ws.handler.server alongside .app in stop_listening /
  deinit_if_we_can teardown.
- on_drain: copy handler.on_drain to a stack local once, matching the
  read-once invariant the other five ws callbacks use.
- rename AnyServer::js_value_if_strong -> js_value_for_dispatch so a
  grep for the gate finds every site.
- fold the hand-expanded js_gc_route_list_set into
  cached_value_set_dispatch\! with the other 11 slots.
The on_close defers reach on_websocket_closed through handler.server, so
wiping it before app.close() stranded the live-socket count and the idle
pass never saw it drained. Fixup for the previous commit.
…503 rig

- drop the redundant serverWeak.deref()?.stop(true) in the ws GC-tracing
  test so it actually exercises the new on_websocket_closed →
  deinit_if_we_can auto-downgrade instead of masking it.
- extract the shared raw-HTTP/1.1 nextResponse parser + socket plumbing
  + hold-release-GC protocol from the two "late keep-alive 503" tests
  into a runLateKeepAlive503 helper parameterized on the server-create
  snippet.
Comment thread src/runtime/server/ServerWebSocket.rs
Comment thread test/js/bun/http/bun-server.test.ts Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/mod.rs Outdated
…ia assert_alive

ws.close/terminate read handler.server after websocket().end()/.close(),
which re-enters on_close and may run a user handler that calls stop(true)
(clearing handler.server) — copy it first so the count compensation always
fires. on_reload_from_zig is reached via a host_fn (wrapper on JS stack) or
the hot_map path (removed before downgrade), so the wrapper is alive even
when js_value is Weak; using for_dispatch() there skipped the slot writes
after stop+reload, leaving the new handlers unrooted in the heap shadow.

Also: trim stale WeakRef test prose and the deinit_running Cell doc.
Comment thread src/runtime/server/mod.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
…t_alive prose

The handler-slot writes in on_reload_from_zig switched to assert_alive but
the routeList slot write below still re-derived via for_dispatch (None on
Weak). reload_static_routes keeps for_dispatch — that path is async and
can fire post-downgrade.
Comment thread src/runtime/server/server_body.rs Outdated
@alii

alii commented Jun 15, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

✅ Diff is green. Latest: b9275c7 (re-entrant-close test now captures target ws in message() instead of by open() order). 3feca20 added the is_closed() re-check after reason toString() in ServerWebSocket::close(). All 15 handler tests + bake deinit pass on the debug build; gate test fails on the released binary. Review threads resolved.

CI red across the last five builds is unrelated Windows infra, rotating per run:

  • 71005/71014/71031: postgres-binary-array-bounds.test.ts / postgres-invalid-message-length.test.ts with ERR_POSTGRES_CONNECTION_REFUSED on Windows (PR touches no SQL; same tests flake on main builds 70800/70850)
  • 71018: napi.test.ts napi_wrap GC-timing flake on Windows (also on main 70600/70800; PR touches no NAPI)
  • 71024: darwin-14-x64 runner crashed with uv_os_get_passwd ENOENT at test 7/935 (runner-level, no junit)

Ready for a maintainer to merge.

Every caller passes Some(live_wrapper); the None arm was dead after
beaddea switched reload to js_value_assert_alive(). Drop the Option
wrapper and the vestigial 'wrapper already collected' doc clauses.

Also shorten the heapStats drain sleep (50ms -> 10ms) and give the
subprocess GC-polling tests a 15s budget to match the existing
collectContinuously cases; they were brushing the 5s default under
debug+ASAN.
robobun and others added 3 commits July 9, 2026 12:16
server_body.rs on_web_socket_upgrade: take main's request_object_ptr
provenance refactor (#33311) and keep this branch's server_js (already
resolved via js_value_for_dispatch above).

bun-server.test.ts imports: main dropped bunRun; keep this branch's
normalizeBunSnapshot.
Comment thread src/runtime/server/ServerWebSocket.rs
…ocket.close

to_slice_or_null on the reason arg can run user toString(), which may
re-entrantly call ws.close(). The inner call sets closed=true and runs
the on_websocket_closed compensation this PR added; without re-checking,
the outer call then decremented the count again (N->N-2 with >=2
sockets). Re-check the guard after coercion so only one decrement runs.

Test observes server.pendingWebSockets: 2->1 with the re-check, 2->0
without it (and 2->2 on the released binary, which had no compensation
path).
Comment thread test/js/bun/http/bun-server.test.ts
…open() order

Server-side open() ordering for two same-tick connects is poll-order
dependent across platforms; only c1 sends "do-close", so capture its
peer there instead.
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Superseded by #34346, which landed the same approach for every server-level callback. Closing.

@robobun robobun closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants