Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/async/http/protocol/http2/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def each(task: Task.current)
@requests&.async do |task, request|
task.annotate("Incoming request: #{request.method} #{request.path.inspect}.")

response = nil

task.defer_stop do
response = yield(request)
rescue
Expand All @@ -69,6 +71,11 @@ def each(task: Task.current)
raise
else
request.send_response(response)
# If send response is successful, we clear it so that we don't close it below.
response = nil
ensure
# If some failure occurs and we didn't send the response correctly, ensure that it's closed:
response&.close
end
end

Expand Down
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Releases

## Unreleased

- Fix response body leak in HTTP/2 server when stream is reset before `send_response` completes (e.g. client-side gRPC cancellation). The response body's `close` was never called, leaking any resources tied to body lifecycle (such as `rack.response_finished` callbacks and utilization metrics).

## v0.94.1

- Fix `defer_stop` usage in `HTTP1::Server`, improving server graceful shutdown behavior.
Expand Down
59 changes: 59 additions & 0 deletions test/async/http/protocol/http2/response_close_on_stream_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "async/http/protocol/http2"
require "sus/fixtures/async/http"
require "protocol/http/body/wrapper"

describe Async::HTTP::Protocol::HTTP2 do
with "response body close on stream error" do
include Sus::Fixtures::Async::HTTP::ServerContext
let(:protocol) {subject}

let(:body_closed) {Async::Variable.new}

let(:app) do
variable = body_closed

Protocol::HTTP::Middleware.for do |request|
inner_body = Protocol::HTTP::Body::Buffered.new(["Hello World"])

tracking_body = Class.new(Protocol::HTTP::Body::Wrapper) do
define_method(:close) do |error = nil|
super(error)
variable.value = true
end
end.new(inner_body)

if request.respond_to?(:stream) && request.stream
Async do
sleep(0.01)
request.stream.send_reset_stream(Protocol::HTTP2::NO_ERROR)
end

sleep(0.05)
end

Protocol::HTTP::Response[200, {}, tracking_body]
end
end

it "closes the response body when the stream is reset before sending" do
expect do
response = client.get("/")
end.to raise_exception(Exception)

# Wait up to 1 second for the body to be closed. Without the fix,
# close is never called and this times out → test failure.
result = Async::Task.current.with_timeout(1.0) do
body_closed.wait
rescue Async::TimeoutError
nil
end

expect(result).to be == true
end
end
end
Loading