diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index 2364dc821b60..a25095bc8c99 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -360,6 +360,7 @@ struct HttpResponse : public AsyncSocket { } } + /* Completes a response whose bytes the caller already wrote (bodyLength of them are body), with the uncork and close gate internalEnd() ends on. */ void finishResponseInCorkBuffer(size_t bodyLength) { HttpResponseData *httpResponseData = getHttpResponseData(); httpResponseData->state |= HttpResponseData::HTTP_END_CALLED; diff --git a/src/runtime/bake/dev_server/deferred_request.rs b/src/runtime/bake/dev_server/deferred_request.rs index ae9f5c38a82e..a9f8d48dcc3c 100644 --- a/src/runtime/bake/dev_server/deferred_request.rs +++ b/src/runtime/bake/dev_server/deferred_request.rs @@ -161,10 +161,6 @@ impl DeferredRequest { saved.deinit(); } Handler::BundledHtmlPage(r) => { - // Reached from JS event-loop tasks (on_plugins_rejected, the - // bundle-completion OOM cleanup), so end_without_body - // alone cannot close the socket; write Content-Length so the - // client has framing. r.response.write_status(b"500 Internal Server Error"); r.response.write_header_int(b"Content-Length", 0); r.response.end_without_body(true); diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index bb564511d883..7bc84e953c5f 100644 --- a/src/runtime/server/HTMLBundle.rs +++ b/src/runtime/server/HTMLBundle.rs @@ -647,9 +647,6 @@ impl Route { // To protect privacy, do not show errors to end users in production. // TODO: Show a generic error page. } - // This runs from a JS event-loop task, not a uWS handler, - // so `end_without_body(true)` alone cannot close the - // socket; write Content-Length so the client has framing. resp.write_status(b"500 Build Failed"); resp.write_header_int(b"Content-Length", 0); resp.end_without_body(true); diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 6e10d6d94523..c19c438a8be4 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -1791,7 +1791,7 @@ impl NodeHTTPResponse { if let Some(raw_response) = self.writer() { raw_response.clear_on_writable(); raw_response.clear_timeout(); - raw_response.end_without_body(true); + raw_response.abandon(); } self.on_request_complete(); } diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index fdab0574eba3..4a7a41de18b6 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1335,12 +1335,6 @@ where self.reclaim_promise_cell(); // SAFETY: FFI handle resp.end_without_body(close_connection); - // This end can run uncorked (e.g. render_production_error from a - // rejection microtask), where no cork or parser gate runs the - // close check for Connection: close or a graceful-stop mark. The - // shim no-ops when the socket is corked (the cork wrapper's own - // gate runs later) or already closed. - resp.close_if_done_and_marked(); // end_request_streaming_and_drain() must run after the last // `resp` access: its drain_microtasks() can re-enter lsquic (H3) // and free the stream out from under the local `resp` copy. diff --git a/src/uws_sys/Response.rs b/src/uws_sys/Response.rs index e581779ebfc5..1de51ae6e8a8 100644 --- a/src/uws_sys/Response.rs +++ b/src/uws_sys/Response.rs @@ -286,10 +286,16 @@ impl Response { } } + /// Like `end`, this sends the response and can close the socket before it returns. pub fn end_without_body(&mut self, close_connection: bool) { c::uws_res_end_without_body(Self::ssl_flag(), self.as_raw(), close_connection) } + /// `end_without_body(true)` for node:http `res.destroy()`: no uncork and no close gate. + pub(crate) fn abandon(&mut self) { + c::uws_res_abandon(Self::ssl_flag(), self.as_raw()) + } + pub(crate) fn end_send_file(&mut self, write_offset: u64, close_connection: bool) { c::uws_res_end_sendfile( Self::ssl_flag(), @@ -968,6 +974,16 @@ impl AnyResponse { any_dispatch!(self, |r| r.end_without_body(close_connection)) } + /// HTTP/1 only: an HTTP/2 or HTTP/3 stream ends as `end_without_body(true)` does. + pub fn abandon(self) { + match self { + AnyResponse::SSL(ptr) => TLSResponse::as_handle(ptr).abandon(), + AnyResponse::TCP(ptr) => TCPResponse::as_handle(ptr).abandon(), + AnyResponse::H3(ptr) => H3Response::as_handle(ptr).end_without_body(true), + AnyResponse::H2(ptr) => H2Response::as_handle(ptr).end_without_body(true), + } + } + /// HTTP/1 only: the handlers of an HTTP/2 or HTTP/3 stream belong to one response. pub fn clear_handlers_of(self, user_data: *mut U) { match self { @@ -1369,6 +1385,7 @@ pub mod c { res: &mut uws_res, close_connection: bool, ); + pub(crate) safe fn uws_res_abandon(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_end_sendfile( ssl: i32, res: &mut uws_res, diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 8d1faf19ebaa..9011c9eb090e 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -42,6 +42,29 @@ __attribute__((always_inline)) static inline auto withWebSocket(int ssl, uws_web return fn((TCPWebSocket *)ws); } +/* Finishes the head of a response that sends no body bytes. */ +template +static void endHeadWithoutBody(uWS::HttpResponse *uwsRes, bool close_connection) +{ + auto *data = uwsRes->getHttpResponseData(); + /* After write()/flushHeaders() or an earlier end terminated the header section, header bytes would land inside the body (node:http res.destroy() mid-response). */ + bool headers_open = !(data->state & (uWS::HttpResponseData::HTTP_WRITE_CALLED | uWS::HttpResponseData::HTTP_END_CALLED)); + if (close_connection) + { + /* The header is advisory, as in internalEnd(): HTTP_CONNECTION_CLOSE is what makes the close gates tear the connection down. */ + if (headers_open && !(data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE)) + { + uwsRes->writeHeader("Connection", "close"); + } + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } + if (headers_open) + { + // Some HTTP clients require the complete "
\r\n\r\n" to be sent. If not, they may throw a ConnectionError. + uwsRes->uWS::template AsyncSocket::write("\r\n", 2); + } +} + /* upgradeContext and id are only passed to behavior.upgrade */ template static auto makeBehavior(const uws_socket_behavior_t &behavior, void *upgradeContext = nullptr, size_t id = 0) @@ -985,8 +1008,8 @@ extern "C" uwsRes->resetTimeout(); } } - /* Completion gate for response-end paths that bypass internalEnd (the - * sendfile path): closes the socket when the connection is marked to close + /* Completion gate for uws_res_end_sendfile, the one end that does not gate + * itself: closes the socket when the connection is marked to close * (Connection: close, peer FIN, close-when-idle), the response is complete, * and every outgoing byte has been flushed. Corked responses are left to the * cork() wrapper's own post-uncork gate. */ @@ -1036,64 +1059,26 @@ extern "C" void uws_res_end_without_body(int ssl, uws_res_r res, bool close_connection) { - if (ssl) - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - auto *data = uwsRes->getHttpResponseData(); - /* Once write()/flushHeaders() (HTTP_WRITE_CALLED) or an earlier end - * (HTTP_END_CALLED) terminated the header section, header bytes written - * here would land inside the body (node:http res.destroy() mid-response - * ends up here). Setting HTTP_CONNECTION_CLOSE is what makes the close - * gates tear the connection down; the header itself is only advisory, - * same as in internalEnd(). */ - bool headers_open = !(data->state & (uWS::HttpResponseData::HTTP_WRITE_CALLED | uWS::HttpResponseData::HTTP_END_CALLED)); - if (close_connection) - { - if (headers_open && !(data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE)) - { - uwsRes->writeHeader("Connection", "close"); - } - data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; - } - if (headers_open) - { - uwsRes->AsyncSocket::write("\r\n", 2); - } - data->state |= uWS::HttpResponseData::HTTP_END_CALLED; - data->markDone(uwsRes); - uwsRes->resetTimeout(); - /* No close gate here: callers (FileResponseStream::finish, - * DevServer/HTMLBundle error paths) keep using the response after this - * returns, so closing inside this call would destruct the ext under - * them. Corked callers get the cork() wrapper's post-uncork gate; - * uncorked ones run uws_res_close_if_done_and_marked themselves once - * they are done with the response. */ - } - else - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; + withSSL(ssl, [&](auto isSSL) { + auto *uwsRes = (uWS::HttpResponse *)res; + endHeadWithoutBody(uwsRes, close_connection); + /* Like end(): this sends the response and can close the socket before it returns. */ + uwsRes->finishResponseInCorkBuffer(0); + }); + } + + /* uws_res_end_without_body(res, true) for node:http res.destroy(), which drops what is still corked and closes the socket itself: no uncork, no close gate. */ + void uws_res_abandon(int ssl, uws_res_r res) + { + withSSL(ssl, [&](auto isSSL) { + constexpr bool SSL = decltype(isSSL)::value; + auto *uwsRes = (uWS::HttpResponse *)res; + endHeadWithoutBody(uwsRes, true); auto *data = uwsRes->getHttpResponseData(); - /* See the SSL arm above. */ - bool headers_open = !(data->state & (uWS::HttpResponseData::HTTP_WRITE_CALLED | uWS::HttpResponseData::HTTP_END_CALLED)); - if (close_connection) - { - if (headers_open && !(data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE)) - { - uwsRes->writeHeader("Connection", "close"); - } - data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; - } - if (headers_open) - { - // Some HTTP clients require the complete "
\r\n\r\n" to be sent. - // If not, they may throw a ConnectionError. - uwsRes->AsyncSocket::write("\r\n", 2); - } - data->state |= uWS::HttpResponseData::HTTP_END_CALLED; + data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); - /* No close gate here; see the SSL arm above. */ - } + }); } bool uws_res_write(int ssl, uws_res_r res, const char *data, size_t *length) nonnull_fn_decl; diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 928f19dc0891..23678b111a3f 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -1305,6 +1305,58 @@ describe("requests while a dev server bundle is in flight", () => { }); }); +// The first request to an html route waits for the bundle and is answered from an event-loop task, outside the +// read that delivered it. Both answers here end without a body on a connection that has to close. +describe("the request that waited for an html route's bundle gets its connection closed", () => { + const page = (script: string) => + `t`; + const serve = /*ts*/ ` + import net from "node:net"; + import html from "./index.html"; + + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: process.env.DEVELOPMENT === "true", + idleTimeout: 255, + routes: { "/": html }, + fetch: () => new Response("fetch"), + }); + // Resolves when the server ends the connection. The client never ends it first. + const { promise: reply, resolve, reject } = Promise.withResolvers(); + let received = ""; + // The dev server only answers requests for its own host. + const request = process.env.REQUEST.replace("HOST", "127.0.0.1:" + server.port); + const socket = net.connect(server.port, "127.0.0.1", () => socket.write(request)); + socket.on("data", chunk => (received += chunk.toString("latin1"))); + socket.on("error", reject); + socket.on("end", () => resolve(received)); + console.log((await reply).split("\\r\\n")[0]); + `; + + test.concurrent.each([ + { + answer: "the 500 of a production build that failed", + files: { "index.html": page("./missing.ts") }, + env: { DEVELOPMENT: "false", REQUEST: "GET / HTTP/1.1\r\nHost: HOST\r\n\r\n" }, + status: "HTTP/1.1 500 Build Failed", + }, + { + answer: "the 405 of the dev server to Connection: close", + files: { "index.html": page("./app.ts"), "app.ts": `console.log("app");` }, + env: { + DEVELOPMENT: "true", + REQUEST: "POST / HTTP/1.1\r\nHost: HOST\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", + }, + status: "HTTP/1.1 405 Method Not Allowed", + }, + ])("$answer", async ({ files, env, status }) => { + using dir = tempDir("bun-serve-html-bodyless-close", { ...files, "serve.ts": serve }); + const { stdout, stderr, exitCode } = await runServeFixture(dir, env); + expect({ stdout, exitCode }, stderr).toEqual({ stdout: status, exitCode: 0 }); + }); +}); + // The rejection ends the bundle, and the next bundle reuses the memory that the first one freed. test.concurrent("starts the queued bundle after an onLoad plugin's rejection ends a dev server bundle", async () => { const rounds = [0, 1, 2, 3, 4, 5]; diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 58f153dc2462..b96b08d6cb2d 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -1581,6 +1581,189 @@ describe.concurrent("server.stop() drain promise counts open connections", () => }); }); +// A response that ends without a body (a 304, a HEAD, the bodyless errors) has to close a connection that is +// marked to close, like a response with a body. Every request here is a POST whose body never arrives, answered +// 304 by an async handler. Completing that response rejects the body, and the microtasks that run then can +// take the cork slot the response was written into before the server looks at the connection again. +describe.concurrent("a response without a body closes a connection that is marked to close", () => { + const upload = (requestLine: string, headers = "") => + `${requestLine}\r\nHost: x\r\n${headers}Content-Length: 10\r\n\r\n`; + const statusLine = (reply: string) => reply.slice(0, reply.indexOf("\r\n")); + + // `reply` resolves with what the server sent once the server ends the connection. The client never ends it + // first and idleTimeout: 255 keeps the idle sweep away, so a connection the server leaves open times out. + function untilServerCloses(port: number, request: string, useTls = false) { + const { promise: reply, resolve, reject } = Promise.withResolvers(); + let received = ""; + const socket = Bun.connect({ + hostname: "127.0.0.1", + port, + tls: useTls ? { rejectUnauthorized: false } : undefined, + socket: { + open(socket) { + socket.write(request); + }, + data(_socket, chunk) { + received += chunk.toString("latin1"); + }, + end() { + resolve(received); + }, + error(_socket, error) { + reject(error); + }, + connectError(_socket, error) { + reject(error); + }, + }, + }); + return { reply, [Symbol.dispose]: () => void socket.then(socket => socket.end(), reject) }; + } + + // Four responses complete in one microtask drain, each inside the drain of the one before it, so all but + // the last two lose their cork slot to a later one. + test.each([ + { mark: "Connection: close on the request", request: upload("POST / HTTP/1.1", "Connection: close\r\n") }, + { mark: "an HTTP/1.0 request", request: upload("POST / HTTP/1.0") }, + { + mark: "Connection: close on the response", + request: upload("POST / HTTP/1.1"), + headers: { Connection: "close" }, + }, + { mark: "server.stop() while the requests are in flight", request: upload("POST / HTTP/1.1"), stop: true }, + { + mark: "Connection: close on the request, over TLS", + request: upload("POST / HTTP/1.1", "Connection: close\r\n"), + useTls: true, + }, + ])("$mark", async ({ request, headers, stop, useTls }) => { + const concurrent = 4; + const allStarted = Promise.withResolvers(); + const release = Promise.withResolvers(); + let started = 0; + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 255, + tls: useTls ? tls : undefined, + async fetch() { + if (++started === concurrent) allStarted.resolve(); + await release.promise; + return new Response(null, { status: 304, headers }); + }, + }); + using clients = new DisposableStack(); + const replies = Array.from( + { length: concurrent }, + () => clients.use(untilServerCloses(server.port, request, useTls)).reply, + ); + await Promise.race([allStarted.promise, ...replies]); + const stopped = stop ? server.stop() : undefined; + release.resolve(); + + expect((await Promise.all(replies)).map(statusLine)).toEqual( + Array.from({ length: concurrent }, () => "HTTP/1.1 304 Not Modified"), + ); + await stopped; + }); + + test("after two corked WebSockets took its cork slot", async () => { + const webSockets: ServerWebSocket[] = []; + const bothOpen = Promise.withResolvers(); + let tookSlot = false; + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 255, + websocket: { + open(ws) { + if (webSockets.push(ws) === 2) bothOpen.resolve(); + }, + message() {}, + }, + async fetch(req, server) { + if (new URL(req.url).pathname === "/ws") { + if (server.upgrade(req)) return; + return new Response(null, { status: 400 }); + } + const [first, second] = webSockets; + req.text().catch(() => { + first.cork(() => { + first.send("a"); + // Both cork slots hold data now, so this cork() sends the least recently used one: the response's. + second.cork(() => second.send("b")); + }); + tookSlot = true; + }); + // Leaves the read that delivered the request, so that the response gets a cork slot of its own. + await Bun.sleep(0); + return new Response(null, { status: 304 }); + }, + }); + using clients = new DisposableStack(); + for (let i = 0; i < 2; i++) { + const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws`); + clients.defer(() => ws.close()); + ws.onerror = event => bothOpen.reject(new Error(`WebSocket ${event.type}`)); + } + await bothOpen.promise; + + const request = clients.use(untilServerCloses(server.port, upload("POST / HTTP/1.1", "Connection: close\r\n"))); + expect({ status: statusLine(await request.reply), tookSlot }).toEqual({ + status: "HTTP/1.1 304 Not Modified", + tookSlot: true, + }); + }); + + test("after two streaming responses took its cork slot", async () => { + const streams: ReadableStreamDirectController[] = []; + const bothStreaming = Promise.withResolvers(); + const finish = Promise.withResolvers(); + let tookSlot = false; + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + idleTimeout: 255, + async fetch(req) { + if (new URL(req.url).pathname === "/stream") { + return new Response( + new ReadableStream({ + type: "direct", + async pull(controller) { + if (streams.push(controller) === 2) bothStreaming.resolve(); + await finish.promise; + }, + }), + ); + } + req.text().catch(() => { + // A write smaller than the cork buffer corks its socket until the event loop flushes it. + for (const stream of streams) { + stream.write("x"); + stream.flush(); + } + tookSlot = true; + }); + await Bun.sleep(0); + return new Response(null, { status: 304 }); + }, + }); + using clients = new DisposableStack(); + clients.defer(() => finish.resolve()); + for (let i = 0; i < 2; i++) { + // These end when `finish` resolves, after the assertion. + clients.use(untilServerCloses(server.port, "GET /stream HTTP/1.1\r\nHost: x\r\n\r\n")).reply.catch(() => {}); + } + await bothStreaming.promise; + + const request = clients.use(untilServerCloses(server.port, upload("POST / HTTP/1.1", "Connection: close\r\n"))); + expect({ status: statusLine(await request.reply), tookSlot }).toEqual({ + status: "HTTP/1.1 304 Not Modified", + tookSlot: true, + }); + }); +}); + test("should be able to await server.stop(true) with keep alive", async () => { const { promise, resolve } = Promise.withResolvers(); const ready = Promise.withResolvers(); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 520d29b817ed..fe857378890b 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -5201,12 +5201,18 @@ describe("requests pipelined in one read", () => { if (len[0] < 156) throw new Error("tcp_info has no tcpi_data_segs_in: " + len[0] + " bytes"); return new DataView(info.buffer).getUint32(152, true); } - const request = path => "GET " + path + " HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"; - async function run(options, paths = ["/", "/", "/", "/", "/", "/", "/", "/"]) { + // A response to HEAD and a 304 end with their head. + async function run( + options, + paths = ["/", "/", "/", "/", "/", "/", "/", "/"], + { method = "GET", headers = "", status = "200 OK" } = {}, + ) { + const request = path => method + " " + path + " HTTP/1.1\\r\\nHost: x\\r\\n" + headers + "\\r\\n"; + const lastBytes = method === "HEAD" || status !== "200 OK" ? "\\r\\n\\r\\n" : "hello"; const server = Bun.serve({ port: 0, hostname: "127.0.0.1", ...options }); const done = Promise.withResolvers(); let reply = ""; - const responses = () => reply.split("HTTP/1.1 200 OK").length - 1; + const responses = () => reply.split("HTTP/1.1 " + status).length - 1; const socket = await Bun.connect({ hostname: "127.0.0.1", port: server.port, @@ -5216,7 +5222,7 @@ describe("requests pipelined in one read", () => { }, data(socket, chunk) { reply += chunk.toString("latin1"); - if (responses() === paths.length && reply.endsWith("hello")) done.resolve(); + if (responses() === paths.length && reply.endsWith(lastBytes)) done.resolve(); }, error(socket, error) { done.reject(error); @@ -5279,6 +5285,22 @@ describe("requests pipelined in one read", () => { { routes: { "/static": new Response("hello"), "/js": () => new Response("hello") } }, ["/static", "/static", "/static", "/js", "/static", "/static", "/js", "/static"], ), + // The same for responses without a body. + fetchHead: await run({ fetch: () => new Response("hello") }, undefined, { method: "HEAD" }), + staticHead: await run({ routes: { "/": new Response("hello") } }, undefined, { method: "HEAD" }), + mixedHead: await run( + { routes: { "/static": new Response("hello"), "/js": () => new Response("hello") } }, + ["/static", "/static", "/static", "/js", "/static", "/static", "/js", "/static"], + { method: "HEAD" }, + ), + fetch304: await run({ fetch: () => new Response(null, { status: 304 }) }, undefined, { + status: "304 Not Modified", + }), + static304: await run( + { routes: { "/": new Response("hello", { headers: { ETag: '"v1"' } }) } }, + undefined, + { headers: 'If-None-Match: "v1"\\r\\n', status: "304 Not Modified" }, + ), tooLarge: await runTooLarge(), })); `, @@ -5293,6 +5315,11 @@ describe("requests pipelined in one read", () => { route: { responses: 8, segments: 8 }, static: { responses: 8, segments: 1 }, mixed: { responses: 8, segments: 5 }, + fetchHead: { responses: 8, segments: 8 }, + staticHead: { responses: 8, segments: 1 }, + mixedHead: { responses: 8, segments: 5 }, + fetch304: { responses: 8, segments: 8 }, + static304: { responses: 8, segments: 1 }, tooLarge: { statuses: ["HTTP/1.1 100 Continue", "HTTP/1.1 413 Request Entity Too Large"], segments: 1 }, }, exitCode: 0, diff --git a/test/js/node/http/node-http-req-complete.test.ts b/test/js/node/http/node-http-req-complete.test.ts index 9457700491e2..d99b6e0f9171 100644 --- a/test/js/node/http/node-http-req-complete.test.ts +++ b/test/js/node/http/node-http-req-complete.test.ts @@ -398,6 +398,25 @@ for (const expectation of ["100-continue", "something-else"]) { }); } +// Node corks the socket in res.write() until the next tick, so a destroy() in the same turn drops that chunk: +// https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js#L994-L997 +test("res.destroy() drops the chunk that res.write() queued in the same turn", async () => { + const server = http.createServer((req, res) => { + res.write("sent"); + setImmediate(() => { + res.write("dropped"); + res.destroy(); + }); + }); + await withServer(server, async port => { + const socket = send(port, "GET / HTTP/1.1\r\nHost: x\r\n\r\n", () => {}); + let received = ""; + socket.on("data", chunk => (received += chunk)); + await once(socket, "close"); + assert.strictEqual(received.slice(received.indexOf("\r\n\r\n") + 4), "4\r\nsent\r\n"); + }); +}); + // Only in Bun: when Node.js runs this file it must not spawn itself again. if (typeof Bun !== "undefined") { const { bunEnv, nodeExeAtLeast } = await import("harness");