node:http: defer ServerResponse.destroy() 'close' and stop rewriting completed requests as aborted - #33818
node:http: defer ServerResponse.destroy() 'close' and stop rewriting completed requests as aborted#33818robobun wants to merge 4 commits into
Conversation
…completed requests as aborted ServerResponse.prototype.destroy() emitted 'close' synchronously from inside the call (so res.closed was already true when destroy() returned), and the socket _destroy path synchronously destroyed the request before its queued EOF could flip readableEnded/complete, firing 'aborted' on a fully-received request and dropping its pending 'end'. In Node.js res.destroy() destroys the socket and the socket's close callback (onServerResponseClose/abortIncoming) tears the request and response down on a later tick. Route the teardown through the same path: drop the synchronous 'close' emission from ServerResponse.destroy (the socket's #onClose schedules it, with a nextTick fallback when no socket is attached), drop the synchronous req.destroy() from #closeHandle (the same #onClose runs it after pending nextTicks), and keep the socket attached after a synchronous in-handler destroy so #onClose can still see it. With the fix, for a fully-received request res.destroy() matches Node.js exactly: destroy() returns with res.closed === false, 'end' is delivered, 'aborted' is not, req.complete stays true, and res 'close' follows req 'close' on a later tick.
WalkthroughThis PR changes ServerResponse.destroy() and related socket/request teardown logic in Bun's Node.js HTTP server implementation so that 'close' emission is deferred to a later tick rather than immediate, preventing fully-received requests from being incorrectly marked as aborted. A new test file validates this deferred close behavior. ChangesDeferred ServerResponse close behavior
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 4:53 AM PT - Jul 9th, 2026
✅ @robobun, your commit bb231be76fc706e3054afbed542b083561e867ac passed in 🧪 To try this PR locally: bunx bun-pr 33818That installs a local version of the PR into your bun-33818 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
…file The ServerResponse socket getter auto-creates a FakeSocket, so the no-socket fallback was unreachable and a standalone new ServerResponse() never emitted 'close' after destroy(). Read this[fakeSocketSymbol] directly (same pattern as end()/setTimeout()) and schedule emitCloseNT whenever there is no native handle, which covers the FakeSocket case. Also move the tests to test/js/node/http/node-http-server-response-destroy.test.ts so the gate runs them in isolation, and add coverage for the standalone response path.
There was a problem hiding this comment.
Thanks for the fix in 7c32ef5 — the standalone-response path now looks right and is covered by a test. I didn't find further issues, but the removal of the synchronous req.destroy() from #closeHandle and the new conditional detachSocket skip touch subtle request/response lifecycle ordering on a hot path, so this is worth a human look.
Extended reasoning...
Overview
This PR changes three coupled sites in src/js/node/_http_server.ts to make ServerResponse.destroy() follow Node's Writable-destroy contract (async 'close') and to stop a server-initiated destroy from retroactively marking a fully-received request as aborted:
ServerResponse.prototype.destroy: drops the synchronous'close'emit; readsthis[fakeSocketSymbol]directly and schedulesprocess.nextTick(emitCloseNT, this)when there is no native handle.NodeHTTPServerSocket#closeHandle: removes the synchronousreq.destroy(); relies on#onClose(reached via the native close callback) to destroy the request after pending nextTicks drain.onNodeHTTPRequestfast path: skipsdetachSocketwhen the response is destroyed-but-not-closed so#onClosecan still find_httpMessage.
A new test file exercises destroy before-write / after-partial-write / with-error, plus a standalone new ServerResponse(new IncomingMessage()), asserting exact event order against Node v26.
Security risks
None identified — this is event-ordering / lifecycle bookkeeping in the node:http compat layer; no auth, crypto, parsing, or untrusted-input handling is touched.
Level of scrutiny
Medium-high. _http_server.ts is a hot path underneath every node:http-based framework, and the three changes interact: correctness of the new 'close' scheduling depends on #onClose reliably reaching _httpMessage, which in turn depends on the new conditional detach. Removing the synchronous req.destroy() from #closeHandle shifts request teardown to an async native-close callback; a maintainer familiar with why that sync destroy was originally added (and with keep-alive / pipelining edge cases) should confirm no path is left where the request is never destroyed or where a genuine mid-body client abort now fails to emit 'aborted'.
Other factors
My earlier inline review flagged an unreachable fallback branch; that was fixed in 7c32ef5 (reads fakeSocketSymbol storage directly, gates the fallback on !handle) and a regression test for the standalone case was added. The current bug-hunt pass found nothing further. The author reports sweeping ~360 vendored test-http-* parallel tests and the express res.sendFile suite with no new failures, which is reassuring but doesn't substitute for a maintainer sanity-check on the lifecycle reordering.
events.once() installs its own error listener that rejects the awaited promise; a separate no-op error listener does not suppress it. Use an explicit close resolver so an expected ECONNRESET from the force-closed connection cannot fail the test.
|
CI on this diff is green; the remaining red is unrelated infra across three runs:
The new test file |
What
res.destroy()on anode:httpserver response tore the exchange down synchronously and re-entrantly, breaking two independent Node.js contracts in one call:res 'close'was dispatched from insidedestroy(), sores.closedwas alreadytruewhendestroy()returned. The streams contract saysclosedbecomestrueonly after'close'has been emitted on a later tick.'aborted'fired,'end'was dropped,req.completeflipped tofalse.on-finished-style middleware and request accounting misclassify a completed request as client-aborted.Repro
Cause
ServerResponse.prototype.destroyemitted'close'synchronously (this._closed = true; this.emit("close")) before returning.NodeHTTPServerSocket#closeHandle(reached synchronously viasocket.destroy()→_destroy) calledreq.destroy()before the queuedpush(null)/complete = truenextTick for the body-less request could run, soIncomingMessage#_destroysaw!readableEnded || !completeand emitted'aborted'.Fix
Route the teardown through the same path Node.js uses (
OutgoingMessage#destroy→socket.destroy→ socket close callback):'close'emission fromServerResponse.prototype.destroy. In the native server path the socket's#onClose(scheduled as a task from the native close) schedulesemitCloseNT(res); for a standalone response with no native handle, schedule it viaprocess.nextTick. The socket is read fromthis[fakeSocketSymbol]directly (thesocketgetter auto-creates a FakeSocket, which would make the no-handle fallback unreachable).req.destroy()from#closeHandle;#onClosealready destroys the request after pending nextTicks have drained, so a fully-received request hasreadableEnded/completeset by then.detachSocketat thehandle.finishedfast path) so#onClosecan still find_httpMessageto close the response and destroy the request.Verification
New
test/js/node/http/node-http-server-response-destroy.test.tscoversres.destroy()before any write, afterwriteHead+partial body, with an error argument, and on a standalonenew ServerResponse(new IncomingMessage()). Each variant assertsres.closed === falsewhendestroy()returns and that'close'is emitted on a later tick; the server variants also assert the exact event ordercall-destroy > destroy-returned > req.end > req.close > res.closewithreq.aborted === falseandreq.complete === true.All four fail on
mainand pass with this change; all assertions verified against Node v26.3.0 (identical event order). Swept the ~360 vendoredtest/js/node/test/parallel/test-http-*scripts andtest/js/third_party/express/res.sendFile.test.ts: no new failures versusmain.[review] gate passed · iteration 3 · 2 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 1 rejected · iteration 3
evidence per changed file