Compare commits

...

2 Commits

Author SHA1 Message Date
Ciro Spaciari
b125bb29fe Merge branch 'main' into claude/fix-http2-empty-frames-21759 2026-01-27 10:17:52 -08:00
Claude Bot
431c5a3694 fix(http2): avoid multiple empty DATA frames when ending stream
This fixes an issue where Bun's HTTP/2 server implementation would send
multiple consecutive empty DATA frames when ending a stream, causing
Envoy proxy to abort with PROTOCOL_ERROR.

Changes:
1. In noTrailers(), send an empty HEADERS frame with END_STREAM flag
   instead of an empty DATA frame. This is more appropriate per RFC 7540
   Section 8.1 and avoids issues with proxies that limit consecutive
   empty frames.

2. In Http2Stream.end(), don't create an empty buffer when no chunk
   is provided. This prevents an unnecessary empty DATA frame from
   being sent before _final() is called.

Fixes #21759

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:30:32 +00:00
2 changed files with 246 additions and 1 deletions

View File

@@ -3456,7 +3456,18 @@ pub const H2FrameParser = struct {
};
stream.waitForTrailers = false;
this.sendData(stream, "", true, .js_undefined);
// RFC 7540 Section 8.1: Send an empty HEADERS frame with END_STREAM and END_HEADERS flags
// to properly close the stream without trailers. This avoids sending an empty DATA frame
// which can cause issues with proxies that limit consecutive empty frames (e.g., Envoy).
const writer = this.toWriter();
var frame: FrameHeader = .{
.type = @intFromEnum(FrameType.HTTP_FRAME_HEADERS),
.flags = @intFromEnum(HeadersFrameFlags.END_STREAM) | @intFromEnum(HeadersFrameFlags.END_HEADERS),
.streamIdentifier = stream.id,
.length = 0,
};
_ = frame.write(@TypeOf(writer), writer);
const identifier = stream.getIdentifier();
identifier.ensureStillAlive();

View File

@@ -0,0 +1,234 @@
import { describe, expect, test } from "bun:test";
import { tls } from "harness";
import http2 from "node:http2";
const TLS_OPTIONS = { ca: tls.cert };
// Issue #21759: HTTP/2 server sends multiple consecutive empty DATA frames
// This test verifies that when a server responds without trailers, it doesn't
// send multiple empty DATA frames, which can cause issues with proxies like Envoy.
describe("HTTP/2 empty DATA frames", () => {
test("server should not send multiple empty DATA frames when ending stream", async () => {
const {
promise: serverReady,
resolve: resolveServerReady,
reject: rejectServerReady,
} = Promise.withResolvers<{
server: http2.Http2SecureServer;
port: number;
}>();
const {
promise: requestDone,
resolve: resolveRequestDone,
reject: rejectRequestDone,
} = Promise.withResolvers<void>();
// Track DATA frames received by the client
const dataFrames: Buffer[] = [];
const server = http2.createSecureServer({
...tls,
});
server.on("error", err => {
rejectServerReady(err);
rejectRequestDone(err);
});
server.on("stream", (stream, headers) => {
// Respond without trailers - this should NOT result in multiple empty DATA frames
stream.respond({
":status": 200,
"content-type": "text/plain",
});
// Write some data and end the stream
stream.end("Hello, World!");
});
server.listen(0, () => {
const address = server.address();
if (typeof address === "object" && address !== null) {
resolveServerReady({ server, port: address.port });
} else {
rejectServerReady(new Error("Failed to get server address"));
}
});
const { port } = await serverReady;
const client = http2.connect(`https://localhost:${port}`, TLS_OPTIONS);
client.on("error", rejectRequestDone);
const req = client.request({ ":path": "/" });
req.on("response", headers => {
expect(headers[":status"]).toBe(200);
});
req.on("data", chunk => {
dataFrames.push(chunk);
});
req.on("end", () => {
client.close();
resolveRequestDone();
});
req.on("error", rejectRequestDone);
req.end();
await requestDone;
server.close();
// Verify we received the expected data
const receivedData = Buffer.concat(dataFrames).toString();
expect(receivedData).toBe("Hello, World!");
});
test("server using Http2ServerResponse should not send multiple empty DATA frames", async () => {
const {
promise: serverReady,
resolve: resolveServerReady,
reject: rejectServerReady,
} = Promise.withResolvers<{
server: http2.Http2SecureServer;
port: number;
}>();
const {
promise: requestDone,
resolve: resolveRequestDone,
reject: rejectRequestDone,
} = Promise.withResolvers<void>();
const server = http2.createSecureServer({
...tls,
});
server.on("error", err => {
rejectServerReady(err);
rejectRequestDone(err);
});
// Use the request/response API (which has waitForTrailers: true by default)
server.on("request", (req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("Hello from response API!");
});
server.listen(0, () => {
const address = server.address();
if (typeof address === "object" && address !== null) {
resolveServerReady({ server, port: address.port });
} else {
rejectServerReady(new Error("Failed to get server address"));
}
});
const { port } = await serverReady;
const client = http2.connect(`https://localhost:${port}`, TLS_OPTIONS);
client.on("error", rejectRequestDone);
const req = client.request({ ":path": "/" });
const dataChunks: Buffer[] = [];
req.on("response", headers => {
expect(headers[":status"]).toBe(200);
});
req.on("data", chunk => {
dataChunks.push(chunk);
});
req.on("end", () => {
client.close();
resolveRequestDone();
});
req.on("error", rejectRequestDone);
req.end();
await requestDone;
server.close();
const receivedData = Buffer.concat(dataChunks).toString();
expect(receivedData).toBe("Hello from response API!");
});
test("server ending stream without data should send proper END_STREAM", async () => {
const {
promise: serverReady,
resolve: resolveServerReady,
reject: rejectServerReady,
} = Promise.withResolvers<{
server: http2.Http2SecureServer;
port: number;
}>();
const {
promise: requestDone,
resolve: resolveRequestDone,
reject: rejectRequestDone,
} = Promise.withResolvers<void>();
const server = http2.createSecureServer({
...tls,
});
server.on("error", err => {
rejectServerReady(err);
rejectRequestDone(err);
});
server.on("stream", stream => {
// Respond and immediately end without any body
stream.respond({
":status": 204,
});
stream.end();
});
server.listen(0, () => {
const address = server.address();
if (typeof address === "object" && address !== null) {
resolveServerReady({ server, port: address.port });
} else {
rejectServerReady(new Error("Failed to get server address"));
}
});
const { port } = await serverReady;
const client = http2.connect(`https://localhost:${port}`, TLS_OPTIONS);
client.on("error", rejectRequestDone);
const req = client.request({ ":path": "/" });
req.on("response", headers => {
expect(headers[":status"]).toBe(204);
});
const dataChunks: Buffer[] = [];
req.on("data", chunk => {
dataChunks.push(chunk);
});
req.on("end", () => {
client.close();
resolveRequestDone();
});
req.on("error", rejectRequestDone);
req.end();
await requestDone;
server.close();
// No data should be received for 204 response
expect(dataChunks.length).toBe(0);
});
});