From f8b802369ce70a4f6ccc0f4f8f114645ab169a7f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 23:44:12 +1200 Subject: [PATCH] Handle HTTP/1 client disconnects during response writes. --- async-http.gemspec | 2 +- lib/async/http/protocol/http1/server.rb | 3 + releases.md | 4 + test/async/http/protocol/http1/server.rb | 99 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 test/async/http/protocol/http1/server.rb diff --git a/async-http.gemspec b/async-http.gemspec index 69a63320..1a7eba75 100644 --- a/async-http.gemspec +++ b/async-http.gemspec @@ -29,7 +29,7 @@ Gem::Specification.new do |spec| spec.add_dependency "io-endpoint", "~> 0.18" spec.add_dependency "io-stream", "~> 0.14" spec.add_dependency "protocol-http", "~> 0.66" - spec.add_dependency "protocol-http1", "~> 0.39" + spec.add_dependency "protocol-http1", "~> 0.41" spec.add_dependency "protocol-http2", "~> 0.26" spec.add_dependency "protocol-url", "~> 0.2" end diff --git a/lib/async/http/protocol/http1/server.rb b/lib/async/http/protocol/http1/server.rb index b2daaf6b..7e343f72 100644 --- a/lib/async/http/protocol/http1/server.rb +++ b/lib/async/http/protocol/http1/server.rb @@ -161,6 +161,9 @@ def each(task: Task.current) # Do not remove this line or you will unleash the gods of concurrency hell. task.yield end + rescue ::Protocol::HTTP::RemoteError => error + Console.debug(self, "Remote endpoint disconnected!", exception: error) + return rescue => error # We store error (as a local variable) for later use in the ensure block. raise diff --git a/releases.md b/releases.md index 9be0a359..777bc0ff 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Handle remote disconnects in `Async::HTTP::Protocol::HTTP1::Server#each` without reporting them as server failures. + ## v0.100.0 - Added transport-neutral TLS configuration support to `Async::HTTP::Endpoint`. diff --git a/test/async/http/protocol/http1/server.rb b/test/async/http/protocol/http1/server.rb new file mode 100644 index 00000000..910de7be --- /dev/null +++ b/test/async/http/protocol/http1/server.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/http/protocol/http1/server" + +require "async/promise" +require "protocol/http/body/buffered" +require "protocol/http/body/wrapper" +require "sus/fixtures/async/reactor_context" + +describe Async::HTTP::Protocol::HTTP1::Server do + include Sus::Fixtures::Async::ReactorContext + + let(:server_class) do + Class.new(subject) do + attr :next_request_count + + def initialize(error = nil) + super(nil, "HTTP/1.1") + + @error = error + @next_request_count = 0 + @request = Async::HTTP::Protocol::HTTP1::Request.new( + self, + nil, + "localhost", + "GET", + "/", + "HTTP/1.1", + Protocol::HTTP::Headers.new, + nil, + ) + end + + def next_request + @next_request_count += 1 + + request = @request + @request = nil + return request + end + + def write_response(...) + end + + def write_body(...) + if @error + raise Protocol::HTTP::RemoteError, @error.message, cause: @error + end + end + + def hijacked? + false + end + end + end + + let(:body_closed) {Async::Promise.new} + + let(:body) do + body_closed = self.body_closed + + Class.new(Protocol::HTTP::Body::Wrapper) do + define_method(:close) do |error = nil| + body_closed.resolve(error) + super(error) + end + end.new(Protocol::HTTP::Body::Buffered.wrap("Hello World")) + end + + [Errno::EPIPE, Errno::ECONNRESET].each do |error_class| + it "handles #{error_class} reported as a remote error", unique: error_class.name do + error = error_class.new + server = server_class.new(error) + + server.each do + Protocol::HTTP::Response[200, {}, body] + end + + expect(server.next_request_count).to be == 1 + + expect(body_closed.wait).to be_a(Protocol::HTTP::RemoteError).and( + have_attributes(cause: be_equal(error)) + ) + end + end + + it "propagates errors raised while generating the response" do + server = server_class.new + + expect do + server.each do + raise Errno::EPIPE + end + end.to raise_exception(Errno::EPIPE) + end +end