node:http: send nothing on destroy() before the headers go out, and a 500 (not an empty 200) for a throwing listener
Problem
- In
node:http,res.destroy()orreq.destroy()makes Bun finish an unfinished response head. With nothing written, the client getsHTTP/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 withend_without_body(true). That call writes a status line,Connection: closeand the head terminator.- A listener that throws before writing also gets an empty 200. The status check at
src/runtime/server/mod.rs:1495is inverted.
Fix
abort()calls the newuws_res_abandon. It sets the state thatuws_res_end_without_body(res, true)sets and writes nothing, as Node'sdestroy()does.- The throw path writes
500 Internal Server Errorwhen 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 firstwrite(),flushHeaders()orend().HTTP_END_CALLEDmarks a uWS response as ended.JSNodeHTTPServerSocket::close()then discards a corkedwrite().- 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 ares.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_abandonadds 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 | |
|---|---|
| main | 14 |
| this branch | 6 |
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.0 | main | this branch | |
|---|---|---|---|
writeHead(204); write("x") | nothing | a complete 204 No Content | a head with no terminator |
HEAD request, write("x") | nothing | a complete 200 OK | a head with no terminator |
write("") | head with Transfer-Encoding: chunked, no chunk | head with Connection: close, no framing: a complete empty 200 | a 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_resolvestill 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::abandonis HTTP/1 only, likeclear_handlers_ofnext 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-*.jsandtest-https-*.js: 448 of 450 exit 0.test-https-timeout.jstimes out on main too.test-http-client-timeout-option.jsneeds 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