From 0a42f6c0cb95dd5e475cc31d5aa67e2e04ed821d Mon Sep 17 00:00:00 2001 From: Konstantin Baranov Date: Wed, 8 Jul 2026 17:54:58 -0700 Subject: [PATCH] Deliver a promised stream's response HEADERS received after GOAWAY On receiving GOAWAY the connection moves to :closed, after which #receive dropped every inbound HEADERS frame - including the response HEADERS of a stream the peer promised to finish (id <= last_stream_id). That stream's DATA frames were still delivered (the DATA branch has no :closed guard), so the consumer saw a malformed, headerless response and the request was lost. RFC 9113 Section 6.8 says a receiver of GOAWAY may still complete streams at or below last_stream_id. Only discard HEADERS that would open a NEW stream (id not already in @streams); keep delivering an in-flight stream's response. This bites whenever a server emits GOAWAY just ahead of the response on the same connection - e.g. golang.org/x/net/http2 graceful shutdown - which loses responses the server actually processed and put on the wire. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/http/2/connection.rb | 7 ++++++- spec/client_spec.rb | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/http/2/connection.rb b/lib/http/2/connection.rb index 6e74bc9..be77985 100644 --- a/lib/http/2/connection.rb +++ b/lib/http/2/connection.rb @@ -295,7 +295,12 @@ def receive(data) # PUSH_PROMISE and CONTINUATION frames MUST be minimally # processed to ensure a consistent compression state decode_headers(frame) - return if @state == :closed + # A GOAWAY moves the connection to :closed, but streams the peer + # promised to finish (id <= last_stream_id, already in @streams) can + # still complete (RFC 9113 Section 6.8). Only discard HEADERS that + # would open a NEW stream; a known stream's response must be + # delivered (its DATA frames already are - see the branch below). + return if @state == :closed && !@streams.key?(stream_id) stream = @streams[stream_id] if stream.nil? diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 1d40f11..02722e5 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -309,6 +309,36 @@ client << f.generate(push_promise_frame) expect(client.active_stream_count).to eq 1 end + + it "should deliver response headers for a stream the GOAWAY promised to finish" do + stream = client.new_stream # id 1 + stream.send headers_frame + + received = nil + stream.on(:headers) { |h| received = h } + + # GOAWAY (last_stream: 2) covers this stream (id 1), so the server + # promised to finish it: its response, arriving after the GOAWAY, must + # still be delivered rather than dropped once the connection is :closed. + client << f.generate(goaway_frame) + client << f.generate(headers_frame.merge(payload: Compressor.new.encode(RESPONSE_HEADERS))) + + expect(received).to eq(RESPONSE_HEADERS) + end + + it "should drop response headers for a stream the GOAWAY refused" do + client << f.generate(goaway_frame) # last_stream: 2 + + opened = false + client.on(:stream) { opened = true } + + # A stream beyond last_stream_id (id 5) was refused and never processed; + # its (unexpected) headers must not open a stream on the closed connection. + client << f.generate(headers_frame.merge(stream: 5, payload: Compressor.new.encode(RESPONSE_HEADERS))) + + expect(opened).to be(false) + expect(client.active_stream_count).to eq 0 + end end context "framing" do