Repository navigation
Conversation
server.unref() currently drops the server's only event-loop ref. If a handler is suspended on something that is itself unref'd (an unref'd timer, a long-poll event), the process exits mid-handler and the client sees ECONNRESET. Give the server a second KeepAlive that is ref'd while pending_requests is nonzero, separate from the listener's poll_ref. server.unref() then only releases the listener's hold; in-flight requests keep the process alive on their own, matching Node (where an accepted connection is its own handle). The same pattern already landed for node:net in Listener::on_create. Also route the websocket-upgrade fallback through on_pending_request() instead of bumping pending_requests directly so the ref stays balanced with the on_request_complete() decrement in RequestContext::deinit.
|
Reproduced with the fixture at 3d9b699 adds a Known edge: an unref'd server whose direct-stream CI: diff is green. Build 86321 had all test lanes pass (including the 7 where serve-body-leak.test.ts previously timed out); only red was |
WalkthroughChangesThe server now uses a dedicated In-flight request lifecycle
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
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 `@test/js/bun/http/bun-server.test.ts`:
- Around line 509-527: Update the subprocess test around the child process setup
to start draining proc.stderr and waiting on proc.exited immediately after
spawning, while retaining the stdout reader for port discovery. Await the fetch
response, then assert the response body together with the already-started stderr
and exit-code results as one combined outcome, ensuring stdout, stderr, and
process completion are handled concurrently.
🪄 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: 00fc0ce0-b84f-4855-84df-0fe4568553f4
📒 Files selected for processing (4)
src/runtime/server/mod.rssrc/runtime/server/server_body.rstest/js/bun/http/bun-server.test.tstest/js/bun/http/unref-fixture-3.ts
There was a problem hiding this comment.
No issues found — the ref/unref edges look balanced and KeepAlive is idempotent, so I don't see a leak or double-unref path. Deferring because this is a user-visible semantic change to server.unref() (in-flight requests now hold the loop independently) on the hot request-dispatch path; worth a maintainer confirming that's the intended contract.
What was reviewed:
- Audited every
pending_requestsmutation site — all now go throughon_pending_request/on_request_completeexcept the fully-synchronouson_bun_info_requestpair, which never yields. - Checked
stop(true)/deinit_if_we_can/schedule_deinit— all gate onpending_requests == 0, soin_flight_keep_aliveis already unref'd before deinit;poll_refhas the same (no explicit disable in Drop) so no new lifecycle gap. - Confirmed
server.ref()/unref()only touchpoll_ref, so the new keep-alive is independent as intended.
Extended reasoning...
Overview
Adds a second KeepAlive (in_flight_keep_alive) to NewServer, ref'd on the pending_requests 0→nonzero edge in on_pending_request() and unref'd on the nonzero→0 edge in on_request_complete(). Also routes the websocket-upgrade fallback in server_body.rs through on_pending_request() instead of a raw pending_requests += 1 so it participates in the edge-triggered ref. Ships a subprocess test + fixture that reproduces the ECONNRESET on system bun and verifies a 200 + clean exit with the fix.
Security risks
None. No new inputs, no parsing, no auth/crypto surface. The unsafe blocks match the existing // SAFETY idiom used two lines away for poll_ref (dereferencing the per-thread VM singleton backref).
Level of scrutiny
Medium-high. The diff is small (~15 native lines) and mechanically simple, but it sits on the per-request hot path of Bun.serve and changes what server.unref() means: previously it released the server's only loop hold, now in-flight requests hold the loop on their own. That matches Node's behavior (each accepted socket is its own handle) and node:http in Bun already does this via NodeHTTPResponse's per-request jsc::Ref, but for Bun.serve's native fetch path it's a new contract.
Other factors
- I traced every
pending_requests += 1/-= 1site. After this PR the only raw pair left ison_bun_info_request(server_body.rs:2900/2923), which is fully synchronous — no event-loop yield between the increment and decrement, and no interleaving withon_request_complete— so skipping the keep-alive there is fine and can't unbalance the edge trigger. KeepAlive::ref_/unrefare state-gated (Active/Inactive/Done), not counted, so an unmatched call is a no-op rather than a corruption; the edge-trigger design means the state and the counter stay in sync as long as every async increment goes throughon_pending_request, which it now does.deinit_if_we_canandschedule_deinitboth gate onpending_requests == 0, andon_request_completeunrefs before callingdeinit_if_we_can, so the new field is always Inactive by the time the server box is dropped.poll_reffollows the same pattern (no explicitdisable()indeinit/Drop), so this introduces no new teardown gap.- Test looks solid: port 0, subprocess with
bunEnv, stderr/exit drained from spawn, combinedtoEqualassertion. The 200 ms unref'd timer in the fixture is the whole point of the repro (nothing else holds the loop), not a flaky sleep-then-check. - Both prior review comments (concurrent stderr drain, long doc comment) are addressed in ebae380 / 88d2b6b.
stop(true) already drops poll_ref unconditionally; do the same for the in-flight-request keep-alive. A request context whose stream promise never settles (e.g. a parked direct-stream pull()) does not reach on_request_complete on abort, so pending_requests can stay nonzero past an abrupt stop. Without this, the serve-body-leak test's subprocess never exited.
There was a problem hiding this comment.
I didn't find any bugs. Deferring because this changes event-loop lifetime semantics in the core Bun.serve request path and carries an acknowledged behavioral tradeoff (the parked direct-stream + client-disconnect edge now keeps the process alive instead of exiting with a leaked counter) — worth a maintainer sign-off on that and on the server-level edge-triggered KeepAlive vs. a per-RequestContext ref.
What was reviewed:
on_pending_request/on_request_completeedge-triggering vs.KeepAlive's Active/Inactive/Done state machine — balanced, anddisable()on abrupt stop makes laterref_()/unref()no-ops.- The one remaining raw
pending_requests += 1site (on_bun_info_request) is fully synchronous, so skipping the helper there is harmless. - The
stop(true)-after-stop(false)early-return (listener already None, non-H3) leavesin_flight_keep_aliveuntouched — same as pre-existing behavior forpoll_ref/TERMINATEDon that path; ruled out as a regression.
Extended reasoning...
Overview
Adds a second KeepAlive (in_flight_keep_alive) to NewServer, ref'd on the pending_requests 0→nonzero edge in on_pending_request() and unref'd on nonzero→0 in on_request_complete(), so server.unref() (which only clears poll_ref) no longer lets the process exit while a handler is suspended on an unref'd awaitable. Also disable()s it on abrupt stop (both the H3 and listener-present arms of stop_listening), and routes the websocket-upgrade fallback (server_body.rs:3425) through on_pending_request() so its increment pairs with the on_request_complete() decrement in RequestContext::deinit. Test: subprocess fixture that unref's the server inside the handler and awaits an unref'd timer; parent asserts 200 body + clean exit.
Security risks
None. This is event-loop ref-counting; no untrusted input parsing, auth, or data exposure.
Level of scrutiny
High. This is process-lifetime behavior in the core HTTP server. KeepAlive is not a counter — it's a tri-state (Active/Inactive/Done), so the edge-triggered ref/unref must be exactly right, and every pending_requests mutation site must either use the helper or be provably synchronous. I verified: the only remaining raw += 1 (on_bun_info_request) is bracketed by a raw -= 1 in the same frame with no yield point, so it can't affect loop liveness. All other increment sites go through on_pending_request(), and all decrements go through on_request_complete() / on_static_request_complete(). deinit_if_we_can() only schedules teardown when pending_requests == 0, at which point in_flight_keep_alive is already Inactive (or Done after abrupt stop), so no leaked loop ref at destructor time.
Other factors
- The PR body openly documents a behavior change on a pre-existing counter leak (unref'd server + parked direct-stream
pull()+ client disconnect + nostop(true)): previously the process exited with a leakedpending_requests; now it stays alive. That's arguably more correct (Node semantics: in-flight work holds the loop), but it's a user-observable tradeoff a maintainer should ack. - Architecture question worth a human eye: the fix places the ref on the server (edge-triggered on the aggregate counter) rather than per-
RequestContext, unlikeNodeHTTPResponse's per-responsejsc::ReforListener::on_create's per-socketKeepAlive. The PR justifiesKeepAliveoverjsc::Reffor Windowsuv_runI/O; the server-level placement is simpler but couples correctness to everypending_requestsmutation going through the helper. - CodeRabbit's stderr-drain feedback and the comment-cop note were both addressed; the first CI failure (
serve-body-leak.test.ts) was fixed by 3d9b699's abrupt-stopdisable(). - Test follows harness conventions (bunEnv/bunExe, port: 0, concurrent pipe drain, combined-object assertion).
There was a problem hiding this comment.
No bugs found, but this changes process-lifetime semantics for Bun.serve (and acknowledges a new edge case where an unref'd server with a parked direct-stream now stays alive instead of exiting), so it's worth a human look.
What was reviewed:
KeepAlivestate machine — edge-triggered ref/unref onpending_requests0↔nonzero is idempotent;disable()on abrupt stop makes laterref_()a no-op.- The one remaining raw
pending_requests += 1(on_bun_info_request) is fully synchronous, so skipping the keep-alive there is harmless. stop(true)after gracefulstop()on the non-H3 listener-already-None path returns early withoutdisable()— matches pre-existing behavior forpoll_ref/TERMINATED on that path.
Extended reasoning...
Overview
Adds a second KeepAlive field (in_flight_keep_alive) to NewServer, ref'd on the pending_requests 0→nonzero edge in on_pending_request() and unref'd on the nonzero→0 edge in on_request_complete(), so that server.unref() (which only touches poll_ref) no longer lets the process exit while a request handler is suspended. Also routes the websocket-upgrade fallback in server_body.rs through on_pending_request() instead of a raw counter increment, and adds in_flight_keep_alive.disable() on both abrupt-stop branches in stop_listening. A subprocess-based test with an external client fetch validates the fix.
Security risks
None. No parsing of untrusted input, no auth/crypto, no new user-facing surface.
Level of scrutiny
High. This touches the core Bun.serve request lifecycle and event-loop keep-alive accounting, which is cross-platform-sensitive (the existing comment in stop_listening calls out Windows uv_run behavior with zero ref'd handles) and historically subtle (serve-body-leak.test.ts already forced one follow-up commit here). The author explicitly notes a behavioral change: an unref'd server whose direct-stream pull() is parked on a never-resolving promise, with the client disconnected and no stop(true), now keeps the process alive where it previously exited. Whether that trade-off is acceptable is a maintainer call.
Other factors
- The bug-hunting system raised and refuted the
stop() → stop(true)early-return path not disablingin_flight_keep_alive. I checked: on the non-H3 branch withlisteneralready taken,stop_listening(true)returns before the newdisable()— but that path also skipsTERMINATEDand thepoll_refunref today, so this preserves existing shape rather than introducing a new hole. KeepAliveis a tri-state (Inactive/Active/Done), not a counter; the 0↔nonzero edge-triggering keeps ref/unref balanced, anddisable()correctly no-ops futureref_()calls after abrupt stop.on_bun_info_requeststill does rawpending_requests += 1 / -= 1, but it's fully synchronous so the process can't exit between them.- The test follows repo conventions (subprocess, concurrent stderr/exit drain, combined
toEqualassertion) and was verified to fail on system bun and pass on the debug build per the PR body. - All prior review comments (CodeRabbit, comment-cop) are resolved.
Repro
From a separate process,
fetch(\http://127.0.0.1:\${PORT}/\`)` getsECONNRESET: the server process exits mid-handler with code 0, no error. Deterministic 3/3 on both release-asan and canary. Node v26 delivers the response (the accepted socket is its own handle).Cause
NewServerhas a singlepoll_ref: KeepAlive;server.unref()clears it.RequestContext(the per-request state for thefetchhandler) has no event-loop ref of its own.pending_requestsis only a destructor gate, so once the listener is unref'd and the handler awaits something that is itself unref'd, nothing holds the loop.The node:net face of this was fixed in
Listener::on_create(each accepted socket refs its ownKeepAlive), and node:http'sNodeHTTPResponsealready carries a per-requestjsc::Ref. The Bun.servefetchpath did neither.Fix
Add a second
KeepAliveon the server, ref'd on thepending_requests0 -> nonzero edge and unref'd on the nonzero -> 0 edge, separate from the listener'spoll_ref.server.unref()then only releases the listener's hold; in-flight requests keep the process alive on their own. This sits inon_pending_request/on_request_complete, so it covers thefetchhandler, static/file/directory routes, and the node:http dispatch uniformly. It usesKeepAlive(loop ref) rather thanjsc::Refso Windowsuv_runkeeps driving socket I/O while the drain is in flight (same reasoning as the existing comment instop_listening).Also routed the websocket-upgrade fallback through
on_pending_request()instead ofpending_requests += 1so the ref stays balanced with theon_request_complete()decrement inRequestContext::deinit.Verification
Existing
unreftests,websocket and routes, andbun-serve-staticall pass.no test proof · iteration 2 · 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