node:http: send nothing on destroy() before the headers go out, and a 500 (not an empty 200) for a throwing listener

RO
24d ago
+304-12

Problem

  • In node:http, res.destroy() or req.destroy() makes Bun finish an unfinished response head. With nothing written, the client gets HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n. Node sends nothing.
  • NodeHTTPResponse::abort (src/runtime/server/NodeHTTPResponse.rs:1721) ends the native response with end_without_body(true). That call writes a status line, Connection: close and the head terminator.
  • A listener that throws before writing also gets an empty 200. The status check at src/runtime/server/mod.rs:1495 is inverted.

Fix

  • abort() calls the new uws_res_abandon. It sets the state that uws_res_end_without_body(res, true) sets and writes nothing, as Node's destroy() does.
  • The throw path writes 500 Internal Server Error when no status line is out.
  • Self-reviewed: 5 concerns raised, 3 addressed. Rejected: a guard that finishes an open head, and a fix here for the write-then-destroy timing below.
  • Verified: test/js/node/http/node-http-transfer-encoding.test.ts (13 new cases, 11 fail on main). Also the node:http suites and 450 vendored tests.

Background

  • res.writeHead() records the head in JS. uWS writes it at the first write(), flushHeaders() or end().
  • HTTP_END_CALLED marks a uWS response as ended. JSNodeHTTPServerSocket::close() then discards a corked write().
  • The guard is set aside: a finished head makes a destroyed 204 or HEAD response complete.

Downsides

  • A write without body bytes, then a later destroy, leaves the head unterminated. Unchanged from main: whether a res.write() just before a res.destroy() is sent follows the uWS cork, not Node's tick rule (Notes).
  • A throwing listener gets a 500 where it got a 200. Node leaves that connection open.
  • Size: uws_res_abandon adds 185 bytes of code and no host function.
Notes

Repro (a raw client, so the bytes are visible):

