From b0e271856094e5f52762dc13bbda6781ec32af3f Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 11 Feb 2026 21:57:44 +0000 Subject: [PATCH] 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 --- src/bun.js/api/bun/h2_frame_parser.zig | 9 ++++ test/regression/issue/26915.test.ts | 58 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/regression/issue/26915.test.ts diff --git a/src/bun.js/api/bun/h2_frame_parser.zig b/src/bun.js/api/bun/h2_frame_parser.zig index 0224a89f16..06899cf628 100644 --- a/src/bun.js/api/bun/h2_frame_parser.zig +++ b/src/bun.js/api/bun/h2_frame_parser.zig @@ -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) { diff --git a/test/regression/issue/26915.test.ts b/test/regression/issue/26915.test.ts new file mode 100644 index 0000000000..666c3f006e --- /dev/null +++ b/test/regression/issue/26915.test.ts @@ -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(); + + 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(); + } +});