Skip to content

HTTP/1: idle streaming response body fiber is not cancelled on client disconnect (no parity with HTTP/2 closed(error) -> Output#stop) #224

@SeanLF

Description

@SeanLF

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions