From f7d4e9e9d1f08a679ec6e32bda2033a9a8d6f2dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:46:23 +0000 Subject: [PATCH 1/4] Bun.serve: keep idleTimeout armed after the request body completes uWS cleared the socket timeout when the last body byte was parsed and nothing re-armed it while the response was still pending. Bun arms the idle timeout inside the request handler (before the parser consumes body bytes from the same TCP segment), so the body-fin disarm always won and a hung handler on any POST/PUT was never reaped. A synchronous server.timeout(req, N) was cancelled by the same path. Re-arm to the stored per-response idleTimeout on body fin instead of clearing it. A handler that responds inside the inStream callback still calls resetTimeout via end()/tryEnd(), so prompt responses are unchanged. --- packages/bun-uws/src/HttpContext.h | 6 ++-- test/js/bun/http/serve.test.ts | 44 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 9c041bdf611c..1ae0f3acbc0e 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -661,8 +661,10 @@ struct HttpContext { /* Todo: can this handle timeout for non-post as well? */ if (fin) { - /* If we just got the last chunk (or empty chunk), disable timeout */ - us_socket_timeout((struct us_socket_t *) user, 0); + /* Last body chunk received. Re-arm (not clear) the idle timeout so a + * stalled handler is still reaped; clearing it here lost any timeout the + * request handler set before the body bytes in the same segment were parsed. */ + ((HttpResponse *) user)->resetTimeout(); } else { /* We still have some more data coming in later, so reset timeout */ /* Only reset timeout if we got enough bytes (16kb/sec) since last time we reset here */ diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index bad56ba2379b..961b85245024 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3458,6 +3458,50 @@ it.concurrent( 40_000, ); +it.concurrent( + "idleTimeout reaps a hung handler after the request body is received", + async () => { + const aborted: string[] = []; + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + development: false, + idleTimeout: 2, + fetch(req, srv) { + const p = new URL(req.url).pathname; + req.signal.addEventListener("abort", () => aborted.push(p)); + if (p === "/t6") srv.timeout(req, 6); + return new Promise(() => {}); + }, + }); + + const raw = (name: string, wire: string) => + new Promise<{ name: string; closed: boolean }>((resolve, reject) => { + const s = net.connect(server.port as number, "127.0.0.1"); + s.on("connect", () => s.write(wire)); + s.on("error", reject); + s.on("close", () => resolve({ name, closed: true })); + }); + + const results = await Promise.all([ + raw("get", "GET /get HTTP/1.1\r\nHost: a\r\n\r\n"), + raw("post-cl", "POST /post-cl HTTP/1.1\r\nHost: a\r\nContent-Length: 1\r\n\r\nx"), + raw("post-chunked", "POST /post-chunked HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n0\r\n\r\n"), + raw("post-t6", "POST /t6 HTTP/1.1\r\nHost: a\r\nContent-Length: 1\r\n\r\nx"), + ]); + + expect(results).toEqual([ + { name: "get", closed: true }, + { name: "post-cl", closed: true }, + { name: "post-chunked", closed: true }, + { name: "post-t6", closed: true }, + ]); + expect(aborted.sort()).toEqual(["/get", "/post-chunked", "/post-cl", "/t6"]); + expect(server.pendingRequests).toBe(0); + }, + 20_000, +); + it.concurrent("#6462", async () => { let headers: string[] = []; using server = Bun.serve({ From bb86d6969acf498c6c3cea876c292fc201a290ee Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:48:42 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/bun/http/serve.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 961b85245024..d57e5bb9f229 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3486,7 +3486,10 @@ it.concurrent( const results = await Promise.all([ raw("get", "GET /get HTTP/1.1\r\nHost: a\r\n\r\n"), raw("post-cl", "POST /post-cl HTTP/1.1\r\nHost: a\r\nContent-Length: 1\r\n\r\nx"), - raw("post-chunked", "POST /post-chunked HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n0\r\n\r\n"), + raw( + "post-chunked", + "POST /post-chunked HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n0\r\n\r\n", + ), raw("post-t6", "POST /t6 HTTP/1.1\r\nHost: a\r\nContent-Length: 1\r\n\r\nx"), ]); From b8e9888b0b8c519c3ea7de9dce1ddcdf4c3e4405 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:42:25 +0000 Subject: [PATCH 3/4] ci: retrigger From ba5e1e75a2afc0758e7fa1b4e5678319fe6ee679 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:17:34 +0000 Subject: [PATCH 4/4] serve.test: fail on an assertion when idleTimeout skips a request with a body The test used to wait for every socket to close, so the unfixed build failed on the test timeout, and its closed: true assertion could not fail. A request without a body is now the clock: the timer wheel closes it on the next sweep, and every request with a body was armed before it. When it closes, the abort list must hold each request that idleTimeout covers. The rows cover a body that ends in the head's segment, a body that ends in a later read (plain and TLS), routes, req.text(), a stream that goes quiet, and server.timeout(req, N) for N of 2, 0 and 60. State the invariant in the HttpContext.h comment. --- packages/bun-uws/src/HttpContext.h | 4 +- test/js/bun/http/serve.test.ts | 158 +++++++++++++++++++++++------ 2 files changed, 129 insertions(+), 33 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 1ae0f3acbc0e..9bdb08907032 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -661,9 +661,7 @@ struct HttpContext { /* Todo: can this handle timeout for non-post as well? */ if (fin) { - /* Last body chunk received. Re-arm (not clear) the idle timeout so a - * stalled handler is still reaped; clearing it here lost any timeout the - * request handler set before the body bytes in the same segment were parsed. */ + /* The body is complete: the idle timeout now runs against the pending response */ ((HttpResponse *) user)->resetTimeout(); } else { /* We still have some more data coming in later, so reset timeout */ diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index d57e5bb9f229..b3037977672c 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3459,50 +3459,148 @@ it.concurrent( ); it.concurrent( - "idleTimeout reaps a hung handler after the request body is received", + "idleTimeout closes a request with a body whose handler has not responded", async () => { const aborted: string[] = []; + const entered: Record> = {}; + const enter = (path: string) => (entered[path] ??= Promise.withResolvers()); + const held = { + "/timeout-0": Promise.withResolvers(), + "/timeout-60": Promise.withResolvers(), + }; + const never = new Promise(() => {}); + const tlsAborted = Promise.withResolvers(); + + function handler(req: Request, server: Server) { + const path = new URL(req.url).pathname; + req.signal.addEventListener("abort", () => { + aborted.push(path); + if (path === "/tls-late") tlsAborted.resolve(); + }); + enter(path).resolve(); + switch (path) { + case "/text": + return req.text().then(() => never); + case "/stream": + return new Response(new ReadableStream({ start: controller => controller.enqueue("first") })); + case "/timeout-2": + server.timeout(req, 2); + return never; + case "/timeout-0": + server.timeout(req, 0); + return held[path].promise; + case "/timeout-60": + server.timeout(req, 60); + return held[path].promise; + } + return never; + } + using server = Bun.serve({ port: 0, hostname: "127.0.0.1", - development: false, idleTimeout: 2, - fetch(req, srv) { - const p = new URL(req.url).pathname; - req.signal.addEventListener("abort", () => aborted.push(p)); - if (p === "/t6") srv.timeout(req, 6); - return new Promise(() => {}); - }, + routes: { "/route": { POST: handler } }, + fetch: handler, }); - - const raw = (name: string, wire: string) => - new Promise<{ name: string; closed: boolean }>((resolve, reject) => { - const s = net.connect(server.port as number, "127.0.0.1"); - s.on("connect", () => s.write(wire)); - s.on("error", reject); - s.on("close", () => resolve({ name, closed: true })); + // Only server.timeout(req, 2) can close a request on this one. + using patient = Bun.serve({ port: 0, hostname: "127.0.0.1", idleTimeout: 255, fetch: handler }); + using secure = Bun.serve({ port: 0, hostname: "127.0.0.1", idleTimeout: 2, tls, fetch: handler }); + + const sockets: net.Socket[] = []; + using _ = { [Symbol.dispose]: () => sockets.forEach(socket => socket.destroy()) }; + + function open(socket: net.Socket) { + sockets.push(socket); + let response = ""; + let check = () => {}; + const closed = new Promise(resolve => socket.on("close", () => resolve())); + socket.on("error", () => {}); + socket.on("data", chunk => { + response += chunk; + check(); }); + return { + closed, + // Settles when the kernel has the bytes, or when the timer closed the socket first. + write: (bytes: string) => + Promise.race([new Promise(resolve => socket.write(bytes, () => resolve())), closed]), + received: async (text: string) => { + const arrived = new Promise(resolve => { + check = () => response.includes(text) && resolve(); + check(); + }); + await Promise.race([arrived, closed]); + return response; + }, + }; + } + type Client = ReturnType; - const results = await Promise.all([ - raw("get", "GET /get HTTP/1.1\r\nHost: a\r\n\r\n"), - raw("post-cl", "POST /post-cl HTTP/1.1\r\nHost: a\r\nContent-Length: 1\r\n\r\nx"), - raw( - "post-chunked", - "POST /post-chunked HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n0\r\n\r\n", + // A string step is written to the socket. A function step is a condition to wait for. + async function request(to: number | net.Socket, ...steps: (string | ((client: Client) => Promise))[]) { + const client = open(typeof to === "number" ? net.connect(to, "127.0.0.1") : to); + for (const step of steps) await (typeof step === "string" ? client.write(step) : step(client)); + return client; + } + const head = (path: string, framing: string) => `POST ${path} HTTP/1.1\r\nHost: localhost\r\n${framing}\r\n\r\n`; + const contentLength = (path: string) => head(path, "Content-Length: 1"); + const chunked = (path: string) => head(path, "Transfer-Encoding: chunked"); + + const closedByTimer = await Promise.all([ + // The last body byte arrives with the head. + request(server.port!, contentLength("/content-length") + "x"), + request(server.port!, chunked("/chunked") + "1\r\nx\r\n0\r\n\r\n"), + request(server.port!, contentLength("/route") + "x"), + request(server.port!, contentLength("/text") + "x"), + request(patient.port!, contentLength("/timeout-2") + "x"), + // The last body byte arrives in a later read. + request(server.port!, contentLength("/content-length-late"), () => enter("/content-length-late").promise, "x"), + request(server.port!, chunked("/chunked-late") + "1\r\nx\r\n", () => enter("/chunked-late").promise, "0\r\n\r\n"), + request( + nodeTls.connect({ port: secure.port!, host: "127.0.0.1", rejectUnauthorized: false }), + contentLength("/tls-late"), + () => enter("/tls-late").promise, + "x", ), - raw("post-t6", "POST /t6 HTTP/1.1\r\nHost: a\r\nContent-Length: 1\r\n\r\nx"), + // The last body byte arrives after the last write of a response that then goes quiet. + request(server.port!, contentLength("/stream"), client => client.received("first"), "x"), + ]); + const stillOpen = await Promise.all([ + request(server.port!, contentLength("/timeout-0") + "x"), + request(server.port!, contentLength("/timeout-60") + "x"), ]); - expect(results).toEqual([ - { name: "get", closed: true }, - { name: "post-cl", closed: true }, - { name: "post-chunked", closed: true }, - { name: "post-t6", closed: true }, + // The timer wheel closes a request without a body on its next sweep, at most 4 s away. + // The kernel had every byte of the requests above before this one connected, so no + // timer above was armed later than this one: when this socket closes, every request + // that idleTimeout covers is closed too. + const control = await request(server.port!, "GET /no-body HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await control.closed; + + expect(aborted.filter(path => path !== "/tls-late").sort()).toEqual([ + "/chunked", + "/chunked-late", + "/content-length", + "/content-length-late", + "/no-body", + "/route", + "/stream", + "/text", + "/timeout-2", ]); - expect(aborted.sort()).toEqual(["/get", "/post-chunked", "/post-cl", "/t6"]); - expect(server.pendingRequests).toBe(0); + // A TLS close waits for the client's close_notify, so this abort comes a few reads later. + await tlsAborted.promise; + await Promise.all(closedByTimer.map(client => client.closed)); + expect(server.pendingRequests).toBe(2); + + held["/timeout-0"].resolve(new Response("zero")); + held["/timeout-60"].resolve(new Response("sixty")); + const responses = await Promise.all([stillOpen[0].received("zero"), stillOpen[1].received("sixty")]); + expect(responses.map(response => response.split("\r\n")[0])).toEqual(["HTTP/1.1 200 OK", "HTTP/1.1 200 OK"]); }, - 20_000, + // One sweep of the 4 s timer wheel does not fit the 5 s default. + 15_000, ); it.concurrent("#6462", async () => {