From 09ecb4631133502b23d817218ca79f84587f1968 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:23:09 +0000 Subject: [PATCH 1/2] Bun.serve: close the connection after a sendfile response when the client asked for close uws_res_end_sendfile ignored its close_connection argument and never ran the shutdown+close check that internalEnd performs. When a Bun.file() body was large enough that sendfile(2) could not complete synchronously, completion happened inside HttpContext::onWritable, whose early return on callOnWritable() == false skips the HTTP_CONNECTION_CLOSE check. The full body arrived but no FIN followed, so an HTTP/1.0 client or one that sent Connection: close waited for EOF forever and the server pinned the fd for the life of idleTimeout. uws_res_end_sendfile now mirrors internalEnd: it records the close flag and, once markDone has cleared HTTP_RESPONSE_PENDING, shuts down and closes the socket when uncorked and drained. --- src/uws_sys/libuwsockets.cpp | 36 +++++++++++++++ test/js/bun/http/bun-serve-file.test.ts | 58 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index b9a49d00bb6c..172257d4359e 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -1299,18 +1299,54 @@ extern "C" uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; auto *data = uwsRes->getHttpResponseData(); data->offset = offset; + if (close_connection) + { + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); + if (!uwsRes->uWS::AsyncSocket::isCorked()) + { + if (data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE) + { + if ((data->state & uWS::HttpResponseData::HTTP_RESPONSE_PENDING) == 0) + { + if (uwsRes->uWS::AsyncSocket::getBufferedAmount() == 0) + { + uwsRes->uWS::AsyncSocket::shutdown(); + uwsRes->uWS::AsyncSocket::close(); + } + } + } + } } else { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; auto *data = uwsRes->getHttpResponseData(); data->offset = offset; + if (close_connection) + { + data->state |= uWS::HttpResponseData::HTTP_CONNECTION_CLOSE; + } data->state |= uWS::HttpResponseData::HTTP_END_CALLED; data->markDone(uwsRes); uwsRes->resetTimeout(); + if (!uwsRes->uWS::AsyncSocket::isCorked()) + { + if (data->state & uWS::HttpResponseData::HTTP_CONNECTION_CLOSE) + { + if ((data->state & uWS::HttpResponseData::HTTP_RESPONSE_PENDING) == 0) + { + if (uwsRes->uWS::AsyncSocket::getBufferedAmount() == 0) + { + uwsRes->uWS::AsyncSocket::shutdown(); + uwsRes->uWS::AsyncSocket::close(); + } + } + } + } } } void uws_res_reset_timeout(int ssl, uws_res_r res) { diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 6e18e4644cad..73b105d90773 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test" import { bunEnv, bunExe, isASAN, isWindows, rmScope, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; import { unlinkSync } from "node:fs"; +import * as net from "node:net"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1138,3 +1139,60 @@ console.log("OK"); }, 60_000, ); + +// The sendfile(2) fast path used for Bun.file() bodies must honour +// Connection: close / HTTP/1.0 semantics after an asynchronous completion. +describe.skipIf(isWindows)("Bun.file() sendfile closes connection when requested", () => { + const SIZE = 32 * 1024 * 1024; + let dir: string; + let server: Server; + + beforeAll(async () => { + dir = tempDirWithFiles("sendfile-close", { + "big.bin": Buffer.alloc(SIZE, 0x62), + }); + server = Bun.serve({ + port: 0, + idleTimeout: 0, + development: false, + fetch: () => new Response(Bun.file(join(dir, "big.bin"))), + }); + }); + + afterAll(() => { + server?.stop(true); + }); + + async function request(raw: string) { + const { promise, resolve, reject } = Promise.withResolvers<{ body: number; ended: boolean }>(); + let body = -1; + let head = Buffer.alloc(0); + const socket = net.connect({ port: server.port, host: "127.0.0.1" }, () => socket.write(raw)); + socket.on("error", reject); + socket.on("data", chunk => { + if (body < 0) { + head = Buffer.concat([head, chunk]); + const i = head.indexOf("\r\n\r\n"); + if (i < 0) return; + body = 0; + chunk = head.subarray(i + 4); + } + body += chunk.length; + }); + socket.on("end", () => resolve({ body, ended: true })); + socket.on("close", () => resolve({ body, ended: false })); + try { + return await promise; + } finally { + socket.destroy(); + } + } + + test.each([ + ["Connection: close", `GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`], + ["HTTP/1.0", `GET / HTTP/1.0\r\nHost: x\r\n\r\n`], + ])("%s", async (_name, raw) => { + const result = await request(raw); + expect(result).toEqual({ body: SIZE, ended: true }); + }); +}); From 74c8f269247b6358921973ca3dcc5073fc74598e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:46:56 +0000 Subject: [PATCH 2/2] test: remove the 32 MiB temp directory in afterAll --- test/js/bun/http/bun-serve-file.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 73b105d90773..f970c3dcb8d0 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1161,6 +1161,7 @@ describe.skipIf(isWindows)("Bun.file() sendfile closes connection when requested afterAll(() => { server?.stop(true); + using _ = rmScope(dir); }); async function request(raw: string) {