diff --git a/lib/async/http/protocol/http2/stream.rb b/lib/async/http/protocol/http2/stream.rb index 2ca4207c..33d045d3 100644 --- a/lib/async/http/protocol/http2/stream.rb +++ b/lib/async/http/protocol/http2/stream.rb @@ -61,6 +61,9 @@ def add_header(key, value, trailer: false) # @parameter end_stream [Boolean] Whether the stream ends after these headers. def receive_trailing_headers(headers, end_stream) 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/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..3fef2f78 100644 --- a/test/async/http/protocol/http2.rb +++ b/test/async/http/protocol/http2.rb @@ -149,6 +149,129 @@ 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 + + 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 let(:finished) {Async::Promise.new}