Repository navigation
Conversation
WalkthroughChangesListener GC teardown
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Beyond the inline test nits, I checked: enqueue_task from inside the sweep only writes to the task fifo (event_loop.rs:682) — no JS-heap access; the JSSocketHandlers cell survives the Listener wrapper's collection because each accepted socket's Strong-held wrapper independently caches it (socket_body.rs:1563/1579); and mark_inactive reaching back through handlers.listener between enqueue and the deferred deinit sees a still-valid heap allocation with ListenerType::None already set.
Extended reasoning...
This is a GC-finalizer / raw-pointer-ownership change on a memory-safety-critical path, so I'm not auto-approving. The mechanism looks sound and matches the ManagedTask deferral pattern already used in server/mod.rs:1799. The deliberate leak-on-is_shutting_down and the two test-quality nits are worth a human sign-off.
|
Updated 2:20 AM PT - Jul 29th, 2026
❌ @robobun, your commit ac620b4 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 36181That installs a local version of the PR into your bun-36181 --bun |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/socket/socket_body.rs:2016-2022—on_handshake(socket_body.rs:1634) has noFINALIZING || handlers.gc_closingguard, but for a TLS listenergroup.close_all()synchronously dispatches it:us_socket_close→us_internal_ssl_close(openssl.c:1848) seesssl_handshake_state != HANDSHAKE_COMPLETED→ssl_trigger_handshake_econnreset→us_dispatch_handshake→on_handshakereadshandlers.on_handshake()from theJSSocketHandlerscell mid-sweep — the exactvalidateIsNotSweepingSIGABRT this PR fixes foron_close/on_data/on_timeout. So an unref'dBun.listen({ tls })collected while an accepted socket is mid-handshake still aborts;on_handshakeneeds the same early return before the cell read (REVIEW.md: "Fix the whole class … SSL/non-SSL variants"), and the new test only exercises plain TCP.Extended reasoning...
What the bug is
The PR adds
handlers.gc_closingguards toon_close,on_data, andon_timeoutso accepted sockets bail before reading theJSSocketHandlerscell whenListener::deinitforce-closes the socket group from inside the GC sweep. Buton_handshake(socket_body.rs:1634-1762) has no such guard — noFlags::FINALIZINGcheck and nohandlers.gc_closing.get()check anywhere before line 1752'shandlers.on_handshake(). For a TLS listener,us_socket_group_close_allsynchronously dispatcheson_handshakebeforeon_closefor any accepted socket whose handshake hasn't completed, so the TLS variant of the exact assertion this PR is fixing is still reachable.Code path that triggers it
Traced end to end:
Listener::deinit(Listener.rs:904, GC-finalizer-only caller) setsgc_closing = true, then callsthis_ref.group.with_mut(|g| g.close_all())whenactive_connections > 0.us_socket_group_close_all_ex(context.c:113) iterateshead_socketsand, for each non-SEMI socket, callsus_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, 0).us_socket_close(socket.c:353):if (s->ssl && !us_socket_is_closed(s)) return us_internal_ssl_close(s, code, reason);— an accepted TLS socket hass->sslset, so it routes to the SSL close path.us_internal_ssl_close(openssl.c:1808): afterssl_update_handshake(s)at :1845, line 1848 checksif (s->ssl_handshake_state != HANDSHAKE_COMPLETED)→ line 1851ssl_trigger_handshake_econnreset(s).ssl_trigger_handshake_econnreset(openssl.c:1701-1713) callsus_dispatch_handshake(s, 0, {ECONNRESET, ...}).us_dispatch_handshakeroutes via theBunSocketTlsvtable (the accepted socket's kind was set inListener::on_create) toNewSocket<true>::on_handshake.on_handshakeat socket_body.rs:1634:has_handlers()at :1645 is true (the accepted socket holds anRc<Handlers>);this.socket.set(s)at :1649 re-arms the handle sois_detached()at :1650 is false; then line 1752handlers.on_handshake()→self.cell.on_handshake()→Bun__SocketHandlers__getField→JSCell::classInfo→validateIsNotSweeping— the exact assertion in the PR's backtrace.
Note that
ssl_update_handshakeat openssl.c:1845 can also independently reachus_dispatch_handshakeon the same call (openssl.c:1698), so there are two entry points toon_handshakeinside the same synchronousclose_all()dispatch.Why the existing guards don't cover it
- The per-socket
Flags::FINALIZINGis not set — it's theListenerbeing swept, not the acceptedNewSocket. That's exactly why the PR addedgc_closingto the sharedHandlersforon_close/on_data/on_timeout. has_handlers()(line 1645) only checks that theRc<Handlers>exists — it does, the accepted socket holds it.is_detached()(line 1650) is false because line 1649 just setthis.socketto the liveus_socket_tpassed in.on_closeis now guarded, but for a mid-handshake TLS socketon_handshakefires first in the same synchronousus_socket_closecall, so the guardedon_closenever gets a chance to save it.
Step-by-step proof (concrete trigger)
The direct TLS sibling of the PR's own repro:
const srv = Bun.listen({ hostname: '127.0.0.1', port: 0, tls: { key, cert }, socket: {...} }); srv.unref();—unref()before any accept downgradesthis_valueto weak so GC can collect the Listener wrapper.- A TLS client connects but stalls mid-handshake (e.g. a raw TCP
Bun.connectto the TLS port that never sends a ClientHello, or a TLS client that connects and then does nothing). Server-side accept fireson_open→active_connectionsbecomes 1;ssl_handshake_statestaysHANDSHAKE_PENDING. Bun.gc(true)collects the Listener wrapper →Listener::finalize→deinit(mid-sweep).active_connections > 0→group.close_all().- Steps 2-7 of the code path above execute synchronously inside the sweep; line 1752 reads the cell →
validateIsNotSweepingSIGABRT (exit 134 in release,ASSERTION FAILEDin release-asan).
Impact
Same as the bug the PR fixes — a release-build SIGABRT — but for
Bun.listen({ tls })instead of plain TCP. The mid-handshake window is narrower than TCP-open, but it's a real steady-state condition (any client that connects and never completes the handshake leaves the accepted socket in this state indefinitely). REVIEW.md's "Fix the whole class in the same PR … SSL/non-SSL variants" names this exact sibling-site pattern as required scope, and the new test insocket.test.tsonly exercises plain TCP so it does not cover this variant.Fix
Add the same guard at the top of
on_handshake, beforeref_guard()and before anyhandlers.*cell read:pub fn on_handshake(...) -> JsResult<()> { jsc::mark_binding!(); if !this.has_handlers() { return Ok(()); } let handlers = this.get_handlers(); if this.flags.get().contains(Flags::FINALIZING) || handlers.gc_closing.get() { return Ok(()); } this.update_flags(|f| f.insert(Flags::HANDSHAKE_COMPLETE)); ...
(hoist
get_handlers()above the flag update so the guard runs before any state mutation, matchingon_close's ordering). Ideally also add a TLS variant of the new test — same shape withtls: { key, cert }on the listener and a raw-TCP client that connects without handshaking — to cover it. -
🔴
src/runtime/socket/Listener.rs:903-905— Settinggc_closing = trueunconditionally on the sharedRc<Handlers>inListener::deinitregresses the Windows named-pipe path: accepted named-pipe connections are not in the embeddedSocketGroup(that branch oflisten()never callsgroup.init(); accepts go throughWindowsNamedPipeContextwith their ownuv_pipe), soclose_all()is a no-op and those connections stay open withgc_closingpermanently true. The new guards atsocket_body.rs:2106/:2019/:978then silently drop every subsequentdata,close, andtimeoutevent on connections that are still receiving bytes — before this PR they were unaffected by the listener being GC'd. Consider scopinggc_closingto bracket only the synchronousclose_all()dispatch (set →close_all()→ clear), or gating the.set(true)on the non-named-pipe path — REVIEW.md's "fix the whole class in the same PR / POSIX/Windows branches" applies here.Extended reasoning...
What the bug is
Listener::deinit(Listener.rs:904) now setsthis_ref.handlers.gc_closing.set(true)unconditionally on the sharedRc<Handlers>, and the flag is never cleared. For a uws/TCP listener this is fine:group.close_all()on the next line synchronously closes every accepted socket, so no further events can arrive on thatHandlersafterdeinitreturns.But on the Windows named-pipe listener path (
ListenerType::NamedPipe), accepted connections are not members of the embeddedSocketGroup:- The named-pipe branch of
Listener::listen()constructsgroup: JsCell::new(uws::SocketGroup::default())and returns before ever reaching thegroup.init()/register_root_regioncalls (Listener.rs:212-290). - Accepts go through
WindowsNamedPipeListeningContext::on_client_connect→Listener::on_name_pipe_created→WindowsNamedPipeContext::create, which drives its ownuv_pipehandle and never links the connection intolistener.group. - Each accepted named-pipe socket clones
Rc::clone(&listener.handlers)(Listener.rs:527) — the sameHandlersinstance whosegc_closingcelldeinitjust flipped.
So in
deinit,group.close_all()iterates a default/empty group (a no-op), andfinalize'sclose_pipe_and_deinitcloses only the listeninguv_pipe, not the accepted connections. The accepted named-pipe sockets remain open with their sharedHandlerspermanently markedgc_closing == true.The code path that triggers it
WindowsNamedPipeContext::on_data(WindowsNamedPipeContext.rs:184-191),on_timeout(:254-260), andon_close(:263-278) all forward viamatch_socket!toNewSocket::on_data/on_timeout/on_close— the exact functions this PR added|| handlers.gc_closing.get()guards to (socket_body.rs:2106, :978, :2019). Every subsequent data payload, timeout, and peer-disconnect event on those still-live connections is silently dropped before the callback read.Why existing code doesn't prevent it
Before this PR, named-pipe connections were unaffected by the listener being GC'd. The crash the PR fixes is uws-group-specific:
us_socket_group_close_allsynchronously dispatcheson_closefrom inside the sweep, and that mid-sweep cell read is what tripsvalidateIsNotSweeping. Named-pipe events fire from the libuv event loop on a normal tick, not from inside the sweep, so the assertion never applied there — and each accepted socket's own JS wrapper roots theJSSocketHandlerscell viaget_this_value→handlers_set_cached(socket_body.rs:1566), so the cell read was safe. The new guard suppresses a path that was already sound.Step-by-step proof
- On Windows:
const srv = Bun.listen({ unix: '\\\\.\\pipe\\foo', socket: { data(s, d) { got += d.length }, ... } }); srv.unref();—unref()withactive_connections == 0downgradesthis_valueto weak (Listener.rs:1000). Drop the JS reference tosrv. - A client connects:
on_name_pipe_createdclonesRc::clone(&listener.handlers)into the acceptedNewSocketand incrementsactive_connections. Nothing re-upgrades the listener'sthis_value. - GC runs: the
Listenerwrapper is collected →finalize→ListenerType::NamedPipearm callsclose_pipe_and_deinit(closes only the listening handle) →Self::deinit(this). deinitsetsgc_closing = trueon the sharedHandlers;active_connections > 0so it callsgroup.close_all(), but the group was neverinit()'d and holds no sockets — no-op. The Listener frees itself.- The client writes bytes. libuv delivers them →
WindowsNamedPipeContext::on_data→NewSocket::on_data→handlers.gc_closing.get()is true → early return. The serverdatacallback never fires; bytes are silently discarded on an open, receiving socket. - When the client eventually disconnects,
on_closehits the same guard at socket_body.rs:2019 → the serverclosecallback never fires either.
This is the same
unref()'d-listener-with-live-connections timeline the PR's own TCP test exercises, just withunix: '\\\\.\\pipe\\...'on Windows.Impact
Silent data loss on live connections — a Windows-only behavior regression on the sibling transport of the exact scenario this PR fixes for TCP. The PR's rationale ("firing the user's close handler when GC happens to collect it is not useful") justifies suppressing
on_closeduring the synchronous sweep dispatch, but silently droppingon_dataon a connection that is still open and receiving bytes is a different, user-visible failure. REVIEW.md: "Fix the whole class in the same PR … POSIX/Windows branches, SSL/non-SSL variants."How to fix
Scope
gc_closingto the synchronousclose_all()dispatch only — that is the one path that runs from inside the sweep:this_ref.handlers.set_listener(None); if this_ref.handlers.active_connections.get() > 0 { this_ref.handlers.gc_closing.set(true); this_ref.group.with_mut(|g| g.close_all()); this_ref.handlers.gc_closing.set(false); }
This preserves the fix for the uws path (the guarded
on_closecalls happen synchronously insideclose_all()) while leaving named-pipe connections — whose events arrive later from the libuv loop, outside the sweep — able to read the cell as before. Alternatively, gate theset(true)on!matches!(listener, ListenerType::NamedPipe(_)), but bracketing the flag around the synchronous dispatch is the more precise expression of the invariant. - The named-pipe branch of
|
Addressed both review findings:
The TLS mid-handshake shape is tracked as #6530 per the thread; the |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Not correct. The listener finalizing does not mean the sockets within are finalizing.
|
7d7e35a reverts to the event-loop deferral: the accepted sockets are not being finalized, so their |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/socket/Listener.rs:900-902— Settinggc_closingunconditionally inListener::deinitregresses the Windows named-pipe path: accepted named-pipe sockets areInternalSocket::Pipe(never linked into theus_socket_group, which is left atSocketGroup::default()), soclose_all()is a no-op and the connections stay open withgc_closingstucktrueon their sharedHandlers— from then onon_data/on_end/on_handshake/on_timeout/on_closeall bail silently and incoming bytes are dropped on the floor. Pre-PR this scenario worked (nothing on the named-pipe finalizer path dispatches synchronously mid-sweep, so the assertion being fixed was never reachable there); either gate the flag onListenerType::Uws/ a non-empty group, or also close the accepted named-pipe connections sogc_closingis only ever observed by sockets actually being torn down.Extended reasoning...
What the bug is
Listener::deinit(Listener.rs:900) now doesthis_ref.handlers.gc_closing.set(true)unconditionally, regardless ofListenerType. On the TCP/unix-socket path this is correct: every accepted socket is aus_socket_tlinked into the embeddedus_socket_group, so the immediately-followinggroup.close_all()synchronously force-closes each one, andgc_closingis only observed byon_closeduring that sweep-driven teardown.On the Windows named-pipe path the picture is different. The named-pipe branch of
Listener::listen(Listener.rs:~208-320) constructs the listener withgroup: JsCell::new(uws::SocketGroup::default())and returns before ever callinggroup.init()— the group stays all-zeroed (SocketGroup.rs:78-81,head_sockets == null). Each accepted connection is created viaon_name_pipe_created→WindowsNamedPipeContext::create, and itsNewSocketwrapper stores anInternalSocket::Pipe, not aus_socket_tlinked into the group. Andclose_pipe_and_deinit(called fromfinalizeforListenerType::NamedPipe) only schedules an asyncuv_closeof the listeninguv_pipe; the accepted client pipe handles are independent libuv handles and stay open.The specific code path
Bun.listen({ unix: '\\\\.\\pipe\\foo', socket: {...} });srv.unref()before the first accept.unref()(Listener.rs:993-996) seesactive_connections == 0and downgradesthis_valueto weak.- A client connects.
on_client_connect→on_name_pipe_createdclonesRc::clone(&listener.handlers)into the acceptedNewSocket(Listener.rs:557), andNewSocket::on_open→handlers.mark_active()bumpsactive_connectionsto 1 — but nothing re-upgrades the Listener'sthis_value, so it remains weakly held. - The user drops the JS reference to
srv. GC sweeps the Listener wrapper. Listener::finalizeruns:listener.replace(None)→close_pipe_and_deinit(named_pipe)(async close of the listening handle only) →Self::deinit(Box::into_raw(self)).deinit:set_listener(None);gc_closing.set(true);active_connections == 1 > 0sogroup.close_all()— but on the never-init()'d default group this walks a nullhead_socketsand does nothing. The Listener is freed.- The accepted
NewSocketstill holds itsRc<Handlers>clone, now withgc_closing == truepermanently set. Its libuv pipe handle is still open and reading.
From this point on, every dispatch from
WindowsNamedPipeContext(WindowsNamedPipeContext.rs:187/212/223/257/272) into the sharedNewSocket::on_*hits the new guard:on_data(socket_body.rs:2115):handlers.gc_closing.get()→return— incoming bytes silently dropped.on_end(socket_body.rs:1611): bails afterunref+mark_inactive, never calls the user'sendhandler.on_handshake/on_timeout: return silently.on_close(socket_body.rs:2028): bails throughCloseTeardownwithout ever invoking the user'sclosehandler.
The connection is neither closed nor reported closed — it just goes deaf.
Why existing code doesn't prevent it
The PR's rationale — "a dropped, never-
stop()'d listener is a leak scenario; firing the user'sclosehandler when GC happens to collect it is not useful behaviour to preserve" — is about closing connections. It does not cover a connection that is not being closed and continues receiving data. TheFlags::FINALIZINGhalf of the guard is per-socket and correctly scoped;gc_closingis per-Handlersand now leaks onto sockets the finalizer never touches.Nothing on the named-pipe finalizer path dispatches synchronously into JS mid-sweep:
close_pipe_and_deinitschedules an asyncuv_closeandclose_all()is a no-op. So thevalidateIsNotSweepingassertion this PR fixes was never reachable on the named-pipe path — meaning the flag guards nothing there and only breaks previously-working behavior.Step-by-step proof it worked pre-PR
Before this PR, in the same scenario,
deinitdidset_listener(None)and calledclose_all()on the empty group (no-op) —gc_closingdid not exist. The accepted named-pipe socket kept dispatching normally: its ownRc<Handlers>keeps theHandlersalive, and its JS wrapper's visitedhandlersslot (set viaget_this_value→handlers_set_cached, socket_body.rs:1566, called fromon_open) keeps theJSSocketHandlerscell alive independently of the freed Listener wrapper.on_datareadhandlers.on_data()from that cell and called the user's handler;on_closeeventually fired the user'sclose. All of that ran on the event loop, not inside the sweep.Impact
Windows-only silent-data-loss regression on the sibling transport of the path this PR fixes. The trigger (unref-before-first-accept + drop + GC) is exactly the scenario the PR's own new test exercises for TCP. Per REVIEW.md's "fix the whole class in the same PR … POSIX/Windows branches, SSL/non-SSL variants" this sibling must be handled.
How to fix
Two options:
- Gate the flag — only set
gc_closingwhen the group actually has sockets thatclose_all()will synchronously dispatch (e.g. only on theListenerType::Uwspath, or only whengroupwasinit()'d /head_socketsis non-null). There is nothing to guard on the named-pipe path — no synchronous dispatch happens from the finalizer there. - Also close the accepted named-pipe connections in
deinitsogc_closingis only ever observed by sockets actually being torn down (matching the TCP semantics the flag was designed for).
|
The named-pipe |
7d7e35a to
c271b8c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/socket/socket_body.rs`:
- Around line 1665-1667: Remove the handlers.gc_closing early returns in
src/runtime/socket/socket_body.rs lines 1665-1667 and 2020-2022, retaining only
socket finalization protection so on_handshake and on_close still execute after
deferred listener teardown. Update test/js/bun/net/socket.test.ts lines
3377-3384 to assert normal server-side close delivery, and lines 3429-3437 to
verify server acceptance/handshake before asserting the deferred TLS
close/handshake callbacks; ensure both tests validate their setup preconditions.
🪄 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: e4e4a720-aca9-4c03-858f-cce4528d6ff9
📒 Files selected for processing (4)
src/runtime/socket/Handlers.rssrc/runtime/socket/Listener.rssrc/runtime/socket/socket_body.rstest/js/bun/net/socket.test.ts
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/socket/Listener.rs`:
- Line 870: In the deinit ownership round-trip, replace the standard
Box::into_raw conversion with bun_core::heap::into_raw so it matches the
existing bun_core::heap::take reclamation; preserve the current raw-pointer flow
and use the established heap ownership helpers.
In `@test/js/bun/net/socket.test.ts`:
- Line 3366: Replace the TCP polling loop at test/js/bun/net/socket.test.ts:3366
and the corresponding TLS loop at test/js/bun/net/socket.test.ts:3415 with
event-driven synchronization: await each connection’s open event, trigger GC,
and await promises resolved when the expected server and client close counts are
reached. Remove timing-based await sleep(N) waits while preserving the existing
close-count expectations.
🪄 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: bb1920cf-7c34-4948-86c5-122a99f0ad3a
📒 Files selected for processing (2)
src/runtime/socket/Listener.rstest/js/bun/net/socket.test.ts
387779e to
1a75521
Compare
…event loop A Bun.listen() server that is unref()'d, never stop()'d, and dropped while accepted connections are still live is collected; Listener::finalize calls deinit, which force-closes the embedded socket group. us_socket_group_close_all synchronously dispatches each accepted socket's on_close (and for TLS, a synthetic ECONNRESET on_handshake via us_internal_ssl_close when the server-side handshake has not yet completed), both of which read the JS handler from the JSSocketHandlers cell. Reading a JSCell from inside the GC sweep trips JSC's validateIsNotSweeping assertion; stock release bun SIGABRTs deterministically. The accepted sockets are not themselves being finalized, so their handlers should still fire. Defer deinit (and with it close_all) to the event loop via a ManagedTask when there are live connections, so every dispatch runs outside the sweep. The swept JSListener wrapper's JsRef and the Handlers back-pointer are retired inline so nothing between the sweep and the deferred task can observe the dead wrapper. Two tests in test/js/bun/net/socket.test.ts (plain-TCP and TLS) spawn a child that drops three such listeners and drives GC until both sides see the close; both SIGABRT on stock (exit 134, empty stdout) and print serverClose=3 clientClose=3 with the fix.
1a75521 to
23aee8d
Compare
|
Rebased onto 0316b6f and squashed to one commit (23aee8d). Current state per the review: Both the plain-TCP and TLS tests assert |
There was a problem hiding this comment.
No new findings after the rebase; all earlier review threads are addressed. Deferring to a human because this touches GC-finalizer / raw-pointer lifetime in Listener::finalize with a deferred heap::take and an intentional leak on the shutdown path — worth a maintainer's eyes even though the mechanism looks correct.
What was reviewed: the hoisted this_value.finalize() / set_listener(None) close the stale-socket.listener window before the ManagedTask runs; the updated Handlers::listener() SAFETY block names that as the invariant; the is_shutting_down() leak branch's fds are reclaimed by close_all_socket_groups on worker teardown; both tests gate on server open and poll (serverClose < 3 || clientClose < 3) so neither can pass vacuously.
Extended reasoning...
Overview
The PR fixes a SIGABRT (validateIsNotSweeping) when a dropped, unref()'d Bun.listen() server is GC'd while it still has live accepted connections. Listener::finalize previously ran deinit inline from inside the JSC sweep; deinit's close_all() synchronously dispatches each accepted socket's on_close (and for TLS, a synthetic on_handshake), which reads the JSSocketHandlers cell — a cell read mid-sweep trips JSC's assertion. The fix: inside finalize, hoist this_value.finalize() and handlers.set_listener(None) (plain Rust writes, no JSC heap access), then when active_connections > 0 enqueue deinit as a ManagedTask so close_all() runs from the event loop instead of the sweep. On vm.is_shutting_down() the box is intentionally leaked (event loop won't tick; fds are reclaimed by process exit or the worker's close_all_socket_groups). Files: src/runtime/socket/Listener.rs (finalize logic), src/runtime/socket/Handlers.rs (SAFETY-comment update only), two new subprocess tests in test/js/bun/net/socket.test.ts (TCP + TLS).
Security risks
None identified. This is an internal lifecycle/GC-safety fix; no user-controlled input reaches the changed code, no auth/crypto/permissions surface.
Level of scrutiny
High. This is native memory-safety code in a GC finalizer: raw Box::into_raw ownership handoff to a deferred task, an intentional leak branch, and a raw-pointer SAFETY invariant (Handlers::listener()) whose justification changed. Prior review rounds already caught and fixed a real stale-JsRef::Weak window (the un-hoisted this_value.finalize()), a stale SAFETY comment, and test-flake shapes (missing accept-gate, poll condition not covering clientClose). The remaining acknowledged trade-offs — a small heap-box leak on worker termination and the ManagedTask having no cleanup fn on the enqueue-then-shutdown TOCTOU — match the existing server/mod.rs schedule_deinit precedent and are not regressions (pre-PR this path SIGABRTed).
Other factors
The bug hunter found nothing new on the squashed commit. All eight of my earlier inline findings are resolved (one nit — the TCP fixture's srv-reachability shape — was reasonably declined as zero practical risk). The PR description was re-synced to the shipped approach. The two tests spawn subprocesses, drain pipes concurrently, gate on the actual observable condition (server open count, then bounded poll on both close counters), and assert a strict {stdout, stderr, exitCode} object; the author reports 10/10 stable and SIGABRT on USE_SYSTEM_BUN=1. Given the domain (GC finalizer + deferred native teardown + intentional leak), a maintainer sign-off is still appropriate rather than a bot approval.
There was a problem hiding this comment.
No new findings after the rebase/squash; all earlier threads are resolved. This is a GC-finalizer / raw-pointer-ownership change (deferring deinit to a ManagedTask from inside the sweep, with an intentional leak on the is_shutting_down() path), so it warrants a human sign-off on the memory-safety reasoning.
Checked this pass: the hoisted this_value.finalize() / set_listener(None) close the socket.listener stale-wrapper window; enqueue_task from a finalizer matches the server/mod.rs schedule_deinit precedent; the updated Handlers::listener() SAFETY block names the new invariant; both TCP and TLS tests gate on server open and poll (serverClose < 3 || clientClose < 3).
Extended reasoning...
Overview
The PR fixes a SIGABRT when a dropped, unref()'d Bun.listen() server is GC'd while it still has live accepted connections. Pre-PR, Listener::finalize called deinit inline from the sweep; deinit → close_all() synchronously dispatched each accepted socket's on_close (and for TLS, a synthetic on_handshake), which read the JSSocketHandlers cell and tripped JSC's validateIsNotSweeping assertion. The fix: inside finalize, hoist this_value.finalize() + handlers.set_listener(None) (plain Rust writes, no JSC heap access), then when active_connections > 0 enqueue deinit as a ManagedTask so close_all() runs from the event loop. On vm.is_shutting_down() the Listener box is leaked (fds reclaimed by process exit or the worker's close_all_socket_groups). Handlers::listener()'s SAFETY block is reworded to cite the new invariant. Two subprocess tests (plain-TCP and TLS) exercise the crash and assert the deferred handlers fire (serverClose=3 clientClose=3).
Security risks
None identified. No user-controlled input parsing, no auth/crypto/permissions surface. The change is confined to finalizer scheduling and back-pointer clearing.
Level of scrutiny
High. This is native memory-safety code at the JSC-GC boundary: a finalizer running inside the sweep hands a raw *mut Listener to the event loop, changes the ownership contract documented in an unsafe SAFETY block, and deliberately leaks on one branch. The reasoning is sound and matches the existing server/mod.rs::schedule_deinit pattern, but this is exactly the class of change ("never let a pointer or slice outlive the memory it points into", "reference counts provably balanced on every terminal path") that REVIEW.md calls out for careful human review.
Other factors
I left four rounds of inline feedback earlier (stale-JsRef window, TLS accept-gate, poll-condition, SAFETY-comment sync, PR-description sync); all are addressed and resolved. One consistency nit (mirroring the TCP fixture's srv reachability guard) was declined with a reasonable justification (no auto-GC fires in the connect window of a fresh bun -e process). The remaining acknowledged follow-up — the worker-termination heap leak and the ManagedTask::new (no cleanup fn) TOCTOU on enqueue-then-shutdown — is not a regression (pre-PR that path SIGABRTed) and matches existing precedent, but a maintainer should confirm they're comfortable landing it as-is with the follow-up deferred. The bug-hunting system found nothing this run.
|
CI is blocked on build-cpp agent availability, not this diff: in builds 84660 and 84753 the Rust compile succeeded on every lane that ran, but 7+ |
…stening (#36930) ## Reproduction ```js function makeUnreffedListener() { const l = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { open(s) { s.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); s.end(); }, data() {}, }}); const port = l.port; l.unref(); // still listening; only the event-loop hold should drop return port; } const port = makeUnreffedListener(); Bun.gc(true); await Bun.sleep(1); Bun.gc(true); console.log(await fetch("http://127.0.0.1:" + port + "/").then(r => r.text())); ``` Stock bun (release): `ConnectionRefused` once the collected wrapper's finalizer closes the still-listening socket. A fuzz variant that hammers the port while GC is in flight instead lands in the sweep window: 10/10 within 30s as a silent SIGABRT, `TypeError: r.text is not a function` on the fetch result, or another listener's `open` handler answering on the dropped listener's port. Under ASan the dispatch faults in `Bun__SocketHandlers__getField` (`JSSocketHandlers.cpp`) or as a UBSan member call on null ClassInfo in `Bun__JSValue__call`, reached from `NewSocket<false>::on_open` via `us_internal_dispatch_ready_poll`, i.e. a loop dispatch on the swept handlers cell before the finalizer ever runs. ## Cause `Listener::unref` released the event-loop ref and then downgraded `this_value` to weak whenever `active_connections == 0`, while the listen socket was still open. That contradicts the field's own contract ("Strong while it is listening or has connections"): the wrapper's `handlers` slot is the only GC root of the `JSSocketHandlers` cell that every accept dispatches into. With the wrapper weak and the JS reference dropped, GC collects both while the socket keeps accepting, so `on_open` for the next connection reads a swept cell; after the finalizer runs, the port is closed outright, which is not Node semantics either (libuv keeps unref'd servers accepting). ## Fix `unref()` now only does `poll_ref.unref()`. The idle downgrade already lives in the two places that are gated on the listen socket being closed: `do_stop` (close with no connections) and `Handlers::mark_inactive` (last connection drains after a close). A stopped or idle listener is still collected exactly as before; a listening one stays reachable until `stop()`, and the process can still exit because the event-loop ref is released. This is a different site from #36181, which defers the finalizer's socket-group close out of the GC sweep when a collected listener still has live connections: that covers the teardown dispatch during finalization, while this bug fires on a fresh accept before any finalizer runs and needs no connections at drop time. Both changes stand on their own. ## Verification New test in `test/js/bun/net/socket.test.ts` spawns a child that drops four unref'd listeners, drives GC with allocation churn, and fetches every port each round; the child must serve every request and then exit on its own (proving `unref()` still releases the loop). `USE_SYSTEM_BUN=1`: child gets `ConnectionRefused` on the first collected listener, test fails (also fails on an unfixed debug build, 1.5s). `bun bd test`: passes (4.1s, ASan). The 30s fuzz hammer on the fixed debug build: 18 rounds, 80/80 requests served, no crash, no wrapper collected while listening. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts <!-- robobun:evidence:end -->
…stening (oven-sh#36930) ## Reproduction ```js function makeUnreffedListener() { const l = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { open(s) { s.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); s.end(); }, data() {}, }}); const port = l.port; l.unref(); // still listening; only the event-loop hold should drop return port; } const port = makeUnreffedListener(); Bun.gc(true); await Bun.sleep(1); Bun.gc(true); console.log(await fetch("http://127.0.0.1:" + port + "/").then(r => r.text())); ``` Stock bun (release): `ConnectionRefused` once the collected wrapper's finalizer closes the still-listening socket. A fuzz variant that hammers the port while GC is in flight instead lands in the sweep window: 10/10 within 30s as a silent SIGABRT, `TypeError: r.text is not a function` on the fetch result, or another listener's `open` handler answering on the dropped listener's port. Under ASan the dispatch faults in `Bun__SocketHandlers__getField` (`JSSocketHandlers.cpp`) or as a UBSan member call on null ClassInfo in `Bun__JSValue__call`, reached from `NewSocket<false>::on_open` via `us_internal_dispatch_ready_poll`, i.e. a loop dispatch on the swept handlers cell before the finalizer ever runs. ## Cause `Listener::unref` released the event-loop ref and then downgraded `this_value` to weak whenever `active_connections == 0`, while the listen socket was still open. That contradicts the field's own contract ("Strong while it is listening or has connections"): the wrapper's `handlers` slot is the only GC root of the `JSSocketHandlers` cell that every accept dispatches into. With the wrapper weak and the JS reference dropped, GC collects both while the socket keeps accepting, so `on_open` for the next connection reads a swept cell; after the finalizer runs, the port is closed outright, which is not Node semantics either (libuv keeps unref'd servers accepting). ## Fix `unref()` now only does `poll_ref.unref()`. The idle downgrade already lives in the two places that are gated on the listen socket being closed: `do_stop` (close with no connections) and `Handlers::mark_inactive` (last connection drains after a close). A stopped or idle listener is still collected exactly as before; a listening one stays reachable until `stop()`, and the process can still exit because the event-loop ref is released. This is a different site from oven-sh#36181, which defers the finalizer's socket-group close out of the GC sweep when a collected listener still has live connections: that covers the teardown dispatch during finalization, while this bug fires on a fresh accept before any finalizer runs and needs no connections at drop time. Both changes stand on their own. ## Verification New test in `test/js/bun/net/socket.test.ts` spawns a child that drops four unref'd listeners, drives GC with allocation churn, and fetches every port each round; the child must serve every request and then exit on its own (proving `unref()` still releases the loop). `USE_SYSTEM_BUN=1`: child gets `ConnectionRefused` on the first collected listener, test fails (also fails on an unfixed debug build, 1.5s). `bun bd test`: passes (4.1s, ASan). The 30s fuzz hammer on the fixed debug build: 18 rounds, 80/80 requests served, no crash, no wrapper collected while listening. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts <!-- robobun:evidence:end -->
Reproduction
Stock bun (release): SIGABRT, exit 134, 3/3. The plain-TCP shape (no
tls:on either side) crashes the same way.release-asan:
ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping(JSCell.cpp:179).Backtrace (plain-TCP leg):
Heap::sweepInFinalize -> JSDestructibleObjectDestroyFunc -> Listener::finalize -> Listener::deinit -> us_socket_group_close_all -> NewSocket::on_close -> Bun__SocketHandlers__getField -> JSCell::classInfo -> validateIsNotSweeping.TLS leg: same chain via
us_internal_ssl_close -> ssl_trigger_handshake_econnreset -> us_dispatch_handshake -> NewSocket::on_handshake -> Bun__SocketHandlers__getField(index=8). Which leg trips first is ordering-dependent.Cause
When a dropped,
unref()'dBun.listen()server is collected while it still has live accepted connections,Listener::finalizecallsdeinit, which force-closes the embedded socket group.us_socket_group_close_allsynchronously dispatches each accepted socket'son_close, and for TLS socketsus_internal_ssl_closeadditionally fires a syntheticon_handshake(0, ECONNRESET)for any accepted socket whose server-side handshake has not yet completed. Both dispatchers read their JS handler from theJSSocketHandlerscell; theclassInforead trips JSC'svalidateIsNotSweepingassertion.Fix
The accepted sockets are not themselves being finalized (only the Listener wrapper is), so their handlers should still fire. Defer
deinit(and with itclose_all()) to the event loop via aManagedTaskwhen there are live connections; the dispatch then runs outside the sweep and the user'sclose/handshakecallbacks fire normally. The sweptJSListenerwrapper'sJsRefand theHandlersback-pointer are retired inline (plain Rust state, no JSC heap access) so nothing between the sweep and the deferred task can observe the dead wrapper viasocket.listener. The accepted sockets' own Strong-held wrappers keep the handlers cell alive across the deferral.When the VM is shutting down the event loop won't tick again; in that case the Listener is leaked and its sockets are reclaimed by process exit or by the worker's
close_all_socket_groups.Verification
Two tests in
test/js/bun/net/socket.test.ts, one plain-TCP and one TLS, each spawn a child that drops three such listeners and drives GC until both sides observe the close.USE_SYSTEM_BUN=1: both children SIGABRT (exitCode: 134, empty stdout), both tests fail.bun bd: both printserverClose=3 clientClose=3, exit 0, both pass (10/10 stable).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/net/socket.test.ts