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>
534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
/**
|
|
* Regression test for issue #25589
|
|
*
|
|
* HTTP/2 requests fail with NGHTTP2_FLOW_CONTROL_ERROR when:
|
|
* 1. Server advertises custom window/frame sizes via SETTINGS
|
|
* 2. Client sends data before SETTINGS exchange completes
|
|
*
|
|
* Root cause: Server was enforcing localSettings.initialWindowSize immediately
|
|
* instead of waiting for SETTINGS_ACK from client (per RFC 7540 Section 6.5.1).
|
|
*
|
|
* @see https://github.com/oven-sh/bun/issues/25589
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, test } from "bun:test";
|
|
import assert from "node:assert";
|
|
import { readFileSync } from "node:fs";
|
|
import http2 from "node:http2";
|
|
import { join } from "node:path";
|
|
|
|
// TLS certificates for testing
|
|
const fixturesDir = join(import.meta.dirname, "..", "fixtures");
|
|
const tls = {
|
|
cert: readFileSync(join(fixturesDir, "cert.pem")),
|
|
key: readFileSync(join(fixturesDir, "cert.key")),
|
|
};
|
|
|
|
interface TestContext {
|
|
server: http2.Http2SecureServer;
|
|
serverPort: number;
|
|
serverUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Creates an HTTP/2 server with specified settings
|
|
*/
|
|
async function createServer(settings: http2.Settings): Promise<TestContext> {
|
|
const server = http2.createSecureServer({
|
|
...tls,
|
|
allowHTTP1: false,
|
|
settings,
|
|
});
|
|
|
|
server.on("stream", (stream, _headers) => {
|
|
const chunks: Buffer[] = [];
|
|
|
|
stream.on("data", (chunk: Buffer) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
stream.on("end", () => {
|
|
const body = Buffer.concat(chunks);
|
|
stream.respond({
|
|
":status": 200,
|
|
"content-type": "application/json",
|
|
});
|
|
stream.end(JSON.stringify({ receivedBytes: body.length }));
|
|
});
|
|
|
|
stream.on("error", err => {
|
|
console.error("Stream error:", err);
|
|
});
|
|
});
|
|
|
|
server.on("error", err => {
|
|
console.error("Server error:", err);
|
|
});
|
|
|
|
const serverPort = await new Promise<number>((resolve, reject) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("Failed to get server address"));
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
server.once("error", reject);
|
|
});
|
|
|
|
return {
|
|
server,
|
|
serverPort,
|
|
serverUrl: `https://127.0.0.1:${serverPort}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sends an HTTP/2 POST request and returns the response
|
|
*/
|
|
async function sendRequest(
|
|
client: http2.ClientHttp2Session,
|
|
data: Buffer,
|
|
path = "/test",
|
|
): Promise<{ receivedBytes: number }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = client.request({
|
|
":method": "POST",
|
|
":path": path,
|
|
});
|
|
|
|
let responseData = "";
|
|
|
|
req.on("response", headers => {
|
|
if (headers[":status"] !== 200) {
|
|
reject(new Error(`Unexpected status: ${headers[":status"]}`));
|
|
}
|
|
});
|
|
|
|
req.on("data", chunk => {
|
|
responseData += chunk;
|
|
});
|
|
|
|
req.on("end", () => {
|
|
try {
|
|
resolve(JSON.parse(responseData));
|
|
} catch {
|
|
reject(new Error(`Failed to parse response: ${responseData}`));
|
|
}
|
|
});
|
|
|
|
req.on("error", reject);
|
|
|
|
req.write(data);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Waits for remote settings from server
|
|
*/
|
|
function waitForSettings(client: http2.ClientHttp2Session): Promise<http2.Settings> {
|
|
return new Promise((resolve, reject) => {
|
|
client.once("remoteSettings", resolve);
|
|
client.once("error", reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Closes an HTTP/2 client session
|
|
*/
|
|
function closeClient(client: http2.ClientHttp2Session): Promise<void> {
|
|
return new Promise(resolve => {
|
|
client.close(resolve);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Closes an HTTP/2 server
|
|
*/
|
|
function closeServer(server: http2.Http2SecureServer): Promise<void> {
|
|
return new Promise(resolve => {
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test Suite 1: Large frame size (server allows up to 16MB frames)
|
|
// =============================================================================
|
|
describe("HTTP/2 large frame size", () => {
|
|
let ctx: TestContext;
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createServer({
|
|
maxFrameSize: 16777215, // 16MB - 1 (maximum per RFC 7540)
|
|
maxConcurrentStreams: 100,
|
|
initialWindowSize: 1024 * 1024, // 1MB window
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (ctx?.server) {
|
|
await closeServer(ctx.server);
|
|
}
|
|
});
|
|
|
|
test("sends 32KB data (larger than default 16KB frame)", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
const settings = await waitForSettings(client);
|
|
assert.strictEqual(settings.maxFrameSize, 16777215);
|
|
|
|
const data = Buffer.alloc(32 * 1024, "x");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 32 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sends 100KB data", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
await waitForSettings(client);
|
|
|
|
const data = Buffer.alloc(100 * 1024, "y");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 100 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sends 512KB data", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
await waitForSettings(client);
|
|
|
|
const data = Buffer.alloc(512 * 1024, "z");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 512 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Test Suite 2: Small window size (flow control edge cases)
|
|
// This is the key test for issue #25589
|
|
// =============================================================================
|
|
describe("HTTP/2 small window size (flow control)", () => {
|
|
let ctx: TestContext;
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createServer({
|
|
maxFrameSize: 16777215, // Large frame size
|
|
maxConcurrentStreams: 100,
|
|
initialWindowSize: 16384, // Small window (16KB) - triggers flow control
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (ctx?.server) {
|
|
await closeServer(ctx.server);
|
|
}
|
|
});
|
|
|
|
test("sends 64KB data with 16KB window (requires WINDOW_UPDATE)", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
const settings = await waitForSettings(client);
|
|
assert.strictEqual(settings.maxFrameSize, 16777215);
|
|
assert.strictEqual(settings.initialWindowSize, 16384);
|
|
|
|
// Send 64KB - 4x the window size, requires flow control
|
|
const data = Buffer.alloc(64 * 1024, "x");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 64 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sends multiple parallel requests exhausting window", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
await waitForSettings(client);
|
|
|
|
// Send 3 parallel 32KB requests
|
|
const promises = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const data = Buffer.alloc(32 * 1024, String(i));
|
|
promises.push(sendRequest(client, data));
|
|
}
|
|
|
|
const results = await Promise.all(promises);
|
|
for (const result of results) {
|
|
assert.strictEqual(result.receivedBytes, 32 * 1024);
|
|
}
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sends data immediately without waiting for settings (issue #25589)", async () => {
|
|
// This is the critical test for issue #25589
|
|
// Bug: Server was enforcing initialWindowSize=16384 BEFORE client received SETTINGS
|
|
// Fix: Server uses DEFAULT_WINDOW_SIZE (65535) until SETTINGS_ACK is received
|
|
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
// Send 32KB immediately (2x server's window) WITHOUT waiting for remoteSettings
|
|
// Per RFC 7540, client can assume default window size (65535) until SETTINGS is received
|
|
// Server must accept this until client ACKs the server's SETTINGS
|
|
const data = Buffer.alloc(32 * 1024, "z");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 32 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sends 48KB immediately (3x server window) without waiting for settings", async () => {
|
|
// More data = more likely to trigger flow control error
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
const data = Buffer.alloc(48 * 1024, "a");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 48 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sends 60KB immediately (near default window limit) without waiting for settings", async () => {
|
|
// 60KB is close to the default window size (65535 bytes)
|
|
// Should work because client assumes default window until SETTINGS received
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
const data = Buffer.alloc(60 * 1024, "b");
|
|
const response = await sendRequest(client, data);
|
|
|
|
assert.strictEqual(response.receivedBytes, 60 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("opens multiple streams immediately with small payloads", async () => {
|
|
// Multiple streams opened immediately, each sending data > server's window
|
|
// but total stays within connection window (65535 bytes default)
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
// Send 3 parallel 18KB requests immediately (each > 16KB server window)
|
|
// Total = 54KB < 65535 connection window
|
|
const promises = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
const data = Buffer.alloc(18 * 1024, String(i));
|
|
promises.push(sendRequest(client, data, `/test${i}`));
|
|
}
|
|
|
|
const results = await Promise.all(promises);
|
|
for (const result of results) {
|
|
assert.strictEqual(result.receivedBytes, 18 * 1024);
|
|
}
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("sequential requests on fresh connection without waiting for settings", async () => {
|
|
// Each request on a fresh connection without waiting for settings
|
|
for (let i = 0; i < 3; i++) {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
const data = Buffer.alloc(20 * 1024, String.fromCharCode(97 + i));
|
|
const response = await sendRequest(client, data, `/seq${i}`);
|
|
assert.strictEqual(response.receivedBytes, 20 * 1024);
|
|
|
|
await closeClient(client);
|
|
}
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Test Suite 3: gRPC-style framing (5-byte header + payload)
|
|
// =============================================================================
|
|
describe("HTTP/2 gRPC-style framing", () => {
|
|
let ctx: TestContext;
|
|
|
|
function createGrpcMessage(payload: Buffer): Buffer {
|
|
const header = Buffer.alloc(5);
|
|
header[0] = 0; // Not compressed
|
|
header.writeUInt32BE(payload.length, 1); // Message length (big-endian)
|
|
return Buffer.concat([header, payload]);
|
|
}
|
|
|
|
function parseGrpcResponse(data: Buffer): { receivedBytes: number } {
|
|
if (data.length < 5) {
|
|
throw new Error("Invalid gRPC response: too short");
|
|
}
|
|
const messageLength = data.readUInt32BE(1);
|
|
const payload = data.subarray(5, 5 + messageLength);
|
|
return JSON.parse(payload.toString());
|
|
}
|
|
|
|
async function sendGrpcRequest(
|
|
client: http2.ClientHttp2Session,
|
|
payload: Buffer,
|
|
path = "/test.Service/Method",
|
|
): Promise<{ receivedBytes: number }> {
|
|
return new Promise((resolve, reject) => {
|
|
const grpcMessage = createGrpcMessage(payload);
|
|
|
|
const req = client.request({
|
|
":method": "POST",
|
|
":path": path,
|
|
"content-type": "application/grpc",
|
|
te: "trailers",
|
|
});
|
|
|
|
let responseData = Buffer.alloc(0);
|
|
|
|
req.on("response", headers => {
|
|
if (headers[":status"] !== 200) {
|
|
reject(new Error(`Unexpected status: ${headers[":status"]}`));
|
|
}
|
|
});
|
|
|
|
req.on("data", (chunk: Buffer) => {
|
|
responseData = Buffer.concat([responseData, chunk]);
|
|
});
|
|
|
|
req.on("end", () => {
|
|
try {
|
|
resolve(parseGrpcResponse(responseData));
|
|
} catch (e) {
|
|
reject(new Error(`Failed to parse gRPC response: ${e}`));
|
|
}
|
|
});
|
|
|
|
req.on("error", reject);
|
|
|
|
req.write(grpcMessage);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const server = http2.createSecureServer({
|
|
...tls,
|
|
allowHTTP1: false,
|
|
settings: {
|
|
maxFrameSize: 16777215,
|
|
maxConcurrentStreams: 100,
|
|
initialWindowSize: 1024 * 1024,
|
|
},
|
|
});
|
|
|
|
server.on("stream", (stream, _headers) => {
|
|
const chunks: Buffer[] = [];
|
|
|
|
stream.on("data", (chunk: Buffer) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
stream.on("end", () => {
|
|
const body = Buffer.concat(chunks);
|
|
// Parse gRPC message (skip 5-byte header)
|
|
if (body.length >= 5) {
|
|
const messageLength = body.readUInt32BE(1);
|
|
const payload = body.subarray(5, 5 + messageLength);
|
|
stream.respond({
|
|
":status": 200,
|
|
"content-type": "application/grpc",
|
|
"grpc-status": "0",
|
|
});
|
|
// Echo back a gRPC response
|
|
const response = createGrpcMessage(Buffer.from(JSON.stringify({ receivedBytes: payload.length })));
|
|
stream.end(response);
|
|
} else {
|
|
stream.respond({ ":status": 400 });
|
|
stream.end();
|
|
}
|
|
});
|
|
|
|
stream.on("error", err => {
|
|
console.error("Stream error:", err);
|
|
});
|
|
});
|
|
|
|
server.on("error", err => {
|
|
console.error("Server error:", err);
|
|
});
|
|
|
|
const serverPort = await new Promise<number>((resolve, reject) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("Failed to get server address"));
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
server.once("error", reject);
|
|
});
|
|
|
|
ctx = {
|
|
server,
|
|
serverPort,
|
|
serverUrl: `https://127.0.0.1:${serverPort}`,
|
|
};
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (ctx?.server) {
|
|
await closeServer(ctx.server);
|
|
}
|
|
});
|
|
|
|
test("gRPC message with 32KB payload", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
const settings = await waitForSettings(client);
|
|
assert.strictEqual(settings.maxFrameSize, 16777215);
|
|
|
|
const payload = Buffer.alloc(32 * 1024, "x");
|
|
const response = await sendGrpcRequest(client, payload);
|
|
|
|
assert.strictEqual(response.receivedBytes, 32 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("gRPC message with 100KB payload", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
await waitForSettings(client);
|
|
|
|
const payload = Buffer.alloc(100 * 1024, "y");
|
|
const response = await sendGrpcRequest(client, payload);
|
|
|
|
assert.strictEqual(response.receivedBytes, 100 * 1024);
|
|
|
|
await closeClient(client);
|
|
});
|
|
|
|
test("multiple concurrent gRPC calls", async () => {
|
|
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
|
|
|
|
await waitForSettings(client);
|
|
|
|
const promises = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
const payload = Buffer.alloc(32 * 1024, String.fromCharCode(97 + i));
|
|
promises.push(sendGrpcRequest(client, payload, `/test.Service/Method${i}`));
|
|
}
|
|
|
|
const results = await Promise.all(promises);
|
|
for (const result of results) {
|
|
assert.strictEqual(result.receivedBytes, 32 * 1024);
|
|
}
|
|
|
|
await closeClient(client);
|
|
});
|
|
});
|