node:http: defer server 'close' until every tracked connection has ended - #35837
node:http: defer server 'close' until every tracked connection has ended#35837robobun wants to merge 7 commits into
Conversation
server.close(cb) was firing as soon as the native all-closed promise resolved (pending_requests == 0), even though a keep-alive connection that was mid-request at close() time is still open at that point and keeps accepting requests. Node's net.Server#_emitCloseIfDrained holds 'close' until every accepted connection has ended. Gate emitCloseServer on kTrackedConnections.size and re-check when the last tracked connection's #onClose runs.
|
Warning Review limit reached
Next review available in: 8 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 6:19 PM PT - Jul 25th, 2026
🔄 @robobun, the build for |
node-http.test.ts has an environment-dependent proxy test that fails in this CI container; a standalone file lets the mechanical fail-before / pass-after check run cleanly.
…tions after close() close() clears the native handle, which made both methods no-ops and left no way to force-drain a connection that had been mid-request at close() time. Iterate the tracked set directly in that state. Also guard emitCloseServer against a re-listened server and reset the pending-drain flag in kRealListen so the previous cycle's flag cannot fire 'close' on the new server.
|
Subsumed by #35844, which adds the same |
setCloseCallback throws when a callback is already stored, so a deferred
callback that was never reached (because emitCloseServer bailed on open
connections) would block the next close(cb) after a re-listen. Node's
net.Server#close uses once('close'), which stacks listeners across
cycles and needs no separate clear step.
There was a problem hiding this comment.
All three earlier findings (closeAllConnections/closeIdleConnections no-op after close(), stale kPendingDrainClose across re-listen, and the kCloseCallback "already set" throw) are addressed and covered by new tests. I didn't find further issues this pass. Leaving for a human to weigh the overlap with #35844 (which the author noted subsumes this) and to sign off on the server-close lifecycle change.
What was reviewed:
- emitCloseServer gate on serverSymbol + kTrackedConnections; verified the once('close', cb) switch means the removed callCloseCallback(self) is dead for the server path.
- closeAllConnections/closeIdleConnections fallback: snapshot via Array.from before destroy, and ordering when closeAllConnections() runs before the native promise resolves — the later emitCloseServer still fires because size is 0 by then.
- kRealListen resets kPendingDrainClose; #onClose only re-checks when the flag is set, so a re-listened server with new connections won't spuriously emit.
Extended reasoning...
Overview
This PR changes src/js/node/_http_server.ts to defer the server 'close' event and server.close(cb) callback until every tracked TCP connection has ended, matching Node's net.Server#_emitCloseIfDrained. It introduces a kPendingDrainClose flag, gates emitCloseServer on both serverSymbol (the native handle) and kTrackedConnections.size, has NodeHTTPServerSocket#onClose re-trigger the emit when the last tracked socket drops, resets the flag in kRealListen, and switches the close callback registration from the single-slot setCloseCallback to this.once('close', cb) (matching Node). closeAllConnections() and closeIdleConnections() gain a fallback that iterates kTrackedConnections when close() has already dropped the native handle. A new 4-test file exercises the withheld-callback path, the idle-reap control, the deadline force-drain, and the re-listen guard.
Security risks
None identified. This is event/lifecycle sequencing in the Node HTTP compat layer; no parsing of untrusted input, no auth/crypto, no resource-limit changes.
Level of scrutiny
Medium-high. node:http server close/listen is a widely-exercised compat surface with subtle re-entrancy (re-listen, forced drain, keep-alive). This PR went through two rounds of review that surfaced three real bugs (post-close force-drain no-op, stale pending flag across re-listen, and a "Close callback already set" throw), all now fixed with tests. The once('close', cb) switch is a small but real behavior change (callbacks now stack across cycles like Node instead of throwing on the second), which is the correct direction but worth a human confirming.
Other factors
- The author noted this is subsumed by #35844, which apparently takes a broader approach (keeps the native handle reachable under a closing flag rather than falling back to the tracked set). A human should decide which PR to land.
- Tests are in a new file rather than the existing
node-http.test.ts; they use bounded polling (while (!body.includes(...)) await once(socket, 'data')+ a fewsetImmediateturns) rather than sleeps, and each has afinallycleanup. Thefor (i < 4) setImmediateyield-loop is a mild smell per REVIEW.md but is bounded and comments why. - I traced the ordering where
closeAllConnections()is called synchronously afterclose()(before the native all-closed promise resolves and setskPendingDrainClose): sockets are destroyed with the flag still false so#onClosedoesn't schedule, but the pending native-promise resolution then runsemitCloseServerwithsize === 0and emits — no hang. setCloseCallback/callCloseCallbackare now unused for the server object (still used for sockets/responses), so removingcallCloseCallback(self)fromemitCloseServeris safe.
|
Build #81871: 190 jobs passed, 1 failed. The only red is the pre-existing Re: #35844, both PRs gate |
|
Triage note: this PR is the remaining carrier for the "server Two things to pick up here once #35839 lands:
Re-checked today: 3 of the 4 tests here still fail on a fresh |
Repro
closeCbFiredconnFinresps["resp:/first","resp:/second"]["resp:/first","resp:/second"]The
server.close()callback is documented as firing once all connections have ended. Bun fires it as soon as the in-flight response is written, yet the keep-alive connection is still open and keeps serving new requests. Anyserver.close(() => process.exit())style graceful drain is told the server is drained while a live client can still issue requests into it.Cause
getBunServerAllClosedPromise(bound atlisten()time) resolves when the native server reachespending_requests == 0 && !listener && !websocketsand drivesemitCloseServerdirectly. A keep-alive connection that was serving a request whenclose()ran is not idle (socloseIdleConnections()skips it), and after the response finishespending_requestsdrops to zero while the TCP connection stays open. Nothing in that condition tracks open connections.Fix
Gate
emitCloseServeronkTrackedConnections.sizeand on the native handle being gone, mirroring Node'snet.Server#_emitCloseIfDrained. When the native promise resolves with connections still tracked, record a pending-drain flag; the last connection's#onClosere-checks and schedules the actual'close'emit.kRealListenresets the flag so a re-listen does not inherit the previous cycle's pending state.closeAllConnections()andcloseIdleConnections()now fall back to iteratingkTrackedConnectionswhenclose()has already dropped the native handle, so the documented deadline pattern (server.close(cb); setTimeout(() => server.closeAllConnections(), N)) can force the drain.Verification
New
test/js/node/http/node-http-server-close-drain.test.ts:server.close(cb) does not fire while a keep-alive connection is still open: fail-beforecloseCbFired: trueafter the first response; after the fix the callback is withheld until the client closes the socket.server.close(cb) fires once an idle keep-alive connection is reaped: control for the existing idle-at-close path.closeAllConnections() after close() force-drains the withheld callback: the deadline pattern releases the callback.no 'close' is emitted on a re-listened server when an earlier connection ends: re-listen guard.Also green:
node-http.test.ts(same pass/fail asmain),test-http-server-close-{all,idle,idle-wait-response,destroy-timeout}.js,test-https-server-close-{all,idle}.js,test-http-server-connection-list-when-close.js,test-http-req-res-close.js, andbun-server.test.ts'slate keep-alive request to a node:http server after close().[review] gate passed · iteration 2 · 2 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 3 passed · 1 rejected · iteration 2
evidence per changed file