Files
bun.sh/test/js/node/http2/node-http2-continuation.test.ts
Ciro Spaciari 2582e6f98e fix(http2): fix settings, window size handling, and dynamic header buffer allocation (#26119)
## 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>
2026-01-22 14:35:18 -08:00

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();
}
});
});