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
8 changes: 5 additions & 3 deletions packages/bun-usockets/src/node_quic_shim.c
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,12 @@ int us_nq_stream_send_headers(lsquic_stream_t *s, const char *buf, size_t len,
if (i >= len) break;
i++;
unsigned char flags = (i < len) ? (unsigned char) buf[i++] : 0;
if (name_len > LSXPACK_MAX_STRLEN || val_len > LSXPACK_MAX_STRLEN)
if (name_len >= LSXPACK_MAX_STRLEN || val_len > LSXPACK_MAX_STRLEN)
return -1;
lsxpack_header_set_offset2(&hdrs[count], buf, name_off, name_len,
val_off, val_len);
/* Per-entry base keeps val_offset (= name_len+1) under the setter's
* LSXPACK_MAX_STRLEN assert no matter how large the joined buffer is. */
lsxpack_header_set_offset2(&hdrs[count], buf + name_off, 0, name_len,
val_off - name_off, val_len);
Comment thread
robobun marked this conversation as resolved.
if (flags & 1) {
hdrs[count].flags = LSXPACK_NEVER_INDEX;
hdrs[count].indexed_type = 2;
Expand Down
30 changes: 20 additions & 10 deletions packages/bun-usockets/src/quic.c
Original file line number Diff line number Diff line change
Expand Up @@ -529,14 +529,16 @@ static struct lsxpack_header *us_quic_hsi_prepare(void *hset_p, struct lsxpack_h
h->buf = nb;
h->cap = ncap;
}
/* lsxpack offsets are 16-bit, so the field's buf starts at its own slice
* (h->buf + h->len) and its offsets count from there, not from h->buf. */
if (hdr == NULL) {
hdr = &h->scratch;
lsxpack_header_prepare_decode(hdr, h->buf, h->len, space);
lsxpack_header_prepare_decode(hdr, h->buf + h->len, 0, space);
} else {
/* Resize: lsqpack already wrote part of name/value into the previous
* buffer; only the storage may move. Preserve offsets, repoint buf,
* and report the larger window via val_len. */
hdr->buf = h->buf;
hdr->buf = h->buf + h->len;
hdr->val_len = (lsxpack_strlen_t) space;
}
return hdr;
Expand All @@ -557,13 +559,13 @@ static int us_quic_hsi_process(void *hset_p, struct lsxpack_header *hdr) {
/* lsxpack wrote name+value into h->buf at h->len; record offsets, then
* advance len so the next header lands after this one. We store offsets
* (cast to pointer-sized) and resolve them after the buffer stops moving. */
h->headers[h->count].name = (const char *)(uintptr_t) hdr->name_offset;
h->headers[h->count].name = (const char *)(uintptr_t) (h->len + hdr->name_offset);
h->headers[h->count].name_len = hdr->name_len;
h->headers[h->count].value = (const char *)(uintptr_t) hdr->val_offset;
h->headers[h->count].value = (const char *)(uintptr_t) (h->len + hdr->val_offset);
h->headers[h->count].value_len = hdr->val_len;
h->headers[h->count].qpack_index = -1;
h->count++;
h->len = (unsigned int) hdr->val_offset + hdr->val_len + hdr->dec_overhead;
h->len += (unsigned int) hdr->val_offset + hdr->val_len + hdr->dec_overhead;
return 0;
}

Expand Down Expand Up @@ -1169,8 +1171,13 @@ int us_quic_stream_send_headers(us_quic_stream_t *s,
* so each pair has to be contiguous. The caller hands us arbitrary
* pointers, so flatten here. */
size_t total = 0;
for (unsigned int i = 0; i < count; i++)
for (unsigned int i = 0; i < count; i++) {
/* lsxpack_header stores each length in 16 bits; a longer one would
* go out truncated. */
if (headers[i].name_len > LSXPACK_MAX_STRLEN || headers[i].value_len > LSXPACK_MAX_STRLEN)
return -1;
Comment thread
robobun marked this conversation as resolved.
total += headers[i].name_len + headers[i].value_len;
}

char stackbuf[1024];
char *buf = total <= sizeof(stackbuf) ? stackbuf : (char *) us_malloc(total);
Expand All @@ -1186,10 +1193,13 @@ int us_quic_stream_send_headers(us_quic_stream_t *s,
size_t off = 0;
for (unsigned int i = 0; i < count; i++) {
const struct us_quic_header_t *h = &headers[i];
memcpy(buf + off, h->name, h->name_len);
memcpy(buf + off + h->name_len, h->value, h->value_len);
lsxpack_header_set_offset2(&xh[i], buf, off, h->name_len,
off + h->name_len, h->value_len);
char *pair = buf + off;
memcpy(pair, h->name, h->name_len);
memcpy(pair + h->name_len, h->value, h->value_len);
/* Offsets are relative to this pair, not to buf: lsxpack asserts
* that an offset fits 16 bits, and `total` can be larger. */
lsxpack_header_set_offset2(&xh[i], pair, 0, h->name_len,
h->name_len, h->value_len);
if (h->qpack_index >= 0) {
xh[i].qpack_index = (uint8_t) h->qpack_index;
xh[i].flags = LSXPACK_QPACK_IDX;
Expand Down
86 changes: 86 additions & 0 deletions patches/lsquic/large-header-block.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
--- a/src/liblsquic/lsquic_stream.c
+++ b/src/liblsquic/lsquic_stream.c
@@ -53,6 +53,7 @@
#include "lsquic_ev_log.h"
#include "lsquic_enc_sess.h"
#include "lsqpack.h"
+#include "lsxpack_header.h"
#include "lsquic_frab_list.h"
#include "lsquic_http1x_if.h"
#include "lsquic_qdec_hdl.h"
@@ -4113,9 +4114,28 @@ stream_write_buf (struct lsquic_stream *stream, const void *buf, size_t sz)
}


-/* This limits the cumulative size of the compressed header fields */
+/* Compressed header blocks up to this size are encoded on the stack. Larger
+ * ones go through a heap buffer sized from the header list.
+ */
#define MAX_HEADERS_SIZE (64 * 1024)

+/* bun: upper bound on the size of the compressed header block. QPACK picks
+ * Huffman only when it is shorter than the literal, so a field line is at
+ * most its name and value plus one prefix byte and one integer for each.
+ */
+static size_t
+headers_max_encoded_size (const struct lsquic_http_headers *headers)
+{
+ size_t size = 0;
+ int i;
+
+ for (i = 0; i < headers->count; ++i)
+ if (headers->headers[i].buf)
+ size += 2 * LSQPACK_UINT64_ENC_SZ + headers->headers[i].name_len
+ + headers->headers[i].val_len;
+ return size;
+}
+
static int
send_headers_ietf (struct lsquic_stream *stream,
const struct lsquic_http_headers *headers, int eos)
@@ -4132,7 +4152,11 @@ send_headers_ietf (struct lsquic_stream *stream,
unsigned char *header_block;
enum lsqpack_enc_header_flags hflags;
int rv;
- const size_t buf_sz = max_push_size + max_prefix_size + MAX_HEADERS_SIZE;
+ const size_t max_headers_size = headers_max_encoded_size(headers);
+ const size_t buf_sz = max_push_size + max_prefix_size
+ + (max_headers_size > MAX_HEADERS_SIZE ? max_headers_size
+ : MAX_HEADERS_SIZE);
+ unsigned char *buf;

if (stream->sm_send_headers_state != SSHS_BEGIN || stream->sm_header_block)
{
@@ -4141,9 +4165,20 @@ send_headers_ietf (struct lsquic_stream *stream,
return -1;
}
#ifndef WIN32
- unsigned char buf[buf_sz];
+ unsigned char stack_buf[max_push_size + max_prefix_size + MAX_HEADERS_SIZE];
+ if (max_headers_size > MAX_HEADERS_SIZE)
+ {
+ buf = malloc(buf_sz);
+ if (!buf)
+ {
+ LSQ_WARN("cannot allocate %zu bytes for header block", buf_sz);
+ return -1;
+ }
+ }
+ else
+ buf = stack_buf;
#else
- unsigned char *buf = _malloca(buf_sz);
+ buf = _malloca(buf_sz);
if (!buf)
return -1;
#endif
@@ -4234,6 +4269,9 @@ send_headers_ietf (struct lsquic_stream *stream,
clean:
#ifdef WIN32
_freea(buf);
+#else
+ if (buf != stack_buf)
+ free(buf);
#endif
return rv;

5 changes: 5 additions & 0 deletions scripts/build/deps/lsquic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export const lsquic: Dependency = {
// H3_CLOSED_CRITICAL_STREAM and closes the connection, which kills the
// requests the graceful stop was draining. Reject only request streams.
"patches/lsquic/goaway-accept-uni-streams.patch",
// send_headers_ietf() encoded the header block into a fixed 64 KB stack
// buffer and failed with QWH_ENOBUF for a larger block, so a response
// with more than 64 KB of headers never went out. Size the buffer from
// the header list and use the heap above 64 KB.
"patches/lsquic/large-header-block.patch",
],

fetchDeps: ["zlib", "lshpack", "lsqpack", "boringssl"],
Expand Down
25 changes: 25 additions & 0 deletions test/js/bun/http/serve-http3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ const server = serve({
if (url.pathname === "/big") {
return new Response(big, { headers: { "content-type": "application/octet-stream" } });
}
if (url.pathname === "/big-headers") {
const n = Number(url.searchParams.get("n"));
const size = Number(url.searchParams.get("size"));
// "~" is 13 bits in the QPACK Huffman table, so the values stay literals.
const value = Buffer.alloc(size, "~").toString();
const headers = {};
for (let i = 0; i < n; i++) headers["x-big-" + i] = value;
return new Response("ok", { headers });
}
if (url.pathname === "/status") {
return new Response(null, { status: 204 });
}
Expand Down Expand Up @@ -547,6 +556,22 @@ describe("Bun.serve HTTP/3 adversarial", () => {
});
});

test("response with more than 64 KB of headers is sent", async () => {
// 100 x 700 bytes is about 70 KB after QPACK encoding. lsquic encoded the
// header block into a 64 KB buffer and dropped anything larger, so the
// response never went out and the client waited until its idle timeout.
await withServer(async port => {
const n = 100;
const size = 700;
const res = await fetchH3(port, `/big-headers?n=${n}&size=${size}`);
expect(res.status).toBe(200);
const value = Buffer.alloc(size, "~").toString();
const values = Array.from({ length: n }, (_, i) => res.headers.get(`x-big-${i}`));
expect(values).toEqual(Array(n).fill(value));
expect(await res.text()).toBe("ok");
});
});

test("8 MB POST body echoes byte-exact", async () => {
await withServer(async port => {
// Patterned (not crypto-random) so the test is deterministic but still
Expand Down
53 changes: 53 additions & 0 deletions test/js/node/quic/quic-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,59 @@ describe("HTTP/3 header encoding", () => {
expect(seen.length).toBe(1);
expect(Object.keys(seen[0])).not.toContain("authorization");
});

// The joined name/value buffer is about 70 KB. lsxpack offsets are 16-bit,
// so each header has to address its own slice of that buffer, and lsquic
// has to encode a header block larger than its 64 KB stack buffer.
test("sends a response whose header block is larger than 64 KB", async () => {
const n = 100;
const value = Buffer.alloc(700, "~").toString();
const big: Record<string, string> = { ":status": "200" };
for (let i = 0; i < n; i++) big["x-big-" + i] = value;
const application = { maxHeaderLength: 1 << 20, maxHeaderPairs: 1000 };

await using server = await listen(
async serverSession => {
serverSession.onstream = (stream: any) => {
stream.closed.catch(() => {});
};
await serverSession.closed.catch(() => {});
},
{
sni: { "*": { keys: [key], certs: [cert] } },
transportParams: { maxIdleTimeout: 1 },
application,
onheaders(this: any) {
this.sendHeaders(big, { terminal: true });
},
},
);

// The header limits are baked into the client engine when its endpoint is
// created, so a reused endpoint from an earlier test keeps the defaults.
const client = await connect(server.address, {
servername: "localhost",
verifyPeer: "manual",
transportParams: { maxIdleTimeout: 1 },
application,
reuseEndpoint: false,
});
await client.opened;

const got = Promise.withResolvers<Record<string, string>>();
await client.createBidirectionalStream({
headers: { ":method": "GET", ":path": "/", ":scheme": "https", ":authority": "localhost" },
onheaders(headers: Record<string, string>) {
got.resolve(headers);
},
});

const headers = await got.promise;
expect(headers[":status"]).toBe("200");
const values = Array.from({ length: n }, (_, i) => headers["x-big-" + i]);
expect(values).toEqual(Array(n).fill(value));
client.close();
});
});

// lsquic decodes a header block that follows another one while the stream is
Expand Down
Loading