Cork every HttpResponse write so post-await responses leave in one send()
What does this PR do?
Makes every byte-emitting uWS::HttpResponse method cork the socket first, so a response written from outside the request handler's synchronous part (after an await, from a timer, from another socket's callback) is assembled in the cork buffer and leaves in one send() instead of one per fragment.
uWS only corks around the synchronous request handler. Anything written later had no cork unless the call site added one, and only uws_res_write and node:http writeHead did (#17093, #28615). end()/tryEnd()/sendTerminatingChunk()/flushHeaders() never got one, so:
http.createServer(async (req, res) => {
await db.query();
res.writeHead(200, headers);
res.write("hello ");
res.end("world"); // "5", "\r\n", "world", "\r\n", "0\r\n\r\n" as five send()s
});
and a Bun.serve ReadableStream/async-generator body whose chunks arrive later sent Date, : , the date, \r\n, Content-Length: , the length, \r\n\r\n and the body as separate segments.
The two-slot cork with the pre-poll drain from #28615 already makes a bare cork() safe anywhere on the loop thread, and internalEnd uncorks itself when the response completes, so the core change is Super::cork() at the top of each write method plus making cork() a no-op on a closed socket (a slot must not outlive the socket it points at). The per-call-site corks become redundant and are removed: uws_res_write's, writeHead's, and node:http's JS handle.cork(() => writeHead + write) wrapper, whose uncork is what split write()+end() into two sends. NodeHTTPResponse.cork has no callers left and is removed.
Because ends can now arrive corked from anywhere, "the socket is corked" no longer means a cork wrapper or the request parser will flush it and run the connection-close gate, and the drained checks (hasFullyDrained/getBufferedAmount) never counted the cork buffer. So every close/FIN decision flushes the cork first: closeIfDoneAndMarked uncorks (and internalEnd's two copies of that logic are one helper the uws_res_close_if_done_and_marked shim shares, instead of the shim skipping corked sockets), HttpContext::onWritable uncorks after the user callback, onEnd and node:http's socket end() before their drained checks, and process exit drains the loop's corks after the auto-flushers since no pre-poll hook follows. That is what keeps a Connection: close bodyless response written from a bare microtask closing its connection (bun-server.test.ts "a handler rejection on a HEAD request…") and res.write(); res.socket.end() after an await delivering the write, as on main.
One core, saturated by six client processes, 3 interleaved trials, release builds of main vs this branch:
| handler awaits, then… | before | after |
|---|---|---|
node:http res.write() + res.end() | 50k req/s | 120k |
Bun.serve ReadableStream, one async chunk | 36k | 82k |
Bun.serve async generator, 5 chunks | 30k | 39k |
node:http 4× write() across ticks + end() | 29k | 39k |
node:http end() only (control) | 123k | 123k |
Bun.serve static Response (control) | 150k | 150k |
How did you verify your code works?
New Linux-only tests in serve.test.ts count the TCP data segments the client receives (getsockopt(TCP_INFO)) for the three cases above (7→3, 9→2, 6→1 locally; asserted as upper bounds); they fail on main and pass here. A new node-http.test.ts case covers res.write() + res.socket.end() after an await. bun-server.test.ts, serve.test.ts, node-http.test.ts, websocket-server.test.ts, fetch.stream.test.ts, bun-serve-file.test.ts, the stream-body tests and the 281 test-http-* Node parallel tests show no regressions locally; strace confirms the syscall counts.