From 6c65bbc683cd7ec8ff2ad3d2364fa7ee007e7551 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:04:57 +0000 Subject: [PATCH 01/10] Bun.serve: do not idle-time-out a response the peer is still reading The idle timer is re-armed by writes and by writable events only. The kernel raises a writable event once a good part of its send buffer has drained, so a peer that reads steadily but slower than that buffer drains can sit between two writable events for longer than idleTimeout. The request was then aborted and the socket closed mid-body. When the timer is armed with outgoing bytes pending (a short write, or a remainder in the uWS buffer), remember the kernel's delivery mark (Linux SIOCOUTQNSD/SIOCOUTQ, macOS SO_NWRITE, Windows SIO_TCP_INFO BytesOut). On timeout, a changed mark means the peer took bytes since: re-arm for one more period. A peer that took nothing for a whole period closes as before. --- packages/bun-usockets/src/bsd.c | 48 ++++++++++++ .../src/internal/networking/bsd.h | 4 + packages/bun-usockets/src/libusockets.h | 10 +++ packages/bun-usockets/src/socket.c | 11 +++ packages/bun-uws/src/HttpContext.h | 12 +++ packages/bun-uws/src/HttpResponse.h | 21 ++++- packages/bun-uws/src/HttpResponseData.h | 7 ++ src/uws_sys/libuwsockets.cpp | 10 ++- test/js/bun/http/serve.test.ts | 76 +++++++++++++++++++ 9 files changed, 193 insertions(+), 6 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 4fe48236ca30..bed72e38df16 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -56,6 +56,10 @@ static void init_debug_logging() { #include #include #include +#include +#ifdef __linux__ +#include +#endif #else /* _WIN32 */ #include #endif @@ -680,6 +684,50 @@ int bsd_socket_buffer_size(LIBUS_SOCKET_DESCRIPTOR fd, int is_recv, int size, in return 0; } +int bsd_socket_send_progress_mark(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *mark) { +#if defined(_WIN32) + /* Winsock has no unsent-queue query; the cumulative sent-bytes counter + * moves whenever the kernel delivers more to the peer. */ + TCP_INFO_v0 info; + DWORD version = 0, bytes = 0; + memset(&info, 0, sizeof(info)); + if (WSAIoctl(fd, SIO_TCP_INFO, &version, sizeof(version), &info, sizeof(info), &bytes, NULL, NULL) != 0) { + return -1; + } + *mark = info.BytesOut; + return 0; +#elif defined(__APPLE__) + int queued = 0; + socklen_t len = sizeof(queued); + if (getsockopt(fd, SOL_SOCKET, SO_NWRITE, &queued, &len) != 0) { + return -1; + } + *mark = (uint64_t) queued; + return 0; +#elif defined(SIOCOUTQ) + int queued = 0; + /* TCP: bytes not yet sent. Bytes in flight at the time of a mark are + * acked soon after, and counting them would read as progress once on a + * peer that then stalls. Unix sockets only answer SIOCOUTQ (bytes the + * peer has not read). */ +#ifdef SIOCOUTQNSD + if (ioctl(fd, SIOCOUTQNSD, &queued) == 0) { + *mark = (uint64_t) queued; + return 0; + } +#endif + if (ioctl(fd, SIOCOUTQ, &queued) != 0) { + return -1; + } + *mark = (uint64_t) queued; + return 0; +#else + (void) fd; + (void) mark; + return -1; +#endif +} + void bsd_socket_flush(LIBUS_SOCKET_DESCRIPTOR fd) { // Linux TCP_CORK has the same underlying corking mechanism as with MSG_MORE #ifdef TCP_CORK diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index 4d53a9fa3fa6..fcb38d136abc 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -190,6 +190,10 @@ int bsd_socket_get_tos(LIBUS_SOCKET_DESCRIPTOR fd); * resulting value to *out; on failure returns the setsockopt/getsockopt result * with the error left in errno (WSAGetLastError on Windows). */ int bsd_socket_buffer_size(LIBUS_SOCKET_DESCRIPTOR fd, int is_recv, int size, int *out); +/* See us_socket_send_progress_mark. Linux: SIOCOUTQ, macOS: SO_NWRITE (both the + * unsent queue), Windows: SIO_TCP_INFO BytesOut (cumulative). Returns 0 and + * writes the mark, or -1 when the platform or socket kind cannot answer. */ +int bsd_socket_send_progress_mark(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *mark); int bsd_prepare_adopted_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd); int bsd_set_reuseaddr(LIBUS_SOCKET_DESCRIPTOR fd); int bsd_bind_udp_fd(LIBUS_SOCKET_DESCRIPTOR fd, const struct sockaddr *addr, int addrlen, int flags); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 0a5d82ae32c3..bb122b8a6f01 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -677,6 +677,9 @@ void us_socket_shutdown(us_socket_r s) nonnull_fn_decl; void us_socket_shutdown_read(us_socket_r s) nonnull_fn_decl; int us_socket_is_shut_down(us_socket_r s) nonnull_fn_decl; int us_socket_is_closed(us_socket_r s) nonnull_fn_decl; +/* The last write was short (the kernel send buffer is full) and the socket + * polls for a writable event. Cleared when that event is dispatched. */ +int us_socket_is_awaiting_writable(us_socket_r s) nonnull_fn_decl; int us_socket_is_ssl_handshake_finished(us_socket_r s) nonnull_fn_decl; int us_socket_ssl_handshake_callback_has_fired(us_socket_r s) nonnull_fn_decl; /* TLS ciphertext bytes already sealed for this socket and reported as @@ -724,6 +727,13 @@ int us_socket_get_error(us_socket_r s); * making a stall there routine backpressure, so it asks the kernel * (SO_ERROR, then a zero-byte send probe). */ int us_socket_stalled_write_means_peer_gone(us_socket_r s); +/* A mark of how far the kernel has delivered this socket's outgoing bytes to + * the peer. Two marks taken with no write in between differ iff the kernel + * moved bytes toward the peer meanwhile, which tells a slow reader (the marks + * change) from a stalled one (the marks repeat). Linux and macOS read the + * unsent queue, Windows the cumulative sent-bytes counter. Returns 0 and + * writes the mark, or -1 where the platform or socket kind cannot answer. */ +int us_socket_send_progress_mark(us_socket_r s, uint64_t *mark); void us_socket_ref(us_socket_r s); void us_socket_unref(us_socket_r s); diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 20f351f74b6c..d99fdd78de98 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -422,6 +422,13 @@ int us_socket_stalled_write_means_peer_gone(struct us_socket_t *s) { #endif } +int us_socket_send_progress_mark(struct us_socket_t *s, uint64_t *mark) { + if (us_socket_is_closed(s)) { + return -1; + } + return bsd_socket_send_progress_mark(us_poll_fd(&s->p), mark); +} + int us_socket_write2(struct us_socket_t *s, const char *header, int header_length, const char *payload, int payload_length) { if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { return 0; @@ -710,6 +717,10 @@ __attribute__((always_inline)) int us_socket_is_shut_down(struct us_socket_t *s) return us_internal_poll_type(&s->p) == POLL_TYPE_SOCKET_SHUT_DOWN; } +int us_socket_is_awaiting_writable(struct us_socket_t *s) { + return s->flags.last_write_failed; +} + int us_connecting_socket_is_shut_down(struct us_connecting_socket_t *c) { return c->shutdown; } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index c3fbda0d72c7..12ddcb8b90ea 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -945,6 +945,18 @@ struct HttpContext { // Node.js by default closes the connection but they emit the timeout event before that HttpResponseData *httpResponseData = reinterpret_cast *>(asyncSocket->getAsyncSocketData()); + /* The timer was armed with outgoing bytes pending (see + * HttpResponse::resetTimeout). A peer that took some of them since is + * a slow reader, not an idle one: give it another period. */ + if (httpResponseData->state & HttpResponseData::HTTP_SEND_PROGRESS_MARKED) { + uint64_t mark; + if (us_socket_send_progress_mark(s, &mark) == 0 && mark != httpResponseData->sendProgressMark) { + httpResponseData->sendProgressMark = mark; + asyncSocket->timeout(httpResponseData->idleTimeout); + return s; + } + } + if (httpResponseData->onTimeout) { httpResponseData->onTimeout((HttpResponse *)s, httpResponseData->userData); } diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index c556f4dece84..f352eb6dfe40 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -54,15 +54,28 @@ struct HttpResponse : public AsyncSocket { } void setTimeout(uint8_t seconds) { - auto* data = getHttpResponseData(); - data->idleTimeout = seconds; - Super::timeout(data->idleTimeout); + getHttpResponseData()->idleTimeout = seconds; + resetTimeout(); } void resetTimeout() { auto* data = getHttpResponseData(); - Super::timeout(data->idleTimeout); + + /* Outgoing bytes the peer has not taken yet (the kernel refused part of + * the last write, or uWS holds a remainder) drain at the peer's pace, + * with no write on our side to re-arm the timer and a writable event + * only once a good part of the kernel send buffer is free. Remember how + * far the kernel got, so that HttpContext::onTimeout can tell a slow + * reader from a stalled one. */ + uint64_t mark; + if ((Super::getBufferedAmount() > 0 || us_socket_is_awaiting_writable((us_socket_t *) this)) + && us_socket_send_progress_mark((us_socket_t *) this, &mark) == 0) { + data->sendProgressMark = mark; + data->state |= HttpResponseData::HTTP_SEND_PROGRESS_MARKED; + } else { + data->state &= ~HttpResponseData::HTTP_SEND_PROGRESS_MARKED; + } } /* Write an unsigned 32-bit integer in hex */ void writeUnsignedHex(unsigned int value) { diff --git a/packages/bun-uws/src/HttpResponseData.h b/packages/bun-uws/src/HttpResponseData.h index 756007a69d28..d4f9e7179c1e 100644 --- a/packages/bun-uws/src/HttpResponseData.h +++ b/packages/bun-uws/src/HttpResponseData.h @@ -150,6 +150,10 @@ struct HttpResponseData : AsyncSocketData, HttpParser { * shutdown sweep; the shouldCloseConnection() gates act on it once the * in-flight work completes. */ HTTP_CLOSE_WHEN_IDLE = 1 << 17, + /* The idle timer was armed while outgoing bytes were pending, and + * sendProgressMark holds the kernel's delivery mark from that moment + * (HttpResponse::resetTimeout). onTimeout compares against it. */ + HTTP_SEND_PROGRESS_MARKED = 1 << 18, /* Bits that describe the connection rather than the response in flight. * There is one HttpResponseData per socket, reused by every request on a @@ -197,6 +201,9 @@ struct HttpResponseData : AsyncSocketData, HttpParser { OnTimeoutCallback onTimeout = nullptr; /* Outgoing offset */ uint64_t offset = 0; + /* us_socket_send_progress_mark when the idle timer was last armed or + * extended, valid while HTTP_SEND_PROGRESS_MARKED is set. */ + uint64_t sendProgressMark = 0; /* Let's track number of bytes since last timeout reset in data handler */ unsigned int received_bytes_per_timeout = 0; diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 5c1e40665e62..1d5fbcc7b084 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -1513,15 +1513,21 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, void uws_res_uncork(int ssl, uws_res_r res) { + /* A short write here leaves bytes pending on a timer that was armed + * before them; re-arm so the timer knows (HttpResponse::resetTimeout). */ if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->uncork(); + if (uwsRes->uncork().second && !us_socket_is_closed((struct us_socket_t *)res)) { + uwsRes->resetTimeout(); + } } else { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->uncork(); + if (uwsRes->uncork().second && !us_socket_is_closed((struct us_socket_t *)res)) { + uwsRes->resetTimeout(); + } } } diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e4c5aef97553..695d577d35d1 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3123,6 +3123,82 @@ it.concurrent( 20_000, ); +// A client that reads steadily but slower than the kernel send buffer drains +// gets no writable event on the server for longer than idleTimeout, so the +// server used to abort the request and close the socket mid-body. The client +// here reads one chunk, pauses, and resumes after a fixed delay: the pacing is +// the condition under test, not a wait for one. The timer has a 4 second +// granularity, so the idle period is 8 to 12 seconds and the client reads at +// least once in every period. +describe.concurrent("idleTimeout keeps a slow reader alive", () => { + const TOTAL = 16 << 20; + const PACED_MS = 16_000; + const PAUSE_MS = 5_000; + for (const kind of ["bytes", "stream"] as const) { + it(kind, async () => { + let aborted = false; + using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 10, + fetch(req) { + req.signal.addEventListener("abort", () => (aborted = true)); + if (kind === "bytes") return new Response(new Uint8Array(TOTAL)); + const chunk = new Uint8Array(16 * 1024); + let sent = 0; + return new Response( + new ReadableStream({ + pull(c) { + if (sent >= TOTAL) return c.close(); + c.enqueue(chunk); + sent += chunk.byteLength; + }, + }), + ); + }, + }); + + const { promise, resolve } = Promise.withResolvers(); + let head = Buffer.alloc(0); + let headerDone = false; + let contentLength = -1; + let body = 0; + let tail = Buffer.alloc(0); + const start = performance.now(); + const sock = net.connect(server.port, "127.0.0.1", () => { + sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + }); + sock.on("data", (d: Buffer) => { + if (!headerDone) { + head = Buffer.concat([head, d]); + const i = head.indexOf("\r\n\r\n"); + if (i < 0) return; + headerDone = true; + const m = /content-length: (\d+)/i.exec(head.subarray(0, i).toString()); + if (m) contentLength = Number(m[1]); + d = head.subarray(i + 4); + } + body += d.length; + tail = Buffer.concat([tail, d]).subarray(-8); + if (performance.now() - start < PACED_MS) { + sock.pause(); + setTimeout(() => sock.resume(), PAUSE_MS); + } + }); + sock.on("error", () => resolve()); + sock.on("close", () => resolve()); + await promise; + + expect(aborted).toBe(false); + if (kind === "bytes") { + expect(body).toBe(contentLength); + } else { + expect(tail.toString()).toEndWith("0\r\n\r\n"); + } + }, 30_000); + } +}); + it.concurrent("allow requestIP after async operation", async () => { using server = Bun.serve({ port: 0, From 57bf013abb4b5991228e854e6e6b485c73405e16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:49:25 +0000 Subject: [PATCH 02/10] Require the minimum receive rate for send progress, cover HTTP/2 and sendfile A peer that takes one byte per period is idle. Re-arm only when the kernel delivered at least HTTP_RECEIVE_THROUGHPUT_BYTES * idleTimeout since the mark, the same floor the receive side applies. Http2Connection::touch and Http2Context::onTimeout take part in the same rule. The sendfile path arms its writable wait outside of any uWS write, so mark_needs_more re-arms the timer to record the pending bytes. --- packages/bun-usockets/src/libusockets.h | 13 ++- packages/bun-usockets/src/socket.c | 19 ++++ packages/bun-uws/src/Http2Context.h | 15 +++ packages/bun-uws/src/HttpContext.h | 25 +++-- src/uws_sys/libuwsockets.cpp | 3 + test/js/bun/http/serve-http2.test.ts | 45 ++++++++ test/js/bun/http/serve.test.ts | 139 +++++++++++++++--------- 7 files changed, 190 insertions(+), 69 deletions(-) diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index bb122b8a6f01..f290eb97dd7b 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -728,12 +728,15 @@ int us_socket_get_error(us_socket_r s); * (SO_ERROR, then a zero-byte send probe). */ int us_socket_stalled_write_means_peer_gone(us_socket_r s); /* A mark of how far the kernel has delivered this socket's outgoing bytes to - * the peer. Two marks taken with no write in between differ iff the kernel - * moved bytes toward the peer meanwhile, which tells a slow reader (the marks - * change) from a stalled one (the marks repeat). Linux and macOS read the - * unsent queue, Windows the cumulative sent-bytes counter. Returns 0 and - * writes the mark, or -1 where the platform or socket kind cannot answer. */ + * the peer. Linux and macOS read the unsent queue, Windows the cumulative + * sent-bytes counter. Returns 0 and writes the mark, or -1 where the platform + * or socket kind cannot answer. Take it when an idle timer is armed with + * outgoing bytes pending, so that the timeout can tell a slow reader from a + * stalled one with us_socket_send_progressed. */ int us_socket_send_progress_mark(us_socket_r s, uint64_t *mark); +/* Did the kernel move at least min_bytes toward the peer since *mark was + * taken, with no write in between? Advances *mark when it did. */ +int us_socket_send_progressed(us_socket_r s, uint64_t *mark, uint64_t min_bytes); void us_socket_ref(us_socket_r s); void us_socket_unref(us_socket_r s); diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index d99fdd78de98..fe3d3ac44de9 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -429,6 +429,25 @@ int us_socket_send_progress_mark(struct us_socket_t *s, uint64_t *mark) { return bsd_socket_send_progress_mark(us_poll_fd(&s->p), mark); } +int us_socket_send_progressed(struct us_socket_t *s, uint64_t *mark, uint64_t min_bytes) { + uint64_t now; + if (us_socket_send_progress_mark(s, &now) != 0) { + return 0; + } +#ifdef _WIN32 + /* Cumulative sent bytes: the mark grows. */ + uint64_t delivered = now > *mark ? now - *mark : 0; +#else + /* Unsent queue: the mark shrinks. */ + uint64_t delivered = *mark > now ? *mark - now : 0; +#endif + if (delivered == 0 || delivered < min_bytes) { + return 0; + } + *mark = now; + return 1; +} + int us_socket_write2(struct us_socket_t *s, const char *header, int header_length, const char *payload, int payload_length) { if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { return 0; diff --git a/packages/bun-uws/src/Http2Context.h b/packages/bun-uws/src/Http2Context.h index 7fdc6bca0a30..1de2ca32a0a1 100644 --- a/packages/bun-uws/src/Http2Context.h +++ b/packages/bun-uws/src/Http2Context.h @@ -427,6 +427,10 @@ struct Http2Connection { /* Seconds of silence before onTimeout; recomputed when a stream sets a * timeout or retires (see Http2Response::timeoutS). */ unsigned idleTimeoutS = 0; + /* The idle timer was armed (touch) with outgoing bytes pending, and + * sendProgressMark holds the kernel's delivery mark from that moment. */ + bool sendProgressMarked = false; + uint64_t sendProgressMark = 0; int drainDepth = 0; Http2Connection(us_socket_t *socket, Http2Context *context) : s(socket), ctx(context) { @@ -959,6 +963,14 @@ struct Http2Context { * connection): streams in flight are aborted through onClose. */ static us_socket_t *onTimeout(us_socket_t *s) { Http2Connection *conn = connection(s); + /* Same rule as HttpContext::onTimeout: a peer that took the pending + * response bytes at the minimum receive rate is slow, not idle. */ + if (conn->sendProgressMarked + && us_socket_send_progressed(s, &conn->sendProgressMark, + (uint64_t) HttpContext::HTTP_RECEIVE_THROUGHPUT_BYTES * conn->idleTimeoutS)) { + us_socket_timeout(s, conn->idleTimeoutS); + return s; + } conn->busy++; std::vector open = conn->streams; for (Http2Response *stream : open) { @@ -1034,6 +1046,9 @@ inline void Http2Connection::writeSettings() { inline void Http2Connection::touch() { us_socket_timeout(s, idleTimeoutS); + /* See HttpResponse::resetTimeout. */ + sendProgressMarked = (out->length() > 0 || us_socket_is_awaiting_writable(s)) + && us_socket_send_progress_mark(s, &sendProgressMark) == 0; } inline void Http2Connection::recomputeIdleTimeout() { diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 12ddcb8b90ea..7fa31ac13379 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -108,15 +108,17 @@ struct HttpContext { us_socket_group_t group{}; HttpContextData data; - /* Minimum allowed receive throughput per second (clients uploading less than 16kB/sec get dropped) */ - static constexpr int HTTP_RECEIVE_THROUGHPUT_BYTES = 16 * 1024; - /* Not constexpr — the ordinals are linked from `src/uws_sys/SocketKind.rs` * so a reorder there can't silently mis-route us. Only ever read * at runtime (listen/adopt). */ static unsigned char socketKind() { return SSL ? US_SOCKET_KIND_UWS_HTTP_TLS : US_SOCKET_KIND_UWS_HTTP; } public: + /* Minimum allowed throughput per second in either direction: a client that + * uploads, or takes a response, slower than 16kB/sec is idle. Shared with + * Http2Context::onTimeout. */ + static constexpr int HTTP_RECEIVE_THROUGHPUT_BYTES = 16 * 1024; + /* node:http flood prevention: re-feed parked request bytes through the same * parse path fresh socket data takes. The caller guarantees the buffer has * LIBUS_RECV_BUFFER_PADDING of writable slack past `length`. */ @@ -946,15 +948,14 @@ struct HttpContext { HttpResponseData *httpResponseData = reinterpret_cast *>(asyncSocket->getAsyncSocketData()); /* The timer was armed with outgoing bytes pending (see - * HttpResponse::resetTimeout). A peer that took some of them since is - * a slow reader, not an idle one: give it another period. */ - if (httpResponseData->state & HttpResponseData::HTTP_SEND_PROGRESS_MARKED) { - uint64_t mark; - if (us_socket_send_progress_mark(s, &mark) == 0 && mark != httpResponseData->sendProgressMark) { - httpResponseData->sendProgressMark = mark; - asyncSocket->timeout(httpResponseData->idleTimeout); - return s; - } + * HttpResponse::resetTimeout). A peer that took them at the same + * minimum rate the receive side demands (onData) is a slow reader, + * not an idle one: give it another period. */ + if ((httpResponseData->state & HttpResponseData::HTTP_SEND_PROGRESS_MARKED) + && us_socket_send_progressed(s, &httpResponseData->sendProgressMark, + (uint64_t) HTTP_RECEIVE_THROUGHPUT_BYTES * httpResponseData->idleTimeout)) { + asyncSocket->timeout(httpResponseData->idleTimeout); + return s; } if (httpResponseData->onTimeout) { diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 1d5fbcc7b084..cbf850c6facb 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -1541,6 +1541,9 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, * half-open socket. */ us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_WRITABLE | ((s->flags.is_paused || s->read_eof) ? 0 : LIBUS_SOCKET_READABLE)); + /* The sendfile that ran before this left bytes pending; re-arm the idle + * timer so it records that (HttpResponse::resetTimeout). */ + ((uWS::HttpResponse *)res)->resetTimeout(); } __attribute__((callback (corker, ctx))) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 2ce0c85d361c..0419890ff65f 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -855,4 +855,49 @@ describe("Bun.serve http2 in-process", () => { expect(aborted).toBe(1); await new Promise(r => session.close(() => r())); }); + + // The peer opens a window far larger than the kernel send buffer, so the + // TCP send buffer is what throttles the response, like an HTTP/1.1 client + // reading slowly. No write and no writable event re-arms the idle timer for + // longer than idleTimeout (8 to 12 seconds with the 4 second timer + // granularity), yet the peer keeps taking bytes and must not be aborted. + // The pacing is the condition under test, not a wait for one. + test("idleTimeout keeps a slow reader alive", async () => { + const TOTAL = 16 << 20; + let aborted = false; + await using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + http2: true, + idleTimeout: 10, + fetch(req) { + req.signal.addEventListener("abort", () => (aborted = true)); + return new Response(new Uint8Array(TOTAL)); + }, + }); + const settings = Buffer.alloc(6); + settings.writeUInt16BE(4, 0); // INITIAL_WINDOW_SIZE + settings.writeUInt32BE(64 << 20, 2); + const raw = await RawH2.connect(server.port, false, { settings }); + const inc = Buffer.alloc(4); + inc.writeUInt32BE(64 << 20, 0); + raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); + const start = performance.now(); + raw.socket.on("data", () => { + if (performance.now() - start < 16_000) { + raw.socket.pause(); + setTimeout(() => raw.socket.resume(), 5_000); + } + }); + raw.headers(1, baseHeaders("/")); + const ended = await raw + .waitFor(f => f.type === T.DATA && f.streamId === 1 && (f.flags & F.END_STREAM) !== 0) + .then( + () => true, + () => false, + ); + const received = raw.frames.filter(f => f.type === T.DATA).reduce((n, f) => n + f.payload.length, 0); + expect({ aborted, ended, received }).toEqual({ aborted: false, ended: true, received: TOTAL }); + raw.close(); + }, 30_000); }); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 695d577d35d1..7e8a3ce80098 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3130,73 +3130,108 @@ it.concurrent( // the condition under test, not a wait for one. The timer has a 4 second // granularity, so the idle period is 8 to 12 seconds and the client reads at // least once in every period. -describe.concurrent("idleTimeout keeps a slow reader alive", () => { +describe.concurrent("idleTimeout and a slow reader", () => { const TOTAL = 16 << 20; const PACED_MS = 16_000; const PAUSE_MS = 5_000; - for (const kind of ["bytes", "stream"] as const) { - it(kind, async () => { - let aborted = false; - using server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - idleTimeout: 10, - fetch(req) { - req.signal.addEventListener("abort", () => (aborted = true)); - if (kind === "bytes") return new Response(new Uint8Array(TOTAL)); - const chunk = new Uint8Array(16 * 1024); - let sent = 0; - return new Response( - new ReadableStream({ - pull(c) { - if (sent >= TOTAL) return c.close(); - c.enqueue(chunk); - sent += chunk.byteLength; - }, - }), - ); - }, - }); - const { promise, resolve } = Promise.withResolvers(); - let head = Buffer.alloc(0); - let headerDone = false; - let contentLength = -1; - let body = 0; - let tail = Buffer.alloc(0); + function serveBody(kind: "bytes" | "stream" | "file", onAbort: () => void) { + return Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 10, + fetch(req) { + req.signal.addEventListener("abort", onAbort); + if (kind === "bytes") return new Response(new Uint8Array(TOTAL)); + if (kind === "file") return new Response(Bun.file(bigFile)); + const chunk = new Uint8Array(16 * 1024); + let sent = 0; + return new Response( + new ReadableStream({ + pull(c) { + if (sent >= TOTAL) return c.close(); + c.enqueue(chunk); + sent += chunk.byteLength; + }, + }), + ); + }, + }); + } + + // Sends a GET and reads the response at `pace` until the socket closes. + // Resolves with the body byte count, the Content-Length (-1 when chunked) + // and the last 8 bytes on the wire. + function readResponse(port: number, pace: (sock: net.Socket) => void) { + const { promise, resolve } = Promise.withResolvers<{ body: number; contentLength: number; tail: string }>(); + let head = Buffer.alloc(0); + let headerDone = false; + let contentLength = -1; + let body = 0; + let tail = Buffer.alloc(0); + const sock = net.connect(port, "127.0.0.1", () => { + sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + }); + sock.on("data", (d: Buffer) => { + if (!headerDone) { + head = Buffer.concat([head, d]); + const i = head.indexOf("\r\n\r\n"); + if (i < 0) return; + headerDone = true; + const m = /content-length: (\d+)/i.exec(head.subarray(0, i).toString()); + if (m) contentLength = Number(m[1]); + d = head.subarray(i + 4); + } + body += d.length; + tail = Buffer.concat([tail, d]).subarray(-8); + pace(sock); + }); + const done = () => resolve({ body, contentLength, tail: tail.toString() }); + sock.on("error", done); + sock.on("close", done); + return promise; + } + + const dir = tempDir("serve-slow-reader", { "big.bin": Buffer.alloc(TOTAL) }); + const bigFile = join(String(dir), "big.bin"); + afterAll(() => dir[Symbol.dispose]()); + + for (const kind of ["bytes", "stream", "file"] as const) { + it(`keeps a slow ${kind} reader alive`, async () => { + let aborted = false; + using server = serveBody(kind, () => (aborted = true)); const start = performance.now(); - const sock = net.connect(server.port, "127.0.0.1", () => { - sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); - }); - sock.on("data", (d: Buffer) => { - if (!headerDone) { - head = Buffer.concat([head, d]); - const i = head.indexOf("\r\n\r\n"); - if (i < 0) return; - headerDone = true; - const m = /content-length: (\d+)/i.exec(head.subarray(0, i).toString()); - if (m) contentLength = Number(m[1]); - d = head.subarray(i + 4); - } - body += d.length; - tail = Buffer.concat([tail, d]).subarray(-8); + const res = await readResponse(server.port, sock => { if (performance.now() - start < PACED_MS) { sock.pause(); setTimeout(() => sock.resume(), PAUSE_MS); } }); - sock.on("error", () => resolve()); - sock.on("close", () => resolve()); - await promise; - expect(aborted).toBe(false); - if (kind === "bytes") { - expect(body).toBe(contentLength); + if (kind === "stream") { + expect(res.tail).toEndWith("0\r\n\r\n"); } else { - expect(tail.toString()).toEndWith("0\r\n\r\n"); + expect(res.body).toBe(res.contentLength); } }, 30_000); } + + // A peer that takes no bytes at all is still idle: the request aborts within + // the usual period. + it("still times out a reader that stops", async () => { + const { promise: abortedAt, resolve } = Promise.withResolvers(); + const start = performance.now(); + using server = serveBody("bytes", () => resolve(performance.now() - start)); + let stalled: net.Socket | undefined; + const response = readResponse(server.port, sock => { + stalled = sock; + sock.pause(); + }); + expect(await abortedAt).toBeLessThan(20_000); + // A paused socket never sees the close, so tear it down by hand. + stalled!.destroy(); + expect((await response).body).toBeLessThan(TOTAL); + }, 30_000); }); it.concurrent("allow requestIP after async operation", async () => { From bb25e5ea4a043ea340a419b2303871c73f85ac18 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:23:23 +0000 Subject: [PATCH 03/10] Skip the slow reader tests on Windows --- test/js/bun/http/serve-http2.test.ts | 86 +++++++++++++++------------- test/js/bun/http/serve.test.ts | 7 ++- 2 files changed, 52 insertions(+), 41 deletions(-) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 0419890ff65f..11a8b6b7f774 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { randomBytes } from "crypto"; -import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tls as tlsCert } from "harness"; import http2 from "node:http2"; import net from "node:net"; import { join } from "node:path"; @@ -861,43 +861,49 @@ describe("Bun.serve http2 in-process", () => { // reading slowly. No write and no writable event re-arms the idle timer for // longer than idleTimeout (8 to 12 seconds with the 4 second timer // granularity), yet the peer keeps taking bytes and must not be aborted. - // The pacing is the condition under test, not a wait for one. - test("idleTimeout keeps a slow reader alive", async () => { - const TOTAL = 16 << 20; - let aborted = false; - await using server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - http2: true, - idleTimeout: 10, - fetch(req) { - req.signal.addEventListener("abort", () => (aborted = true)); - return new Response(new Uint8Array(TOTAL)); - }, - }); - const settings = Buffer.alloc(6); - settings.writeUInt16BE(4, 0); // INITIAL_WINDOW_SIZE - settings.writeUInt32BE(64 << 20, 2); - const raw = await RawH2.connect(server.port, false, { settings }); - const inc = Buffer.alloc(4); - inc.writeUInt32BE(64 << 20, 0); - raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); - const start = performance.now(); - raw.socket.on("data", () => { - if (performance.now() - start < 16_000) { - raw.socket.pause(); - setTimeout(() => raw.socket.resume(), 5_000); - } - }); - raw.headers(1, baseHeaders("/")); - const ended = await raw - .waitFor(f => f.type === T.DATA && f.streamId === 1 && (f.flags & F.END_STREAM) !== 0) - .then( - () => true, - () => false, - ); - const received = raw.frames.filter(f => f.type === T.DATA).reduce((n, f) => n + f.payload.length, 0); - expect({ aborted, ended, received }).toEqual({ aborted: false, ended: true, received: TOTAL }); - raw.close(); - }, 30_000); + // The pacing is the condition under test, not a wait for one. Skipped on + // Windows, where the buffered remainder gets no writable event while the + // peer reads slowly (a separate Windows issue). + test.skipIf(isWindows)( + "idleTimeout keeps a slow reader alive", + async () => { + const TOTAL = 16 << 20; + let aborted = false; + await using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + http2: true, + idleTimeout: 10, + fetch(req) { + req.signal.addEventListener("abort", () => (aborted = true)); + return new Response(new Uint8Array(TOTAL)); + }, + }); + const settings = Buffer.alloc(6); + settings.writeUInt16BE(4, 0); // INITIAL_WINDOW_SIZE + settings.writeUInt32BE(64 << 20, 2); + const raw = await RawH2.connect(server.port, false, { settings }); + const inc = Buffer.alloc(4); + inc.writeUInt32BE(64 << 20, 0); + raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); + const start = performance.now(); + raw.socket.on("data", () => { + if (performance.now() - start < 16_000) { + raw.socket.pause(); + setTimeout(() => raw.socket.resume(), 5_000); + } + }); + raw.headers(1, baseHeaders("/")); + const ended = await raw + .waitFor(f => f.type === T.DATA && f.streamId === 1 && (f.flags & F.END_STREAM) !== 0) + .then( + () => true, + () => false, + ); + const received = raw.frames.filter(f => f.type === T.DATA).reduce((n, f) => n + f.payload.length, 0); + expect({ aborted, ended, received }).toEqual({ aborted: false, ended: true, received: TOTAL }); + raw.close(); + }, + 30_000, + ); }); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 7e8a3ce80098..e8e58267bc0c 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -13,6 +13,7 @@ import { isIPv4, isIPv6, isPosix, + isWindows, runFixtureMaxRSS, tempDir, tls, @@ -3130,7 +3131,11 @@ it.concurrent( // the condition under test, not a wait for one. The timer has a 4 second // granularity, so the idle period is 8 to 12 seconds and the client reads at // least once in every period. -describe.concurrent("idleTimeout and a slow reader", () => { +// +// Skipped on Windows: a remainder held in the uWS buffer gets no writable +// event there while the peer reads slowly, and a peer that stops reading is +// never timed out. Both are separate Windows issues. +describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { const TOTAL = 16 << 20; const PACED_MS = 16_000; const PAUSE_MS = 5_000; From 98535bad054d39dc5b815c1d757caa93b0f86634 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:26:44 +0000 Subject: [PATCH 04/10] Measure send progress with a cumulative delivered-bytes counter A producer that refills the kernel send buffer as fast as the peer drains it holds the unsent-queue depth constant. A depth delta then reads as no progress even while the peer reads steadily, so the idle timer still aborted an HTTP/2 slow reader. Read a monotonic delivered-bytes counter instead (Linux TCP_INFO tcpi_bytes_acked, macOS tcpi_txbytes, Windows SIO_TCP_INFO BytesOut). Its growth is real throughput, so onTimeout re-arms a reader that took at least the minimum rate and closes one that took nothing. --- packages/bun-usockets/src/bsd.c | 54 +++++++++++-------- .../src/internal/networking/bsd.h | 7 +-- packages/bun-usockets/src/libusockets.h | 12 ++--- packages/bun-usockets/src/socket.c | 10 ++-- src/uws_sys/libuwsockets.cpp | 6 +-- 5 files changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index bed72e38df16..5fb00177b654 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -56,10 +56,6 @@ static void init_debug_logging() { #include #include #include -#include -#ifdef __linux__ -#include -#endif #else /* _WIN32 */ #include #endif @@ -685,9 +681,12 @@ int bsd_socket_buffer_size(LIBUS_SOCKET_DESCRIPTOR fd, int is_recv, int size, in } int bsd_socket_send_progress_mark(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *mark) { + /* A monotonic count of bytes this socket has delivered toward the peer. + * A queue-depth reading (SIOCOUTQ) is wrong here: a producer that refills + * the send buffer as fast as the peer drains it holds the depth constant, + * so the delta reads as no progress even while the peer reads steadily. + * The cumulative counter always grows with real throughput. */ #if defined(_WIN32) - /* Winsock has no unsent-queue query; the cumulative sent-bytes counter - * moves whenever the kernel delivers more to the peer. */ TCP_INFO_v0 info; DWORD version = 0, bytes = 0; memset(&info, 0, sizeof(info)); @@ -697,29 +696,38 @@ int bsd_socket_send_progress_mark(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *mark) { *mark = info.BytesOut; return 0; #elif defined(__APPLE__) - int queued = 0; - socklen_t len = sizeof(queued); - if (getsockopt(fd, SOL_SOCKET, SO_NWRITE, &queued, &len) != 0) { + struct tcp_connection_info info; + socklen_t len = sizeof(info); + memset(&info, 0, sizeof(info)); + if (getsockopt(fd, IPPROTO_TCP, TCP_CONNECTION_INFO, &info, &len) != 0) { return -1; } - *mark = (uint64_t) queued; + *mark = info.tcpi_txbytes; return 0; -#elif defined(SIOCOUTQ) - int queued = 0; - /* TCP: bytes not yet sent. Bytes in flight at the time of a mark are - * acked soon after, and counting them would read as progress once on a - * peer that then stalls. Unix sockets only answer SIOCOUTQ (bytes the - * peer has not read). */ -#ifdef SIOCOUTQNSD - if (ioctl(fd, SIOCOUTQNSD, &queued) == 0) { - *mark = (uint64_t) queued; - return 0; +#elif defined(__linux__) + /* The kernel's struct tcp_info is append-only (uapi/linux/tcp.h). Some libc + * headers predate tcpi_bytes_acked, so match the ABI prefix up to it by + * hand rather than depend on the header: 8 bytes of __u8 fields, then 24 + * __u32, then the __u64 block whose third member is tcpi_bytes_acked + * (RFC4898 tcpEStatsAppHCThruOctetsAcked, Linux 4.6+). */ + struct { + uint8_t u8[8]; + uint32_t u32[24]; + uint64_t pacing_rate; + uint64_t max_pacing_rate; + uint64_t bytes_acked; + } info; + socklen_t len = sizeof(info); + memset(&info, 0, sizeof(info)); + if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &info, &len) != 0) { + return -1; } -#endif - if (ioctl(fd, SIOCOUTQ, &queued) != 0) { + /* An older kernel returns a shorter struct and leaves bytes_acked at 0, + * which reads as no progress (the old close-on-timeout behaviour). */ + if (len < sizeof(info)) { return -1; } - *mark = (uint64_t) queued; + *mark = info.bytes_acked; return 0; #else (void) fd; diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index fcb38d136abc..6e0997f2a17c 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -190,9 +190,10 @@ int bsd_socket_get_tos(LIBUS_SOCKET_DESCRIPTOR fd); * resulting value to *out; on failure returns the setsockopt/getsockopt result * with the error left in errno (WSAGetLastError on Windows). */ int bsd_socket_buffer_size(LIBUS_SOCKET_DESCRIPTOR fd, int is_recv, int size, int *out); -/* See us_socket_send_progress_mark. Linux: SIOCOUTQ, macOS: SO_NWRITE (both the - * unsent queue), Windows: SIO_TCP_INFO BytesOut (cumulative). Returns 0 and - * writes the mark, or -1 when the platform or socket kind cannot answer. */ +/* See us_socket_send_progress_mark. A monotonic delivered-bytes counter: Linux + * TCP_INFO tcpi_bytes_acked, macOS tcpi_txbytes, Windows SIO_TCP_INFO BytesOut. + * Returns 0 and writes the mark, or -1 when the platform or socket kind cannot + * answer. */ int bsd_socket_send_progress_mark(LIBUS_SOCKET_DESCRIPTOR fd, uint64_t *mark); int bsd_prepare_adopted_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd); int bsd_set_reuseaddr(LIBUS_SOCKET_DESCRIPTOR fd); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index f290eb97dd7b..689ec8e2f409 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -727,12 +727,12 @@ int us_socket_get_error(us_socket_r s); * making a stall there routine backpressure, so it asks the kernel * (SO_ERROR, then a zero-byte send probe). */ int us_socket_stalled_write_means_peer_gone(us_socket_r s); -/* A mark of how far the kernel has delivered this socket's outgoing bytes to - * the peer. Linux and macOS read the unsent queue, Windows the cumulative - * sent-bytes counter. Returns 0 and writes the mark, or -1 where the platform - * or socket kind cannot answer. Take it when an idle timer is armed with - * outgoing bytes pending, so that the timeout can tell a slow reader from a - * stalled one with us_socket_send_progressed. */ +/* A monotonic count of bytes this socket has delivered toward the peer (Linux + * TCP_INFO tcpi_bytes_acked, macOS tcpi_txbytes, Windows SIO_TCP_INFO BytesOut). + * Returns 0 and writes the mark, or -1 where the platform or socket kind cannot + * answer. Take it when an idle timer is armed with outgoing bytes pending, so + * that the timeout can tell a slow reader from a stalled one with + * us_socket_send_progressed. */ int us_socket_send_progress_mark(us_socket_r s, uint64_t *mark); /* Did the kernel move at least min_bytes toward the peer since *mark was * taken, with no write in between? Advances *mark when it did. */ diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index fe3d3ac44de9..2eb59addc0ef 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -434,14 +434,10 @@ int us_socket_send_progressed(struct us_socket_t *s, uint64_t *mark, uint64_t mi if (us_socket_send_progress_mark(s, &now) != 0) { return 0; } -#ifdef _WIN32 - /* Cumulative sent bytes: the mark grows. */ + /* The mark is a monotonic delivered-bytes counter (see + * bsd_socket_send_progress_mark), so progress is how much it grew. */ uint64_t delivered = now > *mark ? now - *mark : 0; -#else - /* Unsent queue: the mark shrinks. */ - uint64_t delivered = *mark > now ? *mark - now : 0; -#endif - if (delivered == 0 || delivered < min_bytes) { + if (delivered < min_bytes) { return 0; } *mark = now; diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index cbf850c6facb..3112123c5c47 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -1513,8 +1513,7 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, void uws_res_uncork(int ssl, uws_res_r res) { - /* A short write here leaves bytes pending on a timer that was armed - * before them; re-arm so the timer knows (HttpResponse::resetTimeout). */ + /* resetTimeout on a short write records the pending bytes for onTimeout. */ if (ssl) { uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; @@ -1541,8 +1540,7 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, * half-open socket. */ us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_WRITABLE | ((s->flags.is_paused || s->read_eof) ? 0 : LIBUS_SOCKET_READABLE)); - /* The sendfile that ran before this left bytes pending; re-arm the idle - * timer so it records that (HttpResponse::resetTimeout). */ + /* resetTimeout records the sendfile remainder for onTimeout. */ ((uWS::HttpResponse *)res)->resetTimeout(); } From 63b92f675ab6144093916d658aa290bbfab3a9ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:34:48 +0000 Subject: [PATCH 05/10] Make the slow reader tests read at a steady rate, not in long pauses A long pause plus scheduler jitter under ASAN made a live reader look idle for a whole period on slow CI machines. Read at a fixed low rate instead (below the rate that frees a writable event, above the receive floor), run past one idle period, and assert the request is still open and bytes kept arriving rather than waiting for the whole body. The negative test asserts a reader that stops is still aborted, with a generous deadline. --- test/js/bun/http/serve-http2.test.ts | 46 +++++----- test/js/bun/http/serve.test.ts | 123 ++++++++++++--------------- 2 files changed, 80 insertions(+), 89 deletions(-) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 11a8b6b7f774..bcb0739fc517 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -856,24 +856,26 @@ describe("Bun.serve http2 in-process", () => { await new Promise(r => session.close(() => r())); }); - // The peer opens a window far larger than the kernel send buffer, so the - // TCP send buffer is what throttles the response, like an HTTP/1.1 client - // reading slowly. No write and no writable event re-arms the idle timer for - // longer than idleTimeout (8 to 12 seconds with the 4 second timer - // granularity), yet the peer keeps taking bytes and must not be aborted. - // The pacing is the condition under test, not a wait for one. Skipped on - // Windows, where the buffered remainder gets no writable event while the - // peer reads slowly (a separate Windows issue). + // The peer opens a window far larger than the kernel send buffer, so the TCP + // send buffer throttles the response, like an HTTP/1.1 client reading slowly. + // No write and no writable event re-arms the idle timer while the buffer + // stays full, yet the peer keeps taking bytes and must not be aborted. The + // client reads at a fixed low rate for longer than one idle period; the body + // is larger than the window so it never completes here, surviving the window + // is the point. Skipped on Windows (the buffered remainder gets no writable + // event there while the peer reads slowly, a separate Windows issue). test.skipIf(isWindows)( "idleTimeout keeps a slow reader alive", async () => { - const TOTAL = 16 << 20; + const TOTAL = 64 << 20; + const IDLE_S = 8; + const RATE = 128 * 1024; // bytes/sec, below the writable-event rate, above the 16 KB/s floor let aborted = false; await using server = Bun.serve({ hostname: "127.0.0.1", port: 0, http2: true, - idleTimeout: 10, + idleTimeout: IDLE_S, fetch(req) { req.signal.addEventListener("abort", () => (aborted = true)); return new Response(new Uint8Array(TOTAL)); @@ -886,22 +888,24 @@ describe("Bun.serve http2 in-process", () => { const inc = Buffer.alloc(4); inc.writeUInt32BE(64 << 20, 0); raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); + const RUN_MS = IDLE_S * 1000 + 8_000; const start = performance.now(); - raw.socket.on("data", () => { - if (performance.now() - start < 16_000) { + let received = 0; + raw.socket.on("data", (d: Buffer) => { + received += d.length; + if (performance.now() - start < RUN_MS) { raw.socket.pause(); - setTimeout(() => raw.socket.resume(), 5_000); + setTimeout(() => raw.socket.resume(), Math.max(1, Math.round((d.length / RATE) * 1000))); } }); raw.headers(1, baseHeaders("/")); - const ended = await raw - .waitFor(f => f.type === T.DATA && f.streamId === 1 && (f.flags & F.END_STREAM) !== 0) - .then( - () => true, - () => false, - ); - const received = raw.frames.filter(f => f.type === T.DATA).reduce((n, f) => n + f.payload.length, 0); - expect({ aborted, ended, received }).toEqual({ aborted: false, ended: true, received: TOTAL }); + const { promise, resolve } = Promise.withResolvers(); + raw.socket.on("close", () => resolve()); // the server aborting fires this early + const deadline = setTimeout(() => resolve(), RUN_MS + 2_000); + await promise; + clearTimeout(deadline); + const gotData = raw.frames.some(f => f.type === T.DATA && f.streamId === 1); + expect({ aborted, gotData }).toEqual({ aborted: false, gotData: true }); raw.close(); }, 30_000, diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e8e58267bc0c..e15d047e031e 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3124,32 +3124,35 @@ it.concurrent( 20_000, ); -// A client that reads steadily but slower than the kernel send buffer drains -// gets no writable event on the server for longer than idleTimeout, so the -// server used to abort the request and close the socket mid-body. The client -// here reads one chunk, pauses, and resumes after a fixed delay: the pacing is -// the condition under test, not a wait for one. The timer has a 4 second -// granularity, so the idle period is 8 to 12 seconds and the client reads at -// least once in every period. +// A client that reads continuously but slower than the kernel send buffer +// drains gets no writable event on the server for longer than idleTimeout, so +// the server used to abort the request and close the socket mid-body. The +// client here reads at a fixed low rate (a short pause after each chunk, sized +// to the bytes read), well below the rate that would free a writable event but +// well above the 16 KB/s the receive side already requires. With the fix the +// request stays open and keeps receiving data; without it the server aborts +// within idleTimeout. // // Skipped on Windows: a remainder held in the uWS buffer gets no writable // event there while the peer reads slowly, and a peer that stops reading is // never timed out. Both are separate Windows issues. describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { - const TOTAL = 16 << 20; - const PACED_MS = 16_000; - const PAUSE_MS = 5_000; + const TOTAL = 64 << 20; + const IDLE_S = 8; + // Read this many bytes per second: below the rate that frees a writable + // event (so the old code times out), above the 16 KB/s receive floor. + const RATE = 128 * 1024; function serveBody(kind: "bytes" | "stream" | "file", onAbort: () => void) { return Bun.serve({ hostname: "127.0.0.1", port: 0, - idleTimeout: 10, + idleTimeout: IDLE_S, fetch(req) { req.signal.addEventListener("abort", onAbort); if (kind === "bytes") return new Response(new Uint8Array(TOTAL)); if (kind === "file") return new Response(Bun.file(bigFile)); - const chunk = new Uint8Array(16 * 1024); + const chunk = new Uint8Array(64 * 1024); let sent = 0; return new Response( new ReadableStream({ @@ -3164,79 +3167,63 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { }); } - // Sends a GET and reads the response at `pace` until the socket closes. - // Resolves with the body byte count, the Content-Length (-1 when chunked) - // and the last 8 bytes on the wire. - function readResponse(port: number, pace: (sock: net.Socket) => void) { - const { promise, resolve } = Promise.withResolvers<{ body: number; contentLength: number; tail: string }>(); - let head = Buffer.alloc(0); - let headerDone = false; - let contentLength = -1; - let body = 0; - let tail = Buffer.alloc(0); - const sock = net.connect(port, "127.0.0.1", () => { - sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); - }); - sock.on("data", (d: Buffer) => { - if (!headerDone) { - head = Buffer.concat([head, d]); - const i = head.indexOf("\r\n\r\n"); - if (i < 0) return; - headerDone = true; - const m = /content-length: (\d+)/i.exec(head.subarray(0, i).toString()); - if (m) contentLength = Number(m[1]); - d = head.subarray(i + 4); - } - body += d.length; - tail = Buffer.concat([tail, d]).subarray(-8); - pace(sock); - }); - const done = () => resolve({ body, contentLength, tail: tail.toString() }); - sock.on("error", done); - sock.on("close", done); - return promise; - } - const dir = tempDir("serve-slow-reader", { "big.bin": Buffer.alloc(TOTAL) }); const bigFile = join(String(dir), "big.bin"); afterAll(() => dir[Symbol.dispose]()); for (const kind of ["bytes", "stream", "file"] as const) { + // Read at RATE for longer than one idle period, then assert the request is + // still alive and bytes kept arriving. The body is far larger than the + // window, so it never completes here: surviving the window is the point. it(`keeps a slow ${kind} reader alive`, async () => { let aborted = false; using server = serveBody(kind, () => (aborted = true)); + const RUN_MS = IDLE_S * 1000 + 8_000; + const { promise, resolve } = Promise.withResolvers(); + let received = 0; const start = performance.now(); - const res = await readResponse(server.port, sock => { - if (performance.now() - start < PACED_MS) { + const sock = net.connect(server.port, "127.0.0.1", () => { + sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + }); + sock.on("data", (d: Buffer) => { + received += d.length; + if (performance.now() - start < RUN_MS) { sock.pause(); - setTimeout(() => sock.resume(), PAUSE_MS); + setTimeout(() => sock.resume(), Math.max(1, Math.round((d.length / RATE) * 1000))); } }); - expect(aborted).toBe(false); - if (kind === "stream") { - expect(res.tail).toEndWith("0\r\n\r\n"); - } else { - expect(res.body).toBe(res.contentLength); - } + sock.on("error", () => resolve()); + sock.on("close", () => resolve()); // the server aborting fires this early + const deadline = setTimeout(() => resolve(), RUN_MS + 2_000); + await promise; + clearTimeout(deadline); + expect({ aborted, receivedSomething: received > 0 }).toEqual({ aborted: false, receivedSomething: true }); + sock.destroy(); }, 30_000); } - // A peer that takes no bytes at all is still idle: the request aborts within - // the usual period. + // A peer that reads nothing is idle, not slow: the request must still abort. + // One idle period can be re-armed by the bytes already in flight when the + // peer stopped, so the abort lands within about two periods, not one. it("still times out a reader that stops", async () => { - const { promise: abortedAt, resolve } = Promise.withResolvers(); - const start = performance.now(); - using server = serveBody("bytes", () => resolve(performance.now() - start)); - let stalled: net.Socket | undefined; - const response = readResponse(server.port, sock => { - stalled = sock; - sock.pause(); + let aborted = false; + using server = serveBody("bytes", () => (aborted = true)); + const { promise, resolve } = Promise.withResolvers(); + const sock = net.connect(server.port, "127.0.0.1", () => { + sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); }); - expect(await abortedAt).toBeLessThan(20_000); - // A paused socket never sees the close, so tear it down by hand. - stalled!.destroy(); - expect((await response).body).toBeLessThan(TOTAL); - }, 30_000); + // Read one chunk to open the response, then never read again. + sock.once("data", () => sock.pause()); + sock.on("error", () => resolve()); + sock.on("close", () => resolve()); + const poll = setInterval(() => aborted && resolve(), 200); + const deadline = setTimeout(() => resolve(), 40_000); + await promise; + clearInterval(poll); + clearTimeout(deadline); + expect(aborted).toBe(true); + sock.destroy(); + }, 45_000); }); it.concurrent("allow requestIP after async operation", async () => { From 000634e4a4378bf6d07d57a7651d609f9435681c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:19:40 +0000 Subject: [PATCH 06/10] Pin the slow reader's SO_RCVBUF so the peer window reopens on every read With receive buffer autotuning a kernel can grow the client's buffer past 8 MB, and then one read frees less than a sixteenth of it and the peer window stays closed. The server then sees no acked bytes for a whole period on that host and aborts a reader that is in fact draining. Pin the buffer through setsockopt so every read reopens the window, and read at 64 KB/s so the old code still runs out of writable events within idleTimeout. --- test/js/bun/http/serve-http2.test.ts | 28 ++++++++++++++++++++++------ test/js/bun/http/serve.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index bcb0739fc517..13bfcc728964 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { randomBytes } from "crypto"; -import { bunEnv, bunExe, isWindows, tempDir, tls as tlsCert } from "harness"; +import { dlopen, FFIType, ptr } from "bun:ffi"; +import { bunEnv, bunExe, isWindows, libcPathForDlopen, tempDir, tls as tlsCert } from "harness"; import http2 from "node:http2"; import net from "node:net"; import { join } from "node:path"; @@ -862,14 +863,17 @@ describe("Bun.serve http2 in-process", () => { // stays full, yet the peer keeps taking bytes and must not be aborted. The // client reads at a fixed low rate for longer than one idle period; the body // is larger than the window so it never completes here, surviving the window - // is the point. Skipped on Windows (the buffered remainder gets no writable - // event there while the peer reads slowly, a separate Windows issue). + // is the point. The client pins its SO_RCVBUF so the kernel does not autotune + // it large (then one read would not reopen the peer window and the server + // would see no acked bytes for a whole period on some hosts). Skipped on + // Windows (the buffered remainder gets no writable event there while the + // peer reads slowly, a separate Windows issue). test.skipIf(isWindows)( "idleTimeout keeps a slow reader alive", async () => { const TOTAL = 64 << 20; const IDLE_S = 8; - const RATE = 128 * 1024; // bytes/sec, below the writable-event rate, above the 16 KB/s floor + const RATE = 64 * 1024; // bytes/sec, below the writable-event rate, above the 16 KB/s floor let aborted = false; await using server = Bun.serve({ hostname: "127.0.0.1", @@ -885,14 +889,26 @@ describe("Bun.serve http2 in-process", () => { settings.writeUInt16BE(4, 0); // INITIAL_WINDOW_SIZE settings.writeUInt32BE(64 << 20, 2); const raw = await RawH2.connect(server.port, false, { settings }); + { + const libc = dlopen(libcPathForDlopen(), { + setsockopt: { + args: [FFIType.int, FFIType.int, FFIType.int, FFIType.ptr, FFIType.u32], + returns: FFIType.int, + }, + }); + const SOL_SOCKET = process.platform === "darwin" ? 0xffff : 1; + const SO_RCVBUF = process.platform === "darwin" ? 0x1002 : 8; + const value = new Int32Array([128 * 1024]); + const rc = libc.symbols.setsockopt((raw.socket as any)._handle.fd, SOL_SOCKET, SO_RCVBUF, ptr(value), 4); + libc.close(); + if (rc !== 0) throw new Error("setsockopt(SO_RCVBUF) failed"); + } const inc = Buffer.alloc(4); inc.writeUInt32BE(64 << 20, 0); raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); const RUN_MS = IDLE_S * 1000 + 8_000; const start = performance.now(); - let received = 0; raw.socket.on("data", (d: Buffer) => { - received += d.length; if (performance.now() - start < RUN_MS) { raw.socket.pause(); setTimeout(() => raw.socket.resume(), Math.max(1, Math.round((d.length / RATE) * 1000))); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e15d047e031e..3244211bba81 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -14,6 +14,7 @@ import { isIPv6, isPosix, isWindows, + libcPathForDlopen, runFixtureMaxRSS, tempDir, tls, @@ -23,6 +24,7 @@ import { connect } from "net"; import { join, resolve } from "path"; // import { renderToReadableStream } from "react-dom/server"; // import app_jsx from "./app.jsx"; +import { dlopen, FFIType, ptr } from "bun:ffi"; import { heapStats } from "bun:jsc"; import { spawn } from "child_process"; import net from "node:net"; @@ -3133,6 +3135,12 @@ it.concurrent( // request stays open and keeps receiving data; without it the server aborts // within idleTimeout. // +// The client pins its SO_RCVBUF. That turns off the kernel's receive buffer +// autotuning, so a read always frees more than a sixteenth of the buffer and +// the peer window reopens on every read. With autotuning the buffer can grow +// past 8 MB on some kernels and then a single read opens nothing, which makes +// the server's view of the peer (bytes acked per period) depend on the host. +// // Skipped on Windows: a remainder held in the uWS buffer gets no writable // event there while the peer reads slowly, and a peer that stops reading is // never timed out. Both are separate Windows issues. @@ -3141,7 +3149,23 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { const IDLE_S = 8; // Read this many bytes per second: below the rate that frees a writable // event (so the old code times out), above the 16 KB/s receive floor. - const RATE = 128 * 1024; + const RATE = 64 * 1024; + const RCVBUF = 128 * 1024; + + function pinRecvBuffer(sock: net.Socket) { + const libc = dlopen(libcPathForDlopen(), { + setsockopt: { + args: [FFIType.int, FFIType.int, FFIType.int, FFIType.ptr, FFIType.u32], + returns: FFIType.int, + }, + }); + const SOL_SOCKET = process.platform === "darwin" ? 0xffff : 1; + const SO_RCVBUF = process.platform === "darwin" ? 0x1002 : 8; + const value = new Int32Array([RCVBUF]); + const rc = libc.symbols.setsockopt((sock as any)._handle.fd, SOL_SOCKET, SO_RCVBUF, ptr(value), 4); + libc.close(); + if (rc !== 0) throw new Error("setsockopt(SO_RCVBUF) failed"); + } function serveBody(kind: "bytes" | "stream" | "file", onAbort: () => void) { return Bun.serve({ @@ -3183,6 +3207,7 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { let received = 0; const start = performance.now(); const sock = net.connect(server.port, "127.0.0.1", () => { + pinRecvBuffer(sock); sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); }); sock.on("data", (d: Buffer) => { @@ -3210,6 +3235,7 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { using server = serveBody("bytes", () => (aborted = true)); const { promise, resolve } = Promise.withResolvers(); const sock = net.connect(server.port, "127.0.0.1", () => { + pinRecvBuffer(sock); sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); }); // Read one chunk to open the response, then never read again. From 4303d934d7f90b52e68d0ead1d096b66a46e4564 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:22:14 +0000 Subject: [PATCH 07/10] [autofix.ci] apply automated fixes --- test/js/bun/http/serve-http2.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 13bfcc728964..f55ab42bb910 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -1,27 +1,27 @@ +import { dlopen, FFIType, ptr } from "bun:ffi"; import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { randomBytes } from "crypto"; -import { dlopen, FFIType, ptr } from "bun:ffi"; import { bunEnv, bunExe, isWindows, libcPathForDlopen, tempDir, tls as tlsCert } from "harness"; import http2 from "node:http2"; import net from "node:net"; import { join } from "node:path"; import tls from "node:tls"; import { - F, - Fixture, - PREFACE, - RawH2, - SharedSession, - T, baseHeaders, connectH2, decodeStatus, + F, + Fixture, frame, hpackFields, hpackLiteral, + PREFACE, + RawH2, request, sha256, + SharedSession, startFixture, + T, } from "./serve-http2-helpers"; /** One HTTP/1.1 request on the fixture's port, written byte-exact (no From e698b60b7f9d13a0f3415df5cd253f575b0b2ed2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:48:52 +0000 Subject: [PATCH 08/10] Create the slow reader fixture in beforeAll so a skipped suite does not allocate it --- test/js/bun/http/serve.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 3244211bba81..386fa8b811ae 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -1,5 +1,5 @@ import { file, gc, Serve, serve, Server } from "bun"; -import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; import { readFileSync, writeFileSync } from "fs"; import { bunEnv, @@ -3191,9 +3191,13 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { }); } - const dir = tempDir("serve-slow-reader", { "big.bin": Buffer.alloc(TOTAL) }); - const bigFile = join(String(dir), "big.bin"); - afterAll(() => dir[Symbol.dispose]()); + let dir: ReturnType | undefined; + let bigFile = ""; + beforeAll(() => { + dir = tempDir("serve-slow-reader", { "big.bin": Buffer.alloc(TOTAL) }); + bigFile = join(String(dir), "big.bin"); + }); + afterAll(() => dir?.[Symbol.dispose]()); for (const kind of ["bytes", "stream", "file"] as const) { // Read at RATE for longer than one idle period, then assert the request is From c923dbb8eefc27b7a07290ec8ec606105e35cf4c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:23:46 +0000 Subject: [PATCH 09/10] Pace the slow reader against a schedule and pin a 64 KB receive buffer Pausing len/RATE after every data event let one large event (Linux doubles SO_RCVBUF, so up to 256 KB with a 128 KB pin) cost a 4 s pause, equal to the shortest idle window of an 8 s timeout, and a late timer lowered the rate. Pace against bytes-so-far / RATE instead, so a late timer shortens the next pause, and pin 64 KB so one event is at most a 2 s pause. --- test/js/bun/http/serve-http2.test.ts | 14 +++++++--- test/js/bun/http/serve.test.ts | 38 ++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index f55ab42bb910..2a8a5765a64f 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -898,7 +898,7 @@ describe("Bun.serve http2 in-process", () => { }); const SOL_SOCKET = process.platform === "darwin" ? 0xffff : 1; const SO_RCVBUF = process.platform === "darwin" ? 0x1002 : 8; - const value = new Int32Array([128 * 1024]); + const value = new Int32Array([64 * 1024]); // one data event is then at most a ~2 s pause at RATE const rc = libc.symbols.setsockopt((raw.socket as any)._handle.fd, SOL_SOCKET, SO_RCVBUF, ptr(value), 4); libc.close(); if (rc !== 0) throw new Error("setsockopt(SO_RCVBUF) failed"); @@ -907,11 +907,19 @@ describe("Bun.serve http2 in-process", () => { inc.writeUInt32BE(64 << 20, 0); raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); const RUN_MS = IDLE_S * 1000 + 8_000; + // Pace against a schedule (bytes so far / RATE) so a late timer shortens + // the next pause instead of lowering the rate; one pause is still at + // most one chunk / RATE. const start = performance.now(); + let received = 0; raw.socket.on("data", (d: Buffer) => { - if (performance.now() - start < RUN_MS) { + received += d.length; + const elapsed = performance.now() - start; + if (elapsed >= RUN_MS) return; + const lag = (received / RATE) * 1000 - elapsed; + if (lag > 1) { raw.socket.pause(); - setTimeout(() => raw.socket.resume(), Math.max(1, Math.round((d.length / RATE) * 1000))); + setTimeout(() => raw.socket.resume(), lag); } }); raw.headers(1, baseHeaders("/")); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 386fa8b811ae..e29294d28fb7 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3146,11 +3146,15 @@ it.concurrent( // never timed out. Both are separate Windows issues. describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { const TOTAL = 64 << 20; + // uSockets keeps timeouts in 4 s ticks ((seconds + 3) >> 2): 8 s is 2 ticks + // and fires after 4 to 8 s without a re-arm. const IDLE_S = 8; // Read this many bytes per second: below the rate that frees a writable // event (so the old code times out), above the 16 KB/s receive floor. const RATE = 64 * 1024; - const RCVBUF = 128 * 1024; + // Small enough that one data event (Linux doubles SO_RCVBUF, so up to about + // 128 KB) is at most a 2 s pause at RATE, half the 4 s minimum idle window. + const RCVBUF = 64 * 1024; function pinRecvBuffer(sock: net.Socket) { const libc = dlopen(libcPathForDlopen(), { @@ -3167,6 +3171,26 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { if (rc !== 0) throw new Error("setsockopt(SO_RCVBUF) failed"); } + // Pace reads against a schedule (bytes so far / RATE) rather than a fixed + // pause per chunk: a timer that fires late shortens the next pause instead + // of lowering the rate, so the acked bytes per idle period stay near RATE on + // a loaded host. One pause is still at most one chunk / RATE. + function paceReads(sock: net.Socket, untilMs: number) { + const start = performance.now(); + let received = 0; + sock.on("data", (d: Buffer) => { + received += d.length; + const elapsed = performance.now() - start; + if (elapsed >= untilMs) return; + const lag = (received / RATE) * 1000 - elapsed; + if (lag > 1) { + sock.pause(); + setTimeout(() => sock.resume(), lag); + } + }); + return () => received; + } + function serveBody(kind: "bytes" | "stream" | "file", onAbort: () => void) { return Bun.serve({ hostname: "127.0.0.1", @@ -3208,25 +3232,17 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { using server = serveBody(kind, () => (aborted = true)); const RUN_MS = IDLE_S * 1000 + 8_000; const { promise, resolve } = Promise.withResolvers(); - let received = 0; - const start = performance.now(); const sock = net.connect(server.port, "127.0.0.1", () => { pinRecvBuffer(sock); sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); }); - sock.on("data", (d: Buffer) => { - received += d.length; - if (performance.now() - start < RUN_MS) { - sock.pause(); - setTimeout(() => sock.resume(), Math.max(1, Math.round((d.length / RATE) * 1000))); - } - }); + const received = paceReads(sock, RUN_MS); sock.on("error", () => resolve()); sock.on("close", () => resolve()); // the server aborting fires this early const deadline = setTimeout(() => resolve(), RUN_MS + 2_000); await promise; clearTimeout(deadline); - expect({ aborted, receivedSomething: received > 0 }).toEqual({ aborted: false, receivedSomething: true }); + expect({ aborted, receivedSomething: received() > 0 }).toEqual({ aborted: false, receivedSomething: true }); sock.destroy(); }, 30_000); } From 8fc1424cc534475544bda3cb6076b704490df5c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:26:03 +0000 Subject: [PATCH 10/10] Slow reader tests: idleTimeout 10, capped pauses, 16 MiB body On macOS the loopback stack serves a slow reader in persist-timer bursts about 4 s apart and hands the client chunks of up to 512 KB, so the server can observe nothing for several seconds even while the client reads steadily. uSockets rounds idleTimeout 8 to a 4 to 8 s window, which can expire between two such bursts; 10 (the default the report is about) gives 8 to 12 s. Cap any single read pause at 4 s so an oversized chunk cannot open a long gap, halve the rate to 32 KB/s so the unfixed server still misses its writable event on Linux, and shrink the body to 16 MiB, which is still far more than the paced window plus kernel buffers absorb. --- test/js/bun/http/serve-http2.test.ts | 24 +++++++-------- test/js/bun/http/serve.test.ts | 44 ++++++++++++++-------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/test/js/bun/http/serve-http2.test.ts b/test/js/bun/http/serve-http2.test.ts index 2a8a5765a64f..74379469ef4a 100644 --- a/test/js/bun/http/serve-http2.test.ts +++ b/test/js/bun/http/serve-http2.test.ts @@ -863,17 +863,17 @@ describe("Bun.serve http2 in-process", () => { // stays full, yet the peer keeps taking bytes and must not be aborted. The // client reads at a fixed low rate for longer than one idle period; the body // is larger than the window so it never completes here, surviving the window - // is the point. The client pins its SO_RCVBUF so the kernel does not autotune - // it large (then one read would not reopen the peer window and the server - // would see no acked bytes for a whole period on some hosts). Skipped on - // Windows (the buffered remainder gets no writable event there while the - // peer reads slowly, a separate Windows issue). + // is the point. Timing constraints (idleTimeout 10, pinned SO_RCVBUF, capped + // pauses) are the same as the HTTP/1.1 twin in serve.test.ts, which explains + // them. Skipped on Windows (the buffered remainder gets no writable event + // there while the peer reads slowly, a separate Windows issue). test.skipIf(isWindows)( "idleTimeout keeps a slow reader alive", async () => { - const TOTAL = 64 << 20; - const IDLE_S = 8; - const RATE = 64 * 1024; // bytes/sec, below the writable-event rate, above the 16 KB/s floor + const TOTAL = 16 << 20; // well above what the paced window plus kernel buffers can absorb + const IDLE_S = 10; + const RATE = 32 * 1024; // bytes/sec, below the Linux writable-event rate, above the 16 KB/s floor + const PAUSE_CAP_MS = 4_000; let aborted = false; await using server = Bun.serve({ hostname: "127.0.0.1", @@ -898,7 +898,7 @@ describe("Bun.serve http2 in-process", () => { }); const SOL_SOCKET = process.platform === "darwin" ? 0xffff : 1; const SO_RCVBUF = process.platform === "darwin" ? 0x1002 : 8; - const value = new Int32Array([64 * 1024]); // one data event is then at most a ~2 s pause at RATE + const value = new Int32Array([64 * 1024]); // bounds the chunk size Linux hands the client const rc = libc.symbols.setsockopt((raw.socket as any)._handle.fd, SOL_SOCKET, SO_RCVBUF, ptr(value), 4); libc.close(); if (rc !== 0) throw new Error("setsockopt(SO_RCVBUF) failed"); @@ -908,15 +908,15 @@ describe("Bun.serve http2 in-process", () => { raw.write(frame(T.WINDOW_UPDATE, 0, 0, inc)); const RUN_MS = IDLE_S * 1000 + 8_000; // Pace against a schedule (bytes so far / RATE) so a late timer shortens - // the next pause instead of lowering the rate; one pause is still at - // most one chunk / RATE. + // the next pause instead of lowering the rate; a pause is capped so an + // oversized chunk cannot open a long gap. const start = performance.now(); let received = 0; raw.socket.on("data", (d: Buffer) => { received += d.length; const elapsed = performance.now() - start; if (elapsed >= RUN_MS) return; - const lag = (received / RATE) * 1000 - elapsed; + const lag = Math.min((received / RATE) * 1000 - elapsed, PAUSE_CAP_MS); if (lag > 1) { raw.socket.pause(); setTimeout(() => raw.socket.resume(), lag); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e29294d28fb7..8dbc45f44c87 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3129,32 +3129,32 @@ it.concurrent( // A client that reads continuously but slower than the kernel send buffer // drains gets no writable event on the server for longer than idleTimeout, so // the server used to abort the request and close the socket mid-body. The -// client here reads at a fixed low rate (a short pause after each chunk, sized -// to the bytes read), well below the rate that would free a writable event but -// well above the 16 KB/s the receive side already requires. With the fix the -// request stays open and keeps receiving data; without it the server aborts -// within idleTimeout. +// client here reads at a fixed low rate, well below the rate that frees a +// writable event on Linux but well above the 16 KB/s the receive side already +// requires. With the fix the request stays open and keeps receiving data; +// without it the server aborts within idleTimeout. // -// The client pins its SO_RCVBUF. That turns off the kernel's receive buffer -// autotuning, so a read always frees more than a sixteenth of the buffer and -// the peer window reopens on every read. With autotuning the buffer can grow -// past 8 MB on some kernels and then a single read opens nothing, which makes -// the server's view of the peer (bytes acked per period) depend on the host. +// What the server can observe is bursty. The kernel reports delivered bytes +// only when the peer's window moves, and the client sees data in chunks (up to +// 512 KB per event on macOS loopback, whose stack also serves a slow reader in +// persist-timer bursts about 4 s apart). So the gap between observable steps +// must stay well inside the shortest idle window: idleTimeout is 10 (the +// default the bug was reported against), which uSockets' 4 s ticks +// ((seconds + 3) >> 2 = 3 ticks) turn into 8 to 12 s; the client pins a small +// SO_RCVBUF so Linux hands it chunks of at most ~128 KB; and no single pause +// exceeds PAUSE_CAP_MS whatever the chunk size. // // Skipped on Windows: a remainder held in the uWS buffer gets no writable // event there while the peer reads slowly, and a peer that stops reading is // never timed out. Both are separate Windows issues. describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { - const TOTAL = 64 << 20; - // uSockets keeps timeouts in 4 s ticks ((seconds + 3) >> 2): 8 s is 2 ticks - // and fires after 4 to 8 s without a re-arm. - const IDLE_S = 8; - // Read this many bytes per second: below the rate that frees a writable - // event (so the old code times out), above the 16 KB/s receive floor. - const RATE = 64 * 1024; - // Small enough that one data event (Linux doubles SO_RCVBUF, so up to about - // 128 KB) is at most a 2 s pause at RATE, half the 4 s minimum idle window. + const TOTAL = 16 << 20; // well above what the paced window plus kernel buffers can absorb + const IDLE_S = 10; + // Bytes per second: below the rate that frees a writable event on Linux (so + // the old code times out), above the 16 KB/s floor. + const RATE = 32 * 1024; const RCVBUF = 64 * 1024; + const PAUSE_CAP_MS = 4_000; function pinRecvBuffer(sock: net.Socket) { const libc = dlopen(libcPathForDlopen(), { @@ -3173,8 +3173,8 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { // Pace reads against a schedule (bytes so far / RATE) rather than a fixed // pause per chunk: a timer that fires late shortens the next pause instead - // of lowering the rate, so the acked bytes per idle period stay near RATE on - // a loaded host. One pause is still at most one chunk / RATE. + // of lowering the rate. A pause is capped, so an oversized chunk raises the + // rate for a moment rather than opening a long gap. function paceReads(sock: net.Socket, untilMs: number) { const start = performance.now(); let received = 0; @@ -3182,7 +3182,7 @@ describe.concurrent.skipIf(isWindows)("idleTimeout and a slow reader", () => { received += d.length; const elapsed = performance.now() - start; if (elapsed >= untilMs) return; - const lag = (received / RATE) * 1000 - elapsed; + const lag = Math.min((received / RATE) * 1000 - elapsed, PAUSE_CAP_MS); if (lag > 1) { sock.pause(); setTimeout(() => sock.resume(), lag);