Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/bun-usockets/src/bsd.c
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,62 @@ 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) {
/* 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)
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__)
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 = info.tcpi_txbytes;
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) {
Comment thread
robobun marked this conversation as resolved.
Resolved
return -1;
}
/* 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 = info.bytes_acked;
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
Expand Down
5 changes: 5 additions & 0 deletions packages/bun-usockets/src/internal/networking/bsd.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ 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. 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);
int bsd_bind_udp_fd(LIBUS_SOCKET_DESCRIPTOR fd, const struct sockaddr *addr, int addrlen, int flags);
Expand Down
13 changes: 13 additions & 0 deletions packages/bun-usockets/src/libusockets.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -724,6 +727,16 @@ 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 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. */
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);
Expand Down
26 changes: 26 additions & 0 deletions packages/bun-usockets/src/socket.c
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,28 @@ 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_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;
}
/* 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;
if (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;
Expand Down Expand Up @@ -710,6 +732,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;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/bun-uws/src/Http2Context.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<false>::HTTP_RECEIVE_THROUGHPUT_BYTES * conn->idleTimeoutS)) {
us_socket_timeout(s, conn->idleTimeoutS);
return s;
}
conn->busy++;
std::vector<Http2Response *> open = conn->streams;
for (Http2Response *stream : open) {
Expand Down Expand Up @@ -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() {
Expand Down
19 changes: 16 additions & 3 deletions packages/bun-uws/src/HttpContext.h
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,17 @@ struct HttpContext {
us_socket_group_t group{};
HttpContextData<SSL> 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`. */
Expand Down Expand Up @@ -945,6 +947,17 @@ struct HttpContext {
// Node.js by default closes the connection but they emit the timeout event before that
HttpResponseData<SSL> *httpResponseData = reinterpret_cast<HttpResponseData<SSL> *>(asyncSocket->getAsyncSocketData());

/* The timer was armed with outgoing bytes pending (see
* 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<SSL>::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) {
httpResponseData->onTimeout((HttpResponse<SSL> *)s, httpResponseData->userData);
}
Expand Down
21 changes: 17 additions & 4 deletions packages/bun-uws/src/HttpResponse.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,28 @@ struct HttpResponse : public AsyncSocket<SSL> {
}

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<SSL>::HTTP_SEND_PROGRESS_MARKED;
} else {
data->state &= ~HttpResponseData<SSL>::HTTP_SEND_PROGRESS_MARKED;
}
}
/* Write an unsigned 32-bit integer in hex */
void writeUnsignedHex(unsigned int value) {
Expand Down
7 changes: 7 additions & 0 deletions packages/bun-uws/src/HttpResponseData.h
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ struct HttpResponseData : AsyncSocketData<SSL>, 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
Expand Down Expand Up @@ -197,6 +201,9 @@ struct HttpResponseData : AsyncSocketData<SSL>, 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;
Expand Down
11 changes: 9 additions & 2 deletions src/uws_sys/libuwsockets.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1513,15 +1513,20 @@ 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)
{
/* resetTimeout on a short write records the pending bytes for onTimeout. */
if (ssl)
{
uWS::HttpResponse<true> *uwsRes = (uWS::HttpResponse<true> *)res;
uwsRes->uncork();
if (uwsRes->uncork().second && !us_socket_is_closed((struct us_socket_t *)res)) {
uwsRes->resetTimeout();
}
}
else
{
uWS::HttpResponse<false> *uwsRes = (uWS::HttpResponse<false> *)res;
uwsRes->uncork();
if (uwsRes->uncork().second && !us_socket_is_closed((struct us_socket_t *)res)) {
uwsRes->resetTimeout();
}
}
}

Expand All @@ -1535,6 +1540,8 @@ 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));
/* resetTimeout records the sendfile remainder for onTimeout. */
((uWS::HttpResponse<false> *)res)->resetTimeout();
}

__attribute__((callback (corker, ctx)))
Expand Down
57 changes: 56 additions & 1 deletion test/js/bun/http/serve-http2.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -855,4 +855,59 @@ describe("Bun.serve http2 in-process", () => {
expect(aborted).toBe(1);
await new Promise<void>(r => session.close(() => r()));
});

// 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 = 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: IDLE_S,
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 RUN_MS = IDLE_S * 1000 + 8_000;
const start = performance.now();
let received = 0;
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
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)));
}
});
raw.headers(1, baseHeaders("/"));
const { promise, resolve } = Promise.withResolvers<void>();
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,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) The HTTP/2 side gets only the positive slow-reader case; the negative contract — a fully-stalled peer is still aborted within one period — is tested for HTTP/1 (serve.test.ts "still times out a reader that stops") but not here, even though Http2Context::onTimeout gained the same extension logic. Fix: add the h2 twin of the stalled-reader test so a regression that lets us_socket_send_progressed keep re-arming a dead h2 connection would be caught.

Extended reasoning...

REVIEW.md: "Cover the variant matrix, not just the repro … the negative contract" and "Fix the whole class in the same PR … HTTP/1 and HTTP/2." Http2Context::onTimeout (Http2Context.h:966-972) now calls us_socket_send_progressed and, when it returns 1, re-arms us_socket_timeout instead of aborting streams. Nothing in the added tests exercises the branch where an h2 peer opens a wide window, receives one burst, then stops reading entirely — the HTTP/1 suite proves that path still aborts, but a future change to the shared us_socket_send_progressed (or to how h2's touch() records the mark) that made a stalled h2 peer read as "progressed" would ship without a failing test. On the base branch there was no extension logic to under-cover; this is new surface added without its negative test.

Verification: nit — The claim is accurate. Http2Context::onTimeout gained the same re-arm-on-progress branch as HTTP/1 (packages/bun-uws/src/Http2Context.h:966-972: if (conn->sendProgressMarked && us_socket_send_progressed(...)) { us_socket_timeout(s, conn->idleTimeoutS); return s; }), so the negative contract — a peer that stops reading entirely must still abort — is now a live regression risk on the h2…

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stalled-peer contract is shared code: both HttpContext::onTimeout and Http2Context::onTimeout call the same us_socket_send_progressed with the same floor, and the HTTP/1 case "still times out a reader that stops" exercises it. An h2 twin would add another 16 s plus case for the same function. Leaving this one open for a maintainer to weigh.

});
Loading