diff --git a/lib/async/http/protocol/http1/client.rb b/lib/async/http/protocol/http1/client.rb index 105a2ca..f408763 100644 --- a/lib/async/http/protocol/http1/client.rb +++ b/lib/async/http/protocol/http1/client.rb @@ -61,7 +61,8 @@ def call(request, task: Task.current) self.close(error) end elsif request.connect? - task.async(annotation: "Tunnneling request...") do + # Transient, like the pipe tasks this tunnel feeds: an open tunnel should not keep the parent task (or the reactor) from finishing. + task.async(transient: true, annotation: "Tunneling request...") do write_tunnel_body(@version, body) rescue => error self.close(error) diff --git a/lib/async/http/protocol/http2/output.rb b/lib/async/http/protocol/http2/output.rb index 6430189..c49d2d2 100644 --- a/lib/async/http/protocol/http2/output.rb +++ b/lib/async/http/protocol/http2/output.rb @@ -29,13 +29,16 @@ def initialize(stream, body, trailer = nil) attr :trailer # Start an asynchronous task to write the body to the stream. - def start(parent: Task.current) + # + # @parameter parent [Async::Task] The parent task to run the output task under. + # @parameter transient [Boolean] Whether the output task should be transient — a tunnel body (e.g. CONNECT) lives as long as the tunnel, and should not keep the parent task from finishing. + def start(parent: Task.current, transient: false) raise "Task already started!" if @task if @body.stream? - @task = parent.async(&self.method(:stream)) + @task = parent.async(transient: transient, &self.method(:stream)) else - @task = parent.async(&self.method(:passthrough)) + @task = parent.async(transient: transient, &self.method(:passthrough)) end end diff --git a/lib/async/http/protocol/http2/response.rb b/lib/async/http/protocol/http2/response.rb index 1bff410..b9e8482 100644 --- a/lib/async/http/protocol/http2/response.rb +++ b/lib/async/http/protocol/http2/response.rb @@ -260,7 +260,8 @@ def send_request(request) raise ::Protocol::HTTP::RefusedError end - @stream.send_body(request.body, trailer) + # A CONNECT request's body streams the tunnel for as long as the tunnel lives — it must not keep the parent task from finishing: + @stream.send_body(request.body, trailer, transient: request.connect?) end end end diff --git a/lib/async/http/protocol/http2/stream.rb b/lib/async/http/protocol/http2/stream.rb index 414d90d..bd2cffa 100644 --- a/lib/async/http/protocol/http2/stream.rb +++ b/lib/async/http/protocol/http2/stream.rb @@ -132,10 +132,10 @@ def process_data(frame) end # Set the body and begin sending it. - def send_body(body, trailer = nil) + def send_body(body, trailer = nil, transient: false) @output = Output.new(self, body, trailer) - @output.start + @output.start(transient: transient) end # Called when the output terminates normally. diff --git a/releases.md b/releases.md index 509a268..bc953c0 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,7 @@ ## Unreleased + - CONNECT tunnels no longer keep the task which opened them, or the reactor, from finishing. - Requests assigned to an HTTP/2 connection which has already closed are refused before being written, allowing them to be retried safely. - HTTP/2 connections which received a graceful `GOAWAY` are removed from availability immediately, but remain in the pool until the server has finished answering the streams it accepted. The connection is closed after its final user releases it, so those requests no longer fail with `EOFError: Connection closed with N active stream(s)!`. diff --git a/test/async/http/proxy.rb b/test/async/http/proxy.rb index e532a0c..680647b 100644 --- a/test/async/http/proxy.rb +++ b/test/async/http/proxy.rb @@ -114,6 +114,26 @@ expect(proxy.client.pool).to be(:empty?) end + + it "does not prevent the connecting task from finishing" do + proxy = Async::HTTP::Proxy.tcp(client, "localhost", 1) + + task = Async do + proxy.connect + end + + peer = task.wait + + # The task has no non-transient children (such as the tunnel's upload task), so it is finished: + expect(task).to be(:finished?) + + # The tunnel remains open in both directions after its connecting task has finished: + peer.write(data) + expect(peer.read(data.bytesize)).to be == data + ensure + peer&.close + proxy&.close + end end with "proxied client" do