From 7db3cf8af05bb0b280112ba748e531e4119b4ed6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 12:35:41 +1300 Subject: [PATCH 1/2] Keep received HTTP/2 trailers distinct from the header section. --- lib/async/http/protocol/http2/stream.rb | 3 + releases.md | 4 ++ test/async/http/protocol/http2.rb | 82 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/lib/async/http/protocol/http2/stream.rb b/lib/async/http/protocol/http2/stream.rb index 2ca4207c..96f5e3e3 100644 --- a/lib/async/http/protocol/http2/stream.rb +++ b/lib/async/http/protocol/http2/stream.rb @@ -60,6 +60,9 @@ def add_header(key, value, trailer: false) # @parameter headers [Array] The trailing header key-value pairs. # @parameter end_stream [Boolean] Whether the stream ends after these headers. def receive_trailing_headers(headers, end_stream) + # Mark the start of the trailer section, so that received trailers are not merged into the header section: + @headers.trailer! + headers.each do |key, value| add_header(key, value, trailer: true) end diff --git a/releases.md b/releases.md index f56fb22f..b3c337be 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Keep received HTTP/2 trailers in the trailer section of the message headers, rather than merging them into the header section. + ## v0.105.0 - Respond with `400 Bad Request` for any `Protocol::HTTP::BadRequest` raised while parsing HTTP/1 or HTTP/2 requests, include the exception class name without reflecting request data, and avoid reporting them as unhandled server errors. diff --git a/test/async/http/protocol/http2.rb b/test/async/http/protocol/http2.rb index be6d4cf9..5fef18fb 100644 --- a/test/async/http/protocol/http2.rb +++ b/test/async/http/protocol/http2.rb @@ -149,6 +149,88 @@ def make_client(endpoint, **options) end end + with "received trailers" do + with "request trailer" do + let(:request_received) {Async::Promise.new} + let(:request_headers) {Async::Promise.new} + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + request_received.resolve(true) + request.finish + + request_headers.resolve(request.headers) + + Protocol::HTTP::Response[200, [], "request trailer"] + end + end + + it "keeps the request trailer distinct from the header section" do + headers = Protocol::HTTP::Headers.new + headers.add("trailer", "etag") + body = Async::HTTP::Body::Writable.new + + Async do + body.write("Hello") + + request_received.wait + headers.add("etag", "abcd") + + body.close_write + end + + response = client.post("/", headers, body) + expect(response.read).to be == "request trailer" + + received = request_headers.wait + + expect(received).to be(:trailer?) + expect(received.header.to_a).not.to have_value(be == ["etag", "abcd"]) + expect(received.trailer.to_a).to be == [["etag", "abcd"]] + end + end + + with "response trailer" do + let(:response_received) {Async::Promise.new} + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + headers = Protocol::HTTP::Headers.new + headers.add("trailer", "etag") + + body = Async::HTTP::Body::Writable.new + + Async do + body.write("response trailer") + + response_received.wait + headers.add("etag", "abcd") + + body.close_write + end + + Protocol::HTTP::Response[200, headers, body] + end + end + + it "keeps the response trailer distinct from the header section" do + response = client.get("/") + headers = response.headers + + expect(headers).to have_keys("trailer") + expect(headers).not.to be(:trailer?) + + response_received.resolve(true) + + expect(response.read).to be == "response trailer" + + expect(headers).to be(:trailer?) + expect(headers.header.to_a).not.to have_value(be == ["etag", "abcd"]) + expect(headers.trailer.to_a).to be == [["etag", "abcd"]] + end + end + end + with "stopping requests" do let(:finished) {Async::Promise.new} From 794eb48986dd66ff5a399fee89ecb1de46498fab Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 13:12:07 +1300 Subject: [PATCH 2/2] Only mark the trailer section when a received trailing HEADERS frame has fields. --- lib/async/http/protocol/http2/stream.rb | 6 ++-- test/async/http/protocol/http2.rb | 41 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/async/http/protocol/http2/stream.rb b/lib/async/http/protocol/http2/stream.rb index 96f5e3e3..33d045d3 100644 --- a/lib/async/http/protocol/http2/stream.rb +++ b/lib/async/http/protocol/http2/stream.rb @@ -60,10 +60,10 @@ def add_header(key, value, trailer: false) # @parameter headers [Array] The trailing header key-value pairs. # @parameter end_stream [Boolean] Whether the stream ends after these headers. def receive_trailing_headers(headers, end_stream) - # Mark the start of the trailer section, so that received trailers are not merged into the header section: - @headers.trailer! - headers.each do |key, value| + # Mark the start of the trailer section before the first trailer field is added, so that received trailers are not merged into the header section. An empty trailing HEADERS frame does not create a trailer section. + @headers.trailer! + add_header(key, value, trailer: true) end end diff --git a/test/async/http/protocol/http2.rb b/test/async/http/protocol/http2.rb index 5fef18fb..3fef2f78 100644 --- a/test/async/http/protocol/http2.rb +++ b/test/async/http/protocol/http2.rb @@ -229,6 +229,47 @@ def make_client(endpoint, **options) expect(headers.trailer.to_a).to be == [["etag", "abcd"]] end end + + with "empty request trailer" do + let(:request_headers) {Async::Promise.new} + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + request.finish + + request_headers.resolve(request.headers) + + Protocol::HTTP::Response[200, [], "empty request trailer"] + end + end + + it "does not create a trailer section" do + client.pool.acquire do |connection| + response = connection.create_response + + # The body is never closed, so the client does not end the stream itself: + body = Async::HTTP::Body::Writable.new + body.write("Hello") + + request = Protocol::HTTP::Request["POST", "/", [], body] + request.scheme = "http" + request.authority = "localhost" + + response.send_request(request) + + # Instead, end the stream with a trailing HEADERS frame that has no fields: + response.stream.send_headers([], ::Protocol::HTTP2::END_STREAM) + + connection.read_response(response) + expect(response.read).to be == "empty request trailer" + end + + received = request_headers.wait + + expect(received).not.to be(:trailer?) + expect(received.trailer.to_a).to be(:empty?) + end + end end with "stopping requests" do