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>
255 lines
7.6 KiB
TypeScript
255 lines
7.6 KiB
TypeScript
/**
|
|
* Test for GitHub Issue #25589: NGHTTP2_FRAME_SIZE_ERROR with gRPC
|
|
* Tests using @grpc/grpc-js client
|
|
*
|
|
* This test verifies that Bun's HTTP/2 client correctly handles:
|
|
* 1. Large response headers from server
|
|
* 2. Large trailers (gRPC status details)
|
|
* 3. Large request headers from client
|
|
* 4. Large DATA frames
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, test } from "bun:test";
|
|
import assert from "node:assert";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// @ts-ignore - @grpc/grpc-js types
|
|
import * as grpc from "@grpc/grpc-js";
|
|
// @ts-ignore - @grpc/proto-loader types
|
|
import * as loader from "@grpc/proto-loader";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
const protoLoaderOptions = {
|
|
keepCase: true,
|
|
longs: String,
|
|
enums: String,
|
|
defaults: true,
|
|
oneofs: true,
|
|
};
|
|
|
|
function loadProtoFile(file: string) {
|
|
const packageDefinition = loader.loadSync(file, protoLoaderOptions);
|
|
return grpc.loadPackageDefinition(packageDefinition);
|
|
}
|
|
|
|
const protoFile = join(__dirname, "../../js/third_party/grpc-js/fixtures/echo_service.proto");
|
|
const echoService = loadProtoFile(protoFile).EchoService as grpc.ServiceClientConstructor;
|
|
const ca = readFileSync(join(__dirname, "../../js/third_party/grpc-js/fixtures/ca.pem"));
|
|
|
|
interface ServerAddress {
|
|
address: string;
|
|
family: string;
|
|
port: number;
|
|
}
|
|
|
|
let serverProcess: ChildProcess | null = null;
|
|
let serverAddress: ServerAddress | null = null;
|
|
|
|
async function startServer(): Promise<ServerAddress> {
|
|
return new Promise((resolve, reject) => {
|
|
const serverPath = join(__dirname, "25589-frame-size-server.js");
|
|
|
|
serverProcess = spawn("node", [serverPath], {
|
|
env: {
|
|
...process.env,
|
|
GRPC_TEST_USE_TLS: "true",
|
|
// Note: @grpc/grpc-js doesn't directly expose HTTP/2 settings like maxFrameSize
|
|
// The server will use Node.js http2 defaults which allow larger frames
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
let output = "";
|
|
|
|
serverProcess.stdout?.on("data", (data: Buffer) => {
|
|
output += data.toString();
|
|
try {
|
|
const addr = JSON.parse(output) as ServerAddress;
|
|
resolve(addr);
|
|
} catch {
|
|
// Wait for more data
|
|
}
|
|
});
|
|
|
|
serverProcess.stderr?.on("data", (data: Buffer) => {
|
|
console.error("Server stderr:", data.toString());
|
|
});
|
|
|
|
serverProcess.on("error", reject);
|
|
|
|
serverProcess.on("exit", code => {
|
|
if (code !== 0 && !serverAddress) {
|
|
reject(new Error(`Server exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopServer(): Promise<void> {
|
|
return new Promise(resolve => {
|
|
if (serverProcess) {
|
|
serverProcess.stdin?.write("shutdown");
|
|
serverProcess.on("exit", () => resolve());
|
|
setTimeout(() => {
|
|
serverProcess?.kill();
|
|
resolve();
|
|
}, 2000);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
function createClient(address: ServerAddress): InstanceType<typeof echoService> {
|
|
const credentials = grpc.credentials.createSsl(ca);
|
|
const target = `${address.address}:${address.port}`;
|
|
return new echoService(target, credentials);
|
|
}
|
|
|
|
describe("HTTP/2 FRAME_SIZE_ERROR with @grpc/grpc-js", () => {
|
|
beforeAll(async () => {
|
|
serverAddress = await startServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stopServer();
|
|
});
|
|
|
|
test("receives large response (32KB) without FRAME_SIZE_ERROR", async () => {
|
|
assert.ok(serverAddress, "Server should be running");
|
|
|
|
const client = createClient(serverAddress);
|
|
const metadata = new grpc.Metadata();
|
|
metadata.add("x-large-response", "32768"); // 32KB response
|
|
|
|
try {
|
|
const response = await new Promise<{ value: string; value2: number }>((resolve, reject) => {
|
|
client.echo(
|
|
{ value: "test", value2: 1 },
|
|
metadata,
|
|
(err: Error | null, response: { value: string; value2: number }) => {
|
|
if (err) reject(err);
|
|
else resolve(response);
|
|
},
|
|
);
|
|
});
|
|
|
|
assert.ok(response.value.length >= 32768, `Response should be at least 32KB, got ${response.value.length}`);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("receives large response (100KB) without FRAME_SIZE_ERROR", async () => {
|
|
assert.ok(serverAddress, "Server should be running");
|
|
|
|
const client = createClient(serverAddress);
|
|
const metadata = new grpc.Metadata();
|
|
metadata.add("x-large-response", "102400"); // 100KB response
|
|
|
|
try {
|
|
const response = await new Promise<{ value: string; value2: number }>((resolve, reject) => {
|
|
client.echo(
|
|
{ value: "test", value2: 1 },
|
|
metadata,
|
|
(err: Error | null, response: { value: string; value2: number }) => {
|
|
if (err) reject(err);
|
|
else resolve(response);
|
|
},
|
|
);
|
|
});
|
|
|
|
assert.ok(response.value.length >= 102400, `Response should be at least 100KB, got ${response.value.length}`);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("receives large response headers without FRAME_SIZE_ERROR", async () => {
|
|
assert.ok(serverAddress, "Server should be running");
|
|
|
|
const client = createClient(serverAddress);
|
|
const metadata = new grpc.Metadata();
|
|
// Request 100 headers of ~200 bytes each = ~20KB of headers
|
|
metadata.add("x-large-headers", "100");
|
|
|
|
try {
|
|
const response = await new Promise<{ value: string; value2: number }>((resolve, reject) => {
|
|
client.echo(
|
|
{ value: "test", value2: 1 },
|
|
metadata,
|
|
(err: Error | null, response: { value: string; value2: number }) => {
|
|
if (err) reject(err);
|
|
else resolve(response);
|
|
},
|
|
);
|
|
});
|
|
|
|
assert.strictEqual(response.value, "test");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("sends large request metadata without FRAME_SIZE_ERROR", async () => {
|
|
assert.ok(serverAddress, "Server should be running");
|
|
|
|
const client = createClient(serverAddress);
|
|
const metadata = new grpc.Metadata();
|
|
// Add many custom headers to test large header handling.
|
|
// Bun supports CONTINUATION frames for headers exceeding MAX_FRAME_SIZE,
|
|
// but we limit to 97 headers (~19KB) as a reasonable test bound.
|
|
for (let i = 0; i < 97; i++) {
|
|
metadata.add(`x-custom-header-${i}`, "A".repeat(200));
|
|
}
|
|
|
|
try {
|
|
const response = await new Promise<{ value: string; value2: number }>((resolve, reject) => {
|
|
client.echo(
|
|
{ value: "test", value2: 1 },
|
|
metadata,
|
|
(err: Error | null, response: { value: string; value2: number }) => {
|
|
if (err) reject(err);
|
|
else resolve(response);
|
|
},
|
|
);
|
|
});
|
|
|
|
assert.strictEqual(response.value, "test");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("receives large trailers without FRAME_SIZE_ERROR", async () => {
|
|
assert.ok(serverAddress, "Server should be running");
|
|
|
|
const client = createClient(serverAddress);
|
|
const metadata = new grpc.Metadata();
|
|
// Request large trailers (20KB)
|
|
metadata.add("x-large-trailers", "20000");
|
|
|
|
try {
|
|
const response = await new Promise<{ value: string; value2: number }>((resolve, reject) => {
|
|
client.echo(
|
|
{ value: "test", value2: 1 },
|
|
metadata,
|
|
(err: Error | null, response: { value: string; value2: number }) => {
|
|
if (err) reject(err);
|
|
else resolve(response);
|
|
},
|
|
);
|
|
});
|
|
|
|
assert.strictEqual(response.value, "test");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
});
|