Summary
On the HTTP/1 server, a streaming Rack 3 response body (body.stream? / body.call(stream)) that is parked idle is never notified when the client disconnects. For example, an SSE connection waiting on a ConditionVariable with no pending write. The scheduled body fiber leaks. It stays parked for the lifetime of the process.
The HTTP/2 server already handles this correctly. I think the HTTP/1 path is missing the equivalent hook. I would like to check that I have read the source correctly before proposing a patch.
Environment
async-http 0.95.1, protocol-http 0.62.2, protocol-http1 0.39.0
falcon 0.55.5, protocol-rack 0.22.1, rack 3.2.6
- Ruby 4.0.5 (2026-05-20) +PRISM, aarch64-linux
This is a Ruby with Bug #21166 available, so IO#close interruption and fiber_interrupt exist. The cancellation mechanism is present. It is just never triggered on HTTP/1 idle peer close.
What I believe the source shows
The HTTP/2 path cancels the body fiber on peer close. In lib/async/http/protocol/http2/output.rb the body runs in a task and there is an explicit stop:
def stop(error)
if task = @task
@task = nil
task.stop(error)
end
end
and lib/async/http/protocol/http2/stream.rb wires the stream's close to it:
def closed(error)
# ...
if output = @output
@output = nil
output.stop(error)
end
end
So a RST_STREAM or connection close reaches closed(error), which calls Output#stop(error), which calls task.stop(error), and the parked body fiber is cancelled.
The HTTP/1 server has no equivalent. In lib/async/http/protocol/http1/server.rb, closed(error) only signals the accept loop:
def closed(error = nil)
super
@ready.signal
end
and the response is written by reading the body to completion, for example in Protocol::HTTP1::Connection#write_chunked_body:
while chunk = body.read
# ...write chunk...
end
For an idle streaming body, body.read ultimately blocks inside Protocol::HTTP::Body::Streamable::Output#read waiting on the Writable output, while the user's block is parked in its own fiber (@fiber = Fiber.schedule { @block.call(@stream) }). There is no signal from the HTTP/1 connection that the peer has gone away while we are parked with no pending read. So neither the write loop nor the body fiber is ever woken, and body.close(error) in the server's ensure is never reached because the loop never returns. protocol-http already exposes the right primitive, Streamable::ResponseBody#close(error) which calls close_output(error). It is just not being called on HTTP/1 idle disconnect.
I checked the context/ guides, protocol-http's streaming.md and message-body.md, and async-http's docs, to make sure I was not missing an intended close path. I could not find one for this case, but I am happy to be pointed at it.
Minimal reproduction
config.ru:
# frozen_string_literal: true
LOG = ->(m) { warn "#{Process.clock_gettime(Process::CLOCK_MONOTONIC).round(3)} #{m}" }
# Idle-parked streaming body: writes one comment, then parks with no further writes.
class ParkBody
def initialize
@mutex = Mutex.new
@cond = ConditionVariable.new
@closed = false
end
def call(stream)
LOG.call "call:enter scheduler=#{Fiber.scheduler.class}"
stream.write(": connected\n\n")
@mutex.synchronize { @cond.wait(@mutex) until @closed } # pure idle park
LOG.call "call:woke-normally"
rescue Exception => e
LOG.call "call:interrupted #{e.class}: #{e.message}"
ensure
LOG.call "call:exit"
stream.close rescue nil
end
end
run ->(_env) { [200, {"content-type" => "text/event-stream"}, ParkBody.new] }
Run it (uses the default config.ru), then connect and disconnect:
falcon serve --bind http://127.0.0.1:9400 -n 1 &
curl -sN http://127.0.0.1:9400/ & CURL=$! # HTTP/1.1 SSE client
sleep 1; kill $CURL # client disconnects while the body is parked
Expected vs actual
Expected: when the client disconnects, the body fiber is interrupted (or its close(error) path runs), so the log shows call:interrupted then call:exit and the fiber is reaped, matching HTTP/2 behaviour.
Actual: only call:enter is ever logged. There is no call:interrupted and no call:exit. The fiber stays parked indefinitely after the peer is gone. Over time, idle SSE and long-poll clients that drop without a clean shutdown accumulate leaked fibers.
For contrast, the same app over HTTP/2 (for example falcon serve with TLS and h2, or an h2 client) does reap the fiber on disconnect, via the closed(error) to Output#stop chain above.
Proposed direction (I would value your read first)
Mirror the HTTP/2 lifecycle on HTTP/1. Give the HTTP/1 server a way to cancel or close the active streaming response body when the connection's closed(error) fires (peer close detected), rather than relying solely on the write loop returning. Concretely, track the in-flight response body in the HTTP/1 Server#each loop and have closed(error) invoke body.close(error) (which for Streamable::ResponseBody flows to close_output(error)), so the parked output read is released and the user block's fiber unwinds. That is the same end state as Output#stop(error).
I want to be careful about two things and would value your steer:
- Detecting HTTP/1 idle peer close at all. Unlike HTTP/2 frames, an idle HTTP/1 peer close gives no application level signal while we are parked with no pending read. This likely needs a read-side watch on the socket concurrent with the parked body (a half-close or EOF detector), which is a real design decision and not a one liner.
- False positives. A naive "readable means closed" check risks misclassifying a half-open connection or pipelined or upgrade data as a disconnect. This is related to the EOF versus data ambiguity discussed around Bug #21918. I would rather get the detection semantics right than ship something that tears down healthy idle streams.
If you would prefer this start as a Discussion given the design surface, I am happy to move it there.
Offer
If the direction is agreeable, I am glad to follow up with a PR: a sus test under test/ reproducing the leak and asserting the body fiber is cancelled on HTTP/1 disconnect, plus the server-side change, run through bundle exec bake test (and bake test:external for the downstreams) on 3.3, 3.4, and 4.0, matching rubocop-socketry style. Just let me know if I have read the HTTP/1 path correctly and whether you would rather own the detection design yourself.
Thanks for async-http and the whole socketry stack. The HTTP/2 closed(error) to Output#stop design is the precedent that made this easy to pin down.
Summary
On the HTTP/1 server, a streaming Rack 3 response body (
body.stream?/body.call(stream)) that is parked idle is never notified when the client disconnects. For example, an SSE connection waiting on aConditionVariablewith no pending write. The scheduled body fiber leaks. It stays parked for the lifetime of the process.The HTTP/2 server already handles this correctly. I think the HTTP/1 path is missing the equivalent hook. I would like to check that I have read the source correctly before proposing a patch.
Environment
async-http0.95.1,protocol-http0.62.2,protocol-http10.39.0falcon0.55.5,protocol-rack0.22.1,rack3.2.6This is a Ruby with Bug #21166 available, so
IO#closeinterruption andfiber_interruptexist. The cancellation mechanism is present. It is just never triggered on HTTP/1 idle peer close.What I believe the source shows
The HTTP/2 path cancels the body fiber on peer close. In
lib/async/http/protocol/http2/output.rbthe body runs in a task and there is an explicit stop:and
lib/async/http/protocol/http2/stream.rbwires the stream's close to it:So a RST_STREAM or connection close reaches
closed(error), which callsOutput#stop(error), which callstask.stop(error), and the parked body fiber is cancelled.The HTTP/1 server has no equivalent. In
lib/async/http/protocol/http1/server.rb,closed(error)only signals the accept loop:and the response is written by reading the body to completion, for example in
Protocol::HTTP1::Connection#write_chunked_body:For an idle streaming body,
body.readultimately blocks insideProtocol::HTTP::Body::Streamable::Output#readwaiting on theWritableoutput, while the user's block is parked in its own fiber (@fiber = Fiber.schedule { @block.call(@stream) }). There is no signal from the HTTP/1 connection that the peer has gone away while we are parked with no pending read. So neither the write loop nor the body fiber is ever woken, andbody.close(error)in the server'sensureis never reached because the loop never returns. protocol-http already exposes the right primitive,Streamable::ResponseBody#close(error)which callsclose_output(error). It is just not being called on HTTP/1 idle disconnect.I checked the
context/guides,protocol-http'sstreaming.mdandmessage-body.md, andasync-http's docs, to make sure I was not missing an intended close path. I could not find one for this case, but I am happy to be pointed at it.Minimal reproduction
config.ru:Run it (uses the default
config.ru), then connect and disconnect:Expected vs actual
Expected: when the client disconnects, the body fiber is interrupted (or its
close(error)path runs), so the log showscall:interruptedthencall:exitand the fiber is reaped, matching HTTP/2 behaviour.Actual: only
call:enteris ever logged. There is nocall:interruptedand nocall:exit. The fiber stays parked indefinitely after the peer is gone. Over time, idle SSE and long-poll clients that drop without a clean shutdown accumulate leaked fibers.For contrast, the same app over HTTP/2 (for example
falcon servewith TLS and h2, or an h2 client) does reap the fiber on disconnect, via theclosed(error)toOutput#stopchain above.Proposed direction (I would value your read first)
Mirror the HTTP/2 lifecycle on HTTP/1. Give the HTTP/1 server a way to cancel or close the active streaming response body when the connection's
closed(error)fires (peer close detected), rather than relying solely on the write loop returning. Concretely, track the in-flight response body in the HTTP/1Server#eachloop and haveclosed(error)invokebody.close(error)(which forStreamable::ResponseBodyflows toclose_output(error)), so the parked output read is released and the user block's fiber unwinds. That is the same end state asOutput#stop(error).I want to be careful about two things and would value your steer:
If you would prefer this start as a Discussion given the design surface, I am happy to move it there.
Offer
If the direction is agreeable, I am glad to follow up with a PR: a
sustest undertest/reproducing the leak and asserting the body fiber is cancelled on HTTP/1 disconnect, plus the server-side change, run throughbundle exec bake test(andbake test:externalfor the downstreams) on 3.3, 3.4, and 4.0, matchingrubocop-socketrystyle. Just let me know if I have read the HTTP/1 path correctly and whether you would rather own the detection design yourself.Thanks for async-http and the whole socketry stack. The HTTP/2
closed(error)toOutput#stopdesign is the precedent that made this easy to pin down.