mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
## Summary This PR fixes multiple HTTP/2 protocol compliance issues that were causing stream errors with various HTTP/2 clients (Fauna, gRPC/Connect, etc.). fixes https://github.com/oven-sh/bun/issues/12544 fixes https://github.com/oven-sh/bun/issues/25589 ### Key Fixes **Window Size and Settings Handling** - Fix initial stream window size to use `DEFAULT_WINDOW_SIZE` until `SETTINGS_ACK` is received - Per RFC 7540 Section 6.5.1: The sender can only rely on settings being applied AFTER receiving `SETTINGS_ACK` - Properly adjust existing stream windows when `INITIAL_WINDOW_SIZE` setting changes (RFC 7540 Section 6.9.2) **Header List Size Enforcement** - Implement `maxHeaderListSize` checking per RFC 7540 Section 6.5.2 - Track cumulative header list size using HPACK entry overhead (32 bytes per RFC 7541 Section 4.1) - Reject streams with `ENHANCE_YOUR_CALM` when header list exceeds configured limit **Custom Settings Support** - Add validation for `customSettings` option (up to 10 custom settings, matching Node.js `MAX_ADDITIONAL_SETTINGS`) - Validate setting IDs are in range `[0, 0xFFFF]` per RFC 7540 - Validate setting values are in range `[0, 2^32-1]` **Settings Validation Improvements** - Use float comparison for settings validation to handle large values correctly (was using `toInt32()` which truncates) - Use proper `HTTP2_INVALID_SETTING_VALUE_RangeError` error codes for Node.js compatibility **BufferFallbackAllocator** - New allocator that tries a provided buffer first, falls back to heap: - Similar to `std.heap.stackFallback` but accepts external buffer slice - Used with `shared_request_buffer` (16KB threadlocal) for common cases - Falls back to `bun.default_allocator` for large headers ## Test Plan - [x] `bun bd` compiles successfully - [x] Node.js HTTP/2 tests pass: `bun bd test/js/node/test/parallel/test-http2-connect.js` - [x] New regression tests for frame size issues: `bun bd test test/regression/issue/25589.test.ts` - [x] HTTP/2 continuation tests: `bun bd test test/js/node/http2/node-http2-continuation.test.ts` --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
/**
|
|
* HTTP/2 CONTINUATION Frames Tests
|
|
*
|
|
* Tests for RFC 7540 Section 6.10 CONTINUATION frame support.
|
|
* When headers exceed MAX_FRAME_SIZE (default 16384), they must be split
|
|
* into HEADERS + CONTINUATION frames.
|
|
*
|
|
* Works with both:
|
|
* - bun bd test test/js/node/http2/node-http2-continuation.test.ts
|
|
* - node --experimental-strip-types --test test/js/node/http2/node-http2-continuation.test.ts
|
|
*/
|
|
import assert from "node:assert";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import http2 from "node:http2";
|
|
import path from "node:path";
|
|
import { after, before, describe, test } from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Load TLS certificates from fixture files
|
|
const FIXTURES_PATH = path.join(__dirname, "..", "test", "fixtures", "keys");
|
|
const TLS_CERT = {
|
|
cert: fs.readFileSync(path.join(FIXTURES_PATH, "agent1-cert.pem"), "utf8"),
|
|
key: fs.readFileSync(path.join(FIXTURES_PATH, "agent1-key.pem"), "utf8"),
|
|
};
|
|
const CA_CERT = fs.readFileSync(path.join(FIXTURES_PATH, "ca1-cert.pem"), "utf8");
|
|
const TLS_OPTIONS = { ca: CA_CERT };
|
|
|
|
// HTTP/2 connection options to allow large header lists
|
|
const H2_CLIENT_OPTIONS = {
|
|
...TLS_OPTIONS,
|
|
rejectUnauthorized: false,
|
|
// Node.js uses top-level maxHeaderListPairs
|
|
maxHeaderListPairs: 2000,
|
|
settings: {
|
|
// Allow receiving up to 256KB of header data
|
|
maxHeaderListSize: 256 * 1024,
|
|
// Bun reads maxHeaderListPairs from settings
|
|
maxHeaderListPairs: 2000,
|
|
},
|
|
};
|
|
|
|
// Helper to get node executable
|
|
function getNodeExecutable(): string {
|
|
if (typeof Bun !== "undefined") {
|
|
return Bun.which("node") || "node";
|
|
}
|
|
return process.execPath.includes("node") ? process.execPath : "node";
|
|
}
|
|
|
|
// Helper to start Node.js HTTP/2 server
|
|
interface ServerInfo {
|
|
port: number;
|
|
url: string;
|
|
subprocess: ChildProcess;
|
|
close: () => void;
|
|
}
|
|
|
|
async function startNodeServer(): Promise<ServerInfo> {
|
|
const nodeExe = getNodeExecutable();
|
|
const serverPath = path.join(__dirname, "node-http2-continuation-server.fixture.js");
|
|
|
|
const subprocess = spawn(nodeExe, [serverPath, JSON.stringify(TLS_CERT)], {
|
|
stdio: ["inherit", "pipe", "inherit"],
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
|
|
subprocess.stdout!.setEncoding("utf8");
|
|
subprocess.stdout!.on("data", (chunk: string) => {
|
|
data += chunk;
|
|
try {
|
|
const info = JSON.parse(data);
|
|
const url = `https://127.0.0.1:${info.port}`;
|
|
resolve({
|
|
port: info.port,
|
|
url,
|
|
subprocess,
|
|
close: () => subprocess.kill("SIGKILL"),
|
|
});
|
|
} catch {
|
|
// Need more data
|
|
}
|
|
});
|
|
|
|
subprocess.on("error", reject);
|
|
subprocess.on("exit", code => {
|
|
if (code !== 0 && code !== null) {
|
|
reject(new Error(`Server exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Helper to make HTTP/2 request and collect response
|
|
interface Response {
|
|
data: string;
|
|
headers: http2.IncomingHttpHeaders;
|
|
trailers?: http2.IncomingHttpHeaders;
|
|
}
|
|
|
|
function makeRequest(
|
|
client: http2.ClientHttp2Session,
|
|
headers: http2.OutgoingHttpHeaders,
|
|
options?: { waitForTrailers?: boolean },
|
|
): Promise<Response> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = client.request(headers);
|
|
let data = "";
|
|
let responseHeaders: http2.IncomingHttpHeaders = {};
|
|
let trailers: http2.IncomingHttpHeaders | undefined;
|
|
|
|
req.on("response", hdrs => {
|
|
responseHeaders = hdrs;
|
|
});
|
|
|
|
req.on("trailers", hdrs => {
|
|
trailers = hdrs;
|
|
});
|
|
|
|
req.setEncoding("utf8");
|
|
req.on("data", chunk => {
|
|
data += chunk;
|
|
});
|
|
|
|
req.on("end", () => {
|
|
resolve({ data, headers: responseHeaders, trailers });
|
|
});
|
|
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Generate headers of specified count
|
|
function generateHeaders(count: number, valueLength: number = 150): http2.OutgoingHttpHeaders {
|
|
const headers: http2.OutgoingHttpHeaders = {};
|
|
for (let i = 0; i < count; i++) {
|
|
headers[`x-custom-header-${i}`] = "A".repeat(valueLength);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
describe("HTTP/2 CONTINUATION frames - Client Side", () => {
|
|
let server: ServerInfo;
|
|
|
|
before(async () => {
|
|
server = await startNodeServer();
|
|
});
|
|
|
|
after(() => {
|
|
server?.close();
|
|
});
|
|
|
|
test("client sends 97 headers (~16KB) - fits in single HEADERS frame", async () => {
|
|
const client = http2.connect(server.url, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${server.port}`,
|
|
...generateHeaders(97),
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
|
|
const parsed = JSON.parse(response.data);
|
|
assert.strictEqual(parsed.receivedHeaders, 97, "Server should receive all 97 headers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("client sends 150 headers (~25KB) - requires HEADERS + CONTINUATION", async () => {
|
|
const client = http2.connect(server.url, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${server.port}`,
|
|
...generateHeaders(150),
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
|
|
const parsed = JSON.parse(response.data);
|
|
assert.strictEqual(parsed.receivedHeaders, 150, "Server should receive all 150 headers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("client sends 300 headers (~50KB) - requires HEADERS + multiple CONTINUATION", async () => {
|
|
const client = http2.connect(server.url, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${server.port}`,
|
|
...generateHeaders(300),
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
|
|
const parsed = JSON.parse(response.data);
|
|
assert.strictEqual(parsed.receivedHeaders, 300, "Server should receive all 300 headers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("client receives large response headers via CONTINUATION (already works)", async () => {
|
|
const client = http2.connect(server.url, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
// Use 100 headers to stay within Bun's default maxHeaderListPairs limit (~108 after pseudo-headers)
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${server.port}`,
|
|
"x-response-headers": "100", // Server will respond with 100 headers
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
|
|
// Count response headers starting with x-response-header-
|
|
const responseHeaderCount = Object.keys(response.headers).filter(h => h.startsWith("x-response-header-")).length;
|
|
|
|
assert.strictEqual(responseHeaderCount, 100, "Should receive all 100 response headers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("client receives large trailers via CONTINUATION", async () => {
|
|
const client = http2.connect(server.url, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${server.port}`,
|
|
"x-response-trailers": "100", // Server will respond with 100 trailers
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
assert.ok(response.trailers, "Should receive trailers");
|
|
|
|
// Count trailers starting with x-trailer-
|
|
const trailerCount = Object.keys(response.trailers).filter(h => h.startsWith("x-trailer-")).length;
|
|
|
|
assert.strictEqual(trailerCount, 100, "Should receive all 100 trailers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Server-side tests (when Bun acts as HTTP/2 server)
|
|
// These test that Bun can SEND large headers via CONTINUATION frames
|
|
describe("HTTP/2 CONTINUATION frames - Server Side", () => {
|
|
let bunServer: http2.Http2SecureServer;
|
|
let serverPort: number;
|
|
|
|
before(async () => {
|
|
// Create Bun/Node HTTP/2 server
|
|
bunServer = http2.createSecureServer({
|
|
key: TLS_CERT.key,
|
|
cert: TLS_CERT.cert,
|
|
// Allow up to 2000 header pairs (default is 128)
|
|
maxHeaderListPairs: 2000,
|
|
settings: {
|
|
maxHeaderListSize: 256 * 1024, // 256KB
|
|
},
|
|
});
|
|
|
|
bunServer.on("stream", (stream, headers) => {
|
|
const path = headers[":path"] || "/";
|
|
|
|
// Count received headers (excluding pseudo-headers)
|
|
const receivedHeaders = Object.keys(headers).filter(h => !h.startsWith(":")).length;
|
|
|
|
if (path === "/large-response-headers") {
|
|
// Send 150 response headers - requires CONTINUATION frames
|
|
const responseHeaders: http2.OutgoingHttpHeaders = {
|
|
":status": 200,
|
|
"content-type": "application/json",
|
|
};
|
|
for (let i = 0; i < 150; i++) {
|
|
responseHeaders[`x-response-header-${i}`] = "R".repeat(150);
|
|
}
|
|
stream.respond(responseHeaders);
|
|
stream.end(JSON.stringify({ sent: 150 }));
|
|
} else if (path === "/large-trailers") {
|
|
// Send response with large trailers
|
|
stream.respond({ ":status": 200 }, { waitForTrailers: true });
|
|
|
|
stream.on("wantTrailers", () => {
|
|
const trailers: http2.OutgoingHttpHeaders = {};
|
|
for (let i = 0; i < 100; i++) {
|
|
trailers[`x-trailer-${i}`] = "T".repeat(150);
|
|
}
|
|
stream.sendTrailers(trailers);
|
|
});
|
|
|
|
stream.end(JSON.stringify({ sentTrailers: 100 }));
|
|
} else {
|
|
// Echo headers count
|
|
stream.respond({ ":status": 200, "content-type": "application/json" });
|
|
stream.end(JSON.stringify({ receivedHeaders }));
|
|
}
|
|
});
|
|
|
|
bunServer.on("error", err => {
|
|
console.error("Bun server error:", err.message);
|
|
});
|
|
|
|
await new Promise<void>(resolve => {
|
|
bunServer.listen(0, "127.0.0.1", () => {
|
|
const addr = bunServer.address();
|
|
serverPort = typeof addr === "object" && addr ? addr.port : 0;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
after(() => {
|
|
bunServer?.close();
|
|
});
|
|
|
|
test("server receives large request headers via CONTINUATION (already works)", async () => {
|
|
const client = http2.connect(`https://127.0.0.1:${serverPort}`, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
// Use 120 headers to stay within Bun's default maxHeaderListPairs (128)
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${serverPort}`,
|
|
...generateHeaders(120),
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
|
|
const parsed = JSON.parse(response.data);
|
|
assert.strictEqual(parsed.receivedHeaders, 120, "Server should receive all 120 headers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("server sends 120 response headers via CONTINUATION", async () => {
|
|
const client = http2.connect(`https://127.0.0.1:${serverPort}`, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/large-response-headers",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${serverPort}`,
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
|
|
// Count response headers starting with x-response-header-
|
|
// Note: Bun server sends 150 but client receives up to 120 due to maxHeaderListPairs default
|
|
const responseHeaderCount = Object.keys(response.headers).filter(h => h.startsWith("x-response-header-")).length;
|
|
|
|
// Server can send via CONTINUATION, but client has receiving limit
|
|
assert.ok(
|
|
responseHeaderCount >= 100,
|
|
`Should receive at least 100 response headers (got ${responseHeaderCount})`,
|
|
);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("server sends large trailers requiring CONTINUATION", async () => {
|
|
const client = http2.connect(`https://127.0.0.1:${serverPort}`, H2_CLIENT_OPTIONS);
|
|
|
|
try {
|
|
const headers: http2.OutgoingHttpHeaders = {
|
|
":method": "GET",
|
|
":path": "/large-trailers",
|
|
":scheme": "https",
|
|
":authority": `127.0.0.1:${serverPort}`,
|
|
};
|
|
|
|
const response = await makeRequest(client, headers);
|
|
assert.ok(response.data, "Should receive response data");
|
|
assert.ok(response.trailers, "Should receive trailers");
|
|
|
|
// Count trailers starting with x-trailer-
|
|
const trailerCount = Object.keys(response.trailers).filter(h => h.startsWith("x-trailer-")).length;
|
|
|
|
assert.strictEqual(trailerCount, 100, "Should receive all 100 trailers");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
});
|