import { createServer } from "node:http";
import { connect } from "node:net";
const server = createServer((req, res) => {
  res.writeHead(500, { "x-a": "1" });
  setImmediate(() => res.destroy());
});
await new Promise(r => server.listen(0, "127.0.0.1", r));
const chunks = [];
const sock = connect(server.address().port, "127.0.0.1", () => sock.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"));
sock.on("data", d => chunks.push(d));
await new Promise(r => sock.on("close", r));
console.log(JSON.stringify(Buffer.concat(chunks).toString()));
server.close();
bun 1.4.0 to main:  "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
node v26.3.0:       ""
this branch:        ""

A listener that throws (createServer(() => { throw new Error("boom") }) with an uncaughtException handler):

main:          "HTTP/1.1 200 OK\r\nDate: ...\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"
node v26.3.0:  "" (the connection stays open)
this branch:   "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"

Where the bytes came from. The status line is the default of uWS: writeHeader("Connection", "close") writes HTTP/1.1 200 OK first when no status line is out. So res.writeHead(500) then res.destroy() also gave a 200. Since #43557 abort() returns early while uWS parses the socket, and the parser closes the socket after the message. So the stray bytes appear only for a destroy that comes after the listener's dispatch (a timer, an I/O callback, setImmediate). That is the usual place for it: a stalled upload, a failed upstream in a proxy.

Measured against Node v26.3.0. 75 cells: 5 states of the response (untouched, writeHead, writeHead + write with a Content-Length, a chunked write, flushHeaders) x destroy of res, req or req.socket x synchronous, process.nextTick, microtask, setImmediate, timer. Each cell is the bytes a raw client receives.

cells that differ from Node
main14
this branch6

The branch fixes 8 cells and adds no new difference. The 6 that stay are write() followed by a destroy in the same tick inside the listener (next section).

A write and a destroy in one tick inside the listener (not changed here). res.writeHead(200, { "Content-Length": "100" }); res.write("partial"); res.destroy() sends the head and the 7 bytes on main and on this branch. Node and Bun 1.4.2 send nothing. The early return that #43557 added to abort() leaves HTTP_END_CALLED clear, so flushPartialResponseBeforeClose in JSNodeHTTPServerSocket::close() flushes the cork. The same flush is right for a destroy from process.nextTick or a microtask in that dispatch: Node has sent the bytes by then. Only the JS write path knows in which tick a write was made. A review of where that fix belongs preferred that res.write() holds the writes of a tick until the tick ends, as Node's corked socket does. That changes the write path, and #41341, #45246, #43736, #44468 and #44871 are open on the same code. It is a separate change. The test that pinned this case is replaced by one that this PR can keep (a write in the tick of a later destroy).

The opposite case has the same cause and is also the same on main. The head is out, a timer writes a small chunk, and the destroy comes after process.nextTick, a microtask or setImmediate. Node sends the chunk, because its cork ends at the tick. Bun drops it, because the cork that uws_res_write takes ends only at the next loop iteration, and the destroy discards it.

A head that a write without body bytes started. write(""), and a write that a 204, 304 or HEAD response drops, make the JS layer write the head natively without its terminator. With a destroy in a later tick:

Node v26.3.0mainthis branch
writeHead(204); write("x")nothinga complete 204 No Contenta head with no terminator
HEAD request, write("x")nothinga complete 200 OKa head with no terminator
write("")head with Transfer-Encoding: chunked, no chunkhead with Connection: close, no framing: a complete empty 200a head with no terminator

Main appended Connection: close and the terminator on destroy. That made the destroyed response complete for the client in all three rows. On this branch the client sees a failed response in all three, as with Node. The bytes still differ from Node, because the head is on the wire before the destroy. That belongs to the write path (an empty first write must send the whole head, as Node's _send("") does), not to abort(). The three cases are in the new tests: they assert that the client does not get a finished response, which also holds for Node's bytes.

HTTP_END_CALLED in uws_res_abandon. Checked by removing the bit: exactly one test fails, "a chunk written in the tick of the destroy() is not sent" (the client then receives firstlater). The other bits (HTTP_CONNECTION_CLOSE, markDone(), resetTimeout()) only matter if the socket outlives the call. destroy() closes it right after.

Not changed.

  • node_http_request_on_resolve still ends a response that is pending when the dispatch promise resolves. Only a hand-emitted 'close' on the response reaches it.
  • A throw from the dispatch of a request that is queued behind a pipelined response is handled in JS: the connection closes at its turn and no 500 is sent. The 500 is for the current response only.
  • After a write(), a throw still ends the response without more bytes (#39364). #30075 fixed the inverted check in the Zig sources and was closed when the Rust port removed that file. It terminated the in-flight body in that case. This PR keeps the silent end.
  • AnyResponse::abandon is HTTP/1 only, like clear_handlers_of next to it. node:http does not run over HTTP/2 or HTTP/3.

The close itself. From res.destroy() to the client's 'close': Node 2 to 7 ms, main 2 to 3 ms, for a GET and for a POST whose announced body never arrives. This PR does not touch the close path. The branch has only a debug build here, so it is not in this list.

Size. uws_res_abandon is 185 bytes of x86-64 code: the unit compiled with the release flags (-O3 -march=nehalem), lowered per module, before cross-module LTO. For comparison uws_res_end_sendfile is 194 bytes. The Rust wrappers are one call each.

Tests run on a debug build of this branch.

  • node-http-transfer-encoding.test.ts: 131 pass. The 13 new cases on main: 11 fail, 2 pass (the synchronous destroy, and the write in the tick of a later destroy).
  • node-http.test.ts, node-http-server-abort-events, node-http-backpressure, node-http-pinned-write, node-http-uaf, node-http-server-timeouts, node-http-server-socket-end-drain, node-http-req-socket-pause, node-http-with-ws: pass (576 tests).
  • Vendored test-http-*.js and test-https-*.js: 448 of 450 exit 0. test-https-timeout.js times out on main too. test-http-client-timeout-option.js needs its 100 ms window on the client side and fails only on the debug build (it passes with a wider window, and on a release build of main).

[human-review] gate passed · iteration 1 · 6 files touched

fails on main (without fix)
ASAN without fix: 11 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-transfer-encoding.test.ts
bun test v1.4.4 (367d939d9)

test/js/node/http/node-http-transfer-encoding.test.ts:
(pass) should not duplicate transfer-encoding header in request [684.66ms]
(pass) should not duplicate transfer-encoding header in response when explicitly set [546.83ms]
(pass) empty Transfer-Encoding value is ignored like node, no clientError [156.07ms]
(pass) whitespace-only Transfer-Encoding value is ignored like node, no clientError [200.79ms]
(pass) empty Transfer-Encoding with Content-Length frames the body like node [161.66ms]
(pass) empty Transfer-Encoding field relative to Content-Length > empty Transfer-Encoding after Content-Length fires clientError like node [93.60ms]
(pass) empty Transfer-Encoding field relative to Content-Length > whitespace-only Transfer-Encoding after Content-Length fires clientError like node [55.04ms]
(pass) empty Transfer-Encoding field relative to Content-Length > a second empty Transfer-Encoding after Content-Length fires clientError like node [82.01ms]
(pass) em
... (truncated)

release without fix: 71 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http/node-http-transfer-encoding.test.ts:
(pass) should not duplicate transfer-encoding header in request [103.14ms]
(pass) should not duplicate transfer-encoding header in response when explicitly set [93.14ms]
(pass) empty Transfer-Encoding value is ignored like node, no clientError [57.07ms]
(pass) whitespace-only Transfer-Encoding value is ignored like node, no clientError [6.01ms]
(pass) empty Transfer-Encoding with Content-Length frames the body like node [30.49ms]
215 |   test.each([
216 |     ["empty", ["Content-Length: 5", "Transfer-Encoding:"]],
217 |     ["whitespace-only", ["Content-Length: 5", "Transfer-Encoding:   "]],
218 |     ["a second empty", ["Transfer-Encoding:", "Content-Length: 5", "Transfer-Encoding:"]],
219 |   ])("%s Transfer-Encoding after Content-Length fires clientError like node", async (name, headers) => {
220 |     expect(await send(headers)).toEqual({
                                      ^
error: expect(received).toEqual(expected)

  {
    "events": [
-     "clientError HPE_INVALID_TRANSFER_ENCODING",
+     "request /p body=hello",
+     "request /after body=",
    ],
-   "statuses"
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-transfer-encoding.test.ts
bun test v1.4.4 (367d939d9)

test/js/node/http/node-http-transfer-encoding.test.ts:
(pass) should not duplicate transfer-encoding header in request [699.91ms]
(pass) should not duplicate transfer-encoding header in response when explicitly set [504.21ms]
(pass) empty Transfer-Encoding value is ignored like node, no clientError [146.84ms]
(pass) whitespace-only Transfer-Encoding value is ignored like node, no clientError [94.58ms]
(pass) empty Transfer-Encoding with Content-Length frames the body like node [131.30ms]
(pass) empty Transfer-Encoding field relative to Content-Length > empty Transfer-Encoding after Content-Length fires clientError like node [78.20ms]
(pass) empty Transfer-Encoding field relative to Content-Length > whitespace-only Transfer-Encoding after Content-Length fires clientError like node [62.86ms]
(pass) empty Transfer-Encoding field relative to Content-Length > a second empty Transfer-Encoding after Content-Length fires clientError like node [62.63ms]
(pass) emp
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 3189ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1482] mkdir stamps
[2/1482] gen string-map defines_table
[3/1482] gen compressed/completions/bun.bash.zst
[4/1482] gen compressed/completions/bun.zsh.zst
[5/1482] gen compressed/completions/bun.fish.zst
[6/1482] gen compressed/src/runtime/bake/bun-framework-react/client.tsx.zst
[7/1482] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 249 extern-C blocks audited
[8/1482] gen compressed/src/lint/linter/config/presets.txt.zst
[9/1482] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[10/1482] gen compressed/src/lint/linter/deprecated.txt.zst
[11/1482] gen compressed/src/lint/oxlint_help.txt.zst
[12/1482] gen compressed/src/lint/linter/schema/schemas.txt.zst
[13/1482] gen compressed/src/sema/messages.txt.zst
[14/1482] gen compressed/src/lint/js_plugin/worker/eslint_patch.js.zst
[15/1482] gen compressed/codegen/bake.error.js.zst
[16/1482] gen compressed/codegen/bake.client.js.zst
[17/1482] gen compressed/lint/plug
... (truncated)
diff hotspot
src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp   |   3 +-
 src/runtime/server/NodeHTTPResponse.rs             |   2 +-
 src/runtime/server/mod.rs                          |   6 +-
 src/uws_sys/Response.rs                            |  16 ++
 src/uws_sys/libuwsockets.cpp                       |  32 ++-
 .../node/http/node-http-transfer-encoding.test.ts  | 257 ++++++++++++++++++++-
 6 files changed, 304 insertions(+), 12 deletions(-)

gate history · 2 passed · 1 rejected · iteration 1

evidence per changed file
file                                                   reads  edits  tests
src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp           3      2     38
src/runtime/server/NodeHTTPResponse.rs                     9      2     38
src/runtime/server/mod.rs                                  4      2     38
src/uws_sys/Response.rs                                    6      5     39
src/uws_sys/libuwsockets.cpp                               5      6     41
test/js/node/http/node-http-transfer-encoding.test.ts      2      2     37