From 8df191ca1721ce3f0c1b0d4cfa3df2f7ddd0c3c0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 2 Sep 2026 09:42:43 +1200 Subject: [PATCH] Handle orderly HTTP/2 stream closure Assisted-By: devx/618580b0-d55f-4c2e-95b6-87e648f60543 --- lib/async/http/protocol/http2/response.rb | 5 +-- lib/async/http/protocol/http2/stream.rb | 11 ++--- releases.md | 4 ++ .../connection_close_with_active_streams.rb | 14 +++++++ .../http2/response_close_on_stream_error.rb | 2 +- test/async/http/protocol/http2/stream.rb | 42 +++++++++++++++++++ 6 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 test/async/http/protocol/http2/stream.rb diff --git a/lib/async/http/protocol/http2/response.rb b/lib/async/http/protocol/http2/response.rb index 83fcf2b..7941140 100644 --- a/lib/async/http/protocol/http2/response.rb +++ b/lib/async/http/protocol/http2/response.rb @@ -129,10 +129,9 @@ def closed(error) @response = nil end - if error + unless @ready.resolved? + error ||= EOFError.new("Stream closed before response headers were received!") @ready.reject(error) - else - @ready.resolve(nil) end end end diff --git a/lib/async/http/protocol/http2/stream.rb b/lib/async/http/protocol/http2/stream.rb index 3ab171a..2ca4207 100644 --- a/lib/async/http/protocol/http2/stream.rb +++ b/lib/async/http/protocol/http2/stream.rb @@ -210,10 +210,7 @@ def send_data(...) # - A frame is sent which causes this stream to enter the closed state. This method will be invoked from that task. # While the input stream is relatively straight forward, the output stream can trigger the second case above def closed(error) - orderly_reset = error.is_a?(::Protocol::HTTP2::StreamError) && - error.code == ::Protocol::HTTP2::Error::NO_ERROR - - if orderly_reset + if error.is_a?(::Protocol::HTTP2::StreamError) && error.code == ::Protocol::HTTP2::Error::NO_ERROR error = nil end @@ -227,10 +224,10 @@ def closed(error) if output = @output @output = nil - if orderly_reset - output.close_stream - else + if error output.stop(error) + else + output.close_stream end end diff --git a/releases.md b/releases.md index bbb3d92..1fee99c 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Handle `RST_STREAM(NO_ERROR)` as an orderly HTTP/2 stream closure while still failing requests whose streams close before any response headers are received. + ## v0.102.0 - Requests assigned to an HTTP/2 connection which has already closed are refused before being written, allowing them to be retried safely. diff --git a/test/async/http/protocol/http2/connection_close_with_active_streams.rb b/test/async/http/protocol/http2/connection_close_with_active_streams.rb index 3370138..ed8c159 100644 --- a/test/async/http/protocol/http2/connection_close_with_active_streams.rb +++ b/test/async/http/protocol/http2/connection_close_with_active_streams.rb @@ -69,6 +69,20 @@ end.wait end + it "raises EOFError when the stream closes before receiving headers" do + Async do + client_connection = Async::HTTP::Protocol::HTTP2::Client.new(client_stream) + client_connection.open! + + response = client_connection.create_response + response.stream.close! + + expect do + client_connection.read_response(response) + end.to raise_exception(EOFError, message: be =~ /Stream closed before response headers/) + end.wait + end + it "does not raise error when connection closes without active streams" do Async do |task| # Create client connection diff --git a/test/async/http/protocol/http2/response_close_on_stream_error.rb b/test/async/http/protocol/http2/response_close_on_stream_error.rb index 77fe4c7..0e52212 100644 --- a/test/async/http/protocol/http2/response_close_on_stream_error.rb +++ b/test/async/http/protocol/http2/response_close_on_stream_error.rb @@ -10,7 +10,7 @@ require "protocol/http/body/wrapper" describe Async::HTTP::Protocol::HTTP2 do - with "response body close on stream error" do + with "response body close on stream reset" do include Sus::Fixtures::Async::HTTP::ServerContext let(:protocol) {subject} diff --git a/test/async/http/protocol/http2/stream.rb b/test/async/http/protocol/http2/stream.rb new file mode 100644 index 0000000..a18ff40 --- /dev/null +++ b/test/async/http/protocol/http2/stream.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/http/protocol/http2/stream" + +describe Async::HTTP::Protocol::HTTP2::Stream do + let(:output) do + Class.new do + def close_stream + end + + def stop(error) + end + end.new + end + + let(:stream) do + Class.new(subject) do + def initialize(output) + @input = nil + @output = output + @pool = nil + @connection = nil + end + end.new(output) + end + + it "closes the output stream on an orderly closure" do + expect(output).to receive(:close_stream) + + stream.closed(nil) + end + + it "stops the output stream on an error" do + error = RuntimeError.new("Stream failed!") + expect(output).to receive(:stop).with(error) + + stream.closed(error) + end +end