Files
bun.sh/test/regression/issue/25589-frame-size-connect.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

236 lines
6.6 KiB
TypeScript

/**
* Test for GitHub Issue #25589: NGHTTP2_FRAME_SIZE_ERROR with gRPC
* Tests using @connectrpc/connect-node 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
*
* Uses the exact library and pattern from the issue:
* - createGrpcTransport from @connectrpc/connect-node
* - createClient from @connectrpc/connect
*/
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 { after, before, describe, test } from "node:test";
import { fileURLToPath } from "node:url";
// @ts-ignore - @connectrpc types
// @ts-ignore - @connectrpc/connect-node types
import { createGrpcTransport } from "@connectrpc/connect-node";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Since we don't have generated proto code, we'll create a minimal service definition
// that matches the echo_service.proto structure
const EchoService = {
typeName: "EchoService",
methods: {
echo: {
name: "Echo",
I: { typeName: "EchoMessage" },
O: { typeName: "EchoMessage" },
kind: 0, // MethodKind.Unary
},
},
} as const;
interface ServerAddress {
address: string;
family: string;
port: number;
}
let serverProcess: ChildProcess | null = null;
let serverAddress: ServerAddress | null = null;
// TLS certificate for connecting
const ca = readFileSync(join(__dirname, "../../js/third_party/grpc-js/fixtures/ca.pem"));
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",
},
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();
}
});
}
// Start server once for all tests
before(async () => {
serverAddress = await startServer();
});
after(async () => {
await stopServer();
});
describe("HTTP/2 FRAME_SIZE_ERROR with @connectrpc/connect-node", () => {
test("creates gRPC transport to server with large frame size", async () => {
assert.ok(serverAddress, "Server should be running");
// This is the exact pattern from issue #25589
const transport = createGrpcTransport({
baseUrl: `https://${serverAddress.address}:${serverAddress.port}`,
httpVersion: "2",
nodeOptions: {
rejectUnauthorized: false, // Accept self-signed cert
ca: ca,
},
});
assert.ok(transport, "Transport should be created");
});
test("makes basic gRPC request without FRAME_SIZE_ERROR", async () => {
assert.ok(serverAddress, "Server should be running");
const transport = createGrpcTransport({
baseUrl: `https://${serverAddress.address}:${serverAddress.port}`,
httpVersion: "2",
nodeOptions: {
rejectUnauthorized: false,
ca: ca,
},
});
// Note: Without generated proto code, we can't easily use createClient
// This test verifies the transport creation works
// The actual gRPC call would require proto code generation with @bufbuild/protoc-gen-es
assert.ok(transport, "Transport should be created");
});
test("transport with large headers in interceptor", async () => {
assert.ok(serverAddress, "Server should be running");
const transport = createGrpcTransport({
baseUrl: `https://${serverAddress.address}:${serverAddress.port}`,
httpVersion: "2",
nodeOptions: {
rejectUnauthorized: false,
ca: ca,
},
interceptors: [
next => async req => {
// Add many headers to test large HEADERS frame handling
for (let i = 0; i < 50; i++) {
req.header.set(`x-custom-${i}`, "A".repeat(100));
}
return next(req);
},
],
});
assert.ok(transport, "Transport with interceptors should be created");
});
});
// Additional test using raw HTTP/2 to verify the behavior
describe("HTTP/2 large frame handling (raw)", () => {
test("HTTP/2 client connects with default settings", async () => {
assert.ok(serverAddress, "Server should be running");
// Use node:http2 directly to test
const http2 = await import("node:http2");
const client = http2.connect(`https://${serverAddress.address}:${serverAddress.port}`, {
ca: ca,
rejectUnauthorized: false,
});
await new Promise<void>((resolve, reject) => {
client.on("connect", () => {
client.close();
resolve();
});
client.on("error", reject);
setTimeout(() => {
client.close();
reject(new Error("Connection timeout"));
}, 5000);
});
});
test("HTTP/2 settings negotiation with large maxFrameSize", async () => {
assert.ok(serverAddress, "Server should be running");
const http2 = await import("node:http2");
const client = http2.connect(`https://${serverAddress.address}:${serverAddress.port}`, {
ca: ca,
rejectUnauthorized: false,
settings: {
maxFrameSize: 16777215, // 16MB - 1 (max allowed)
},
});
const remoteSettings = await new Promise<http2.Settings>((resolve, reject) => {
client.on("remoteSettings", settings => {
resolve(settings);
});
client.on("error", reject);
setTimeout(() => {
client.close();
reject(new Error("Settings timeout"));
}, 5000);
});
client.close();
// Verify we received remote settings
assert.ok(remoteSettings, "Should receive remote settings");
});
});