fix(http2): send connection-level WINDOW_UPDATE in setLocalWindowSize

`setLocalWindowSize()` updated the internal connection window size but
never sent a WINDOW_UPDATE frame for stream 0 (the connection level) to
the peer. Per RFC 9113 Section 6.9, the INITIAL_WINDOW_SIZE setting only
applies to stream-level windows; the connection-level window must be
updated explicitly via WINDOW_UPDATE. This caused HTTP/2 streams to
stall after receiving 65,535 bytes (the default connection window).

Closes #26915

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-02-11 21:57:44 +00:00
parent 50e478dcdc
commit b0e2718560
2 changed files with 67 additions and 0 deletions

View File

@@ -2864,10 +2864,19 @@ pub const H2FrameParser = struct {
if (this.usedWindowSize > windowSizeValue) {
return globalObject.throwInvalidArguments("Expected windowSize to be greater than usedWindowSize", .{});
}
const oldWindowSize = this.windowSize;
this.windowSize = windowSizeValue;
if (this.localSettings.initialWindowSize < windowSizeValue) {
this.localSettings.initialWindowSize = windowSizeValue;
}
// Send a connection-level WINDOW_UPDATE frame to the peer so it knows
// about the increased window. Per RFC 9113 Section 6.9, the
// INITIAL_WINDOW_SIZE setting only applies to stream-level windows;
// the connection-level window must be updated explicitly.
if (windowSizeValue > oldWindowSize) {
const increment: u31 = @truncate(windowSizeValue - oldWindowSize);
this.sendWindowUpdate(0, UInt31WithReserved.init(increment, false));
}
var it = this.streams.valueIterator();
while (it.next()) |stream| {
if (stream.usedWindowSize > windowSizeValue) {

View File

@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import http2 from "node:http2";
// Regression test for https://github.com/oven-sh/bun/issues/26915
// setLocalWindowSize() must send a connection-level WINDOW_UPDATE frame.
// Without this, the peer's connection-level window stays at the default
// 65,535 bytes and streams stall when receiving larger payloads.
test("http2 client setLocalWindowSize sends connection-level WINDOW_UPDATE", async () => {
const payloadSize = 256 * 1024; // 256 KB - well above the 65535 default
const payload = Buffer.alloc(payloadSize, "x");
const { promise: serverReady, resolve: resolveServer } = Promise.withResolvers<{
port: number;
server: http2.Http2Server;
}>();
const server = http2.createServer();
server.on("stream", stream => {
stream.respond({ ":status": 200 });
stream.end(payload);
});
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === "object") {
resolveServer({ port: addr.port, server });
}
});
const { port, server: srv } = await serverReady;
try {
const client = http2.connect(`http://localhost:${port}`, {
settings: { initialWindowSize: 10 * 1024 * 1024 },
});
client.setLocalWindowSize(10 * 1024 * 1024);
const { promise: done, resolve: resolveDone, reject: rejectDone } = Promise.withResolvers<Buffer>();
const req = client.request({ ":path": "/" });
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
req.on("end", () => {
resolveDone(Buffer.concat(chunks));
});
req.on("error", rejectDone);
req.end();
const result = await done;
expect(result.length).toBe(payloadSize);
client.close();
} finally {
srv.close();
}
});