Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions packages/bun-usockets/src/quic.c
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,20 @@ static void us_quic_prepare_ssl_ctx(SSL_CTX *ssl) {
SSL_CTX_set_early_data_enabled(ssl, 0);
}

/* Both engines are only ticked from an event loop (see "process driver"
* above), so every RTT lsquic measures includes whatever the loop ran between
* two ticks. lsquic's default "adaptive" CC (es_cc_algo 3) makes a permanent
* choice from a connection's handshake RTT, BBRv1 above 1.5 ms, which any JS
* work during the handshake exceeds even on loopback. BBRv1 then models loop
* latency as the path: the only bandwidth it can observe is one cwnd per loop
* iteration, so cwnd never grows in startup and, once startup ends, shrinks to
* the 4-packet minimum; a busy server then moves ~7 KB per loop iteration.
* Cubic only reacts to loss, which a slow loop doesn't produce, and is what
* adaptive mode picks on a quiet loop anyway. */
static void us_quic_use_cubic(struct lsquic_engine_settings *settings) {
settings->es_cc_algo = 1;
}

us_quic_socket_context_t *us_create_quic_socket_context(
struct us_loop_t *loop, struct us_bun_socket_context_options_t options,
unsigned int ext_size, unsigned int idle_timeout_s)
Expand All @@ -706,6 +720,7 @@ us_quic_socket_context_t *us_create_quic_socket_context(
ctx->ssl_ctx = ssl;

lsquic_engine_init_settings(&ctx->settings, LSENG_HTTP_SERVER);
us_quic_use_cubic(&ctx->settings);
ctx->settings.es_versions = LSQUIC_DF_VERSIONS & LSQUIC_IETF_VERSIONS;
ctx->settings.es_ecn = 0;
/* QPACK can expand small dynamic-table refs into large header lists; cap
Expand Down Expand Up @@ -1170,6 +1185,7 @@ us_quic_socket_context_t *us_create_quic_client_context(
ctx->stream_ext_size = stream_ext_size;

lsquic_engine_init_settings(&ctx->settings, LSENG_HTTP);
us_quic_use_cubic(&ctx->settings);
ctx->settings.es_versions = (1u << LSQVER_I001);
ctx->settings.es_ecn = 0;
ctx->settings.es_max_header_list_size = 64 * 1024;
Expand Down
35 changes: 35 additions & 0 deletions patches/lsquic/cubic-llp64-overflow.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
--- a/src/liblsquic/lsquic_cubic.c
+++ b/src/liblsquic/lsquic_cubic.c
@@ -204,11 +204,14 @@ lsquic_cubic_loss (void *cong_ctl)
struct lsquic_cubic *const cubic = cong_ctl;
LSQ_DEBUG("%s(cubic)", __func__);
cubic->cu_epoch_start = 0;
+ /* bun: the window fields are unsigned long, which is 32 bits on Windows
+ * (LLP64), so widen before multiplying; see lsquic_cubic_pacing_rate. */
if (FAST_CONVERGENCE && cubic->cu_cwnd < cubic->cu_last_max_cwnd)
- cubic->cu_last_max_cwnd = cubic->cu_cwnd * TWO_MINUS_BETA_OVER_TWO / 1024;
+ cubic->cu_last_max_cwnd = (uint64_t) cubic->cu_cwnd
+ * TWO_MINUS_BETA_OVER_TWO / 1024;
else
cubic->cu_last_max_cwnd = cubic->cu_cwnd;
- cubic->cu_cwnd = cubic->cu_cwnd * ONE_MINUS_BETA / 1024;
+ cubic->cu_cwnd = (uint64_t) cubic->cu_cwnd * ONE_MINUS_BETA / 1024;
cubic->cu_tcp_cwnd = cubic->cu_cwnd;
cubic->cu_ssthresh = cubic->cu_cwnd;
LSQ_INFO("loss detected, last_max_cwnd: %lu, cwnd: %lu",
@@ -266,7 +269,14 @@ lsquic_cubic_pacing_rate (void *cong_ctl, int in_recovery)
srtt = lsquic_rtt_stats_get_srtt(cubic->cu_rtt_stats);
if (srtt == 0)
srtt = 50000;
- bandwidth = cubic->cu_cwnd * 1000000 / srtt;
+ /* bun: cu_cwnd is unsigned long, 32 bits on Windows (LLP64), so this
+ * product wrapped for every window above 4 KB and the pacing rate came
+ * out as (cwnd * 10^6 mod 2^32) / srtt: a Cubic connection on Windows
+ * was paced at a more or less random rate, usually a small fraction of
+ * cwnd/srtt. Reported upstream as a Windows throughput problem that went
+ * away with pacing disabled or BBR selected:
+ * https://github.com/litespeedtech/lsquic/issues/391 */
+ bandwidth = (uint64_t) cubic->cu_cwnd * 1000000 / srtt;
if (in_slow_start(cubic))
pacing_rate = bandwidth * 2;
else if (in_recovery)
6 changes: 6 additions & 0 deletions scripts/build/deps/lsquic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export const lsquic: Dependency = {
// never be encrypted and the peer idled out instead of learning of the
// close. Select the PNS by handshake progress, as ngtcp2 does.
"patches/lsquic/connection-close-pns.patch",
// Cubic keeps its windows in unsigned long, 32 bits on Windows, and
// multiplied them unwidened: the pacing rate (cwnd * 10^6 / srtt) wrapped
// for every window, so Cubic connections on Windows were paced at a
// garbage rate (litespeedtech/lsquic#391). quic.c pins both engines to
// Cubic, so every HTTP/3 connection on Windows would hit it.
"patches/lsquic/cubic-llp64-overflow.patch",
],

fetchDeps: ["zlib", "lshpack", "lsqpack", "boringssl"],
Expand Down
151 changes: 151 additions & 0 deletions test/js/bun/http/serve-http3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,3 +1301,154 @@ describe("Bun.serve HTTP/3 production", () => {
// (HttpContext.h / Http3Context.h call writeContinue before routing); a
// curl --expect100-timeout assertion was flaky enough to drop here.
});

// Both QUIC engines (Bun.serve's and the fetch client's) are only ticked from
// an event loop, so every RTT lsquic measures includes whatever that loop ran
// in between. quic.c pins the congestion controller to Cubic: lsquic's default
// "adaptive" mode settles on BBRv1 for good when a connection's handshake RTT
// is over 1.5 ms, and BBRv1 then models loop latency as the path. Its window
// target is bandwidth x min RTT, where the only bandwidth it can observe on a
// busy loop is one window per loop iteration and the min RTT is the quickest
// round trip the connection ever saw, so as soon as it leaves startup the
// window drops to the 4-packet minimum and stays there. Cubic only reacts to
// loss, which a slow loop does not produce.
//
// The fixture counts its own event loop iterations and paces them with
// Bun.sleepSync. It sleeps 5 ms per iteration while the connection is set up,
// so adaptive mode would choose BBRv1; /quiet then stops the sleeping, and the
// round trips made while the loop is idle give both engines a genuine loopback
// RTT sample (any real connection has one from a quiet moment); the transfer
// itself then runs with BUSY_ITERATION_MS of work per iteration for ITERATIONS
// iterations. Only the second half of those is asserted on, which leaves
// either controller ample rounds to settle.
//
// What a healthy connection moves per iteration is bounded by the receiver's
// UDP buffer and by lsquic's pacer, which lets about ten packets plus one
// millisecond's worth through per engine tick, so the numbers are modest but
// far apart: over the measured 60 iterations a collapsed BBRv1 window moves
// 5-6 packets per iteration, ~0.4 MiB in total, while Cubic moves several MiB
// (4-13 MiB on Linux and Windows debug builds).
describe("Bun.serve HTTP/3 congestion control", () => {
const ITERATIONS = 120;
// Comfortably longer than the idle round trips even on a debug build, where
// they take 1-2 ms: once out of startup, each round multiplies BBRv1's window
// by about 2 x minRTT / iteration, so this has it at the minimum within a
// few rounds.
const BUSY_ITERATION_MS = 12;
// Larger than any window either controller reaches here, so every tick is
// limited by the congestion window, not by how much the application had
// queued. (Application-limited rounds never let BBRv1 leave startup, which
// is why a response made of small chunks does not show the collapse.)
const CHUNK_SIZE = 512 * 1024;
const MIN_SECOND_HALF_BYTES = 1024 * 1024;

// Two round trips: lsquic takes no bandwidth/RTT sample for a packet sent
// before the connection had its first acknowledgment
// (lsquic_bw_sampler_packet_acked), so the first one only primes the
// sampler and the second is the one the controller measures.
async function quietRoundTrips(port: number) {
for (let i = 0; i < 2; i++) await fetchH3(port, "/quiet").then(r => r.bytes());
}

const script = `
const tls = ${JSON.stringify(tls)};
const ITERATIONS = ${ITERATIONS};
const CHUNK = Buffer.alloc(${CHUNK_SIZE}, "cwnd");
let busyMs = 5;
let iterations = 0;
let windowStart = Infinity;
(function spin() {
iterations++;
if (iterations >= windowStart + ITERATIONS) busyMs = 0;
if (busyMs) Bun.sleepSync(busyMs);
setImmediate(spin);
})();
function openWindow() {
windowStart = iterations;
busyMs = ${BUSY_ITERATION_MS};
}
const inSecondHalf = () => iterations - windowStart >= ITERATIONS / 2;
const windowClosed = () => iterations - windowStart >= ITERATIONS;
let download = { chunks: 0, secondHalfChunks: 0 };
const server = Bun.serve({
port: 0, tls, http3: true, http1: false,
async fetch(req) {
const { pathname } = new URL(req.url);
if (pathname === "/quiet") {
busyMs = 0;
// A few packets' worth so the client acknowledges it right away
// instead of waiting out its delayed-ACK timer.
return new Response(Buffer.alloc(4096, "quiet"));
}
if (pathname === "/download") {
openWindow();
download = { chunks: 0, secondHalfChunks: 0 };
return new Response(new ReadableStream({
// pull() runs once the previous chunk has been handed to lsquic in
// full, so chunks pulled during the second half of the window are
// the chunks the connection moved during it (+-1).
pull(ctrl) {
if (windowClosed()) return ctrl.close();
download.chunks++;
if (inSecondHalf()) download.secondHalfChunks++;
ctrl.enqueue(CHUNK);
},
}));
}
if (pathname === "/download/stats") return Response.json(download);
if (pathname === "/upload") {
openWindow();
let total = 0, secondHalf = 0;
for await (const chunk of req.body) {
if (windowClosed()) break;
total += chunk.length;
if (inSecondHalf()) secondHalf += chunk.length;
}
return Response.json({ total, secondHalf });
}
return new Response("not found", { status: 404 });
},
});
console.error("PORT=" + server.port);
process.stdin.on("data", () => {});
${STOP_ON_STDIN_END}
`;

test("a response body keeps flowing while the server's event loop is busy", async () => {
await withCustomServer(script, async port => {
await quietRoundTrips(port);
const body = await fetchH3(port, "/download").then(r => r.bytes());
const { chunks, secondHalfChunks } = (await fetchH3(port, "/download/stats").then(r => r.json())) as {
chunks: number;
secondHalfChunks: number;
};
expect(body.length).toBe(chunks * CHUNK_SIZE);
expect(secondHalfChunks * CHUNK_SIZE).toBeGreaterThanOrEqual(MIN_SECOND_HALF_BYTES);
});
});

// The fetch client's engine sees the same thing from the other side: the
// server's loop delays every ACK, so its RTT samples are inflated the same way.
test("a streamed request body keeps flowing while the server's event loop is busy", async () => {
await withCustomServer(script, async port => {
await quietRoundTrips(port);
const chunk = Buffer.alloc(CHUNK_SIZE, "cwnd");
let responded = false;
let enqueued = 0;
const res = await fetchH3(port, "/upload", {
method: "POST",
body: new ReadableStream({
pull(ctrl) {
if (responded) return ctrl.close();
enqueued++;
ctrl.enqueue(chunk);
},
}),
});
responded = true;
const { total, secondHalf } = (await res.json()) as { total: number; secondHalf: number };
expect(total).toBeLessThanOrEqual(enqueued * CHUNK_SIZE);
expect(secondHalf).toBeGreaterThanOrEqual(MIN_SECOND_HALF_BYTES);
});
});
});