Repository: rack/rack (Ruby)

Issue:
`Rack::Events` breaks streaming bodies
When `Rack::Events` is in the middleware chain, it causes responses with streaming bodies (where the body responds to `#call` and not `#each`) to fail with a `NoMethodError`.

## Root cause analysis

The `Rack::Events` middleware allows one or more handlers to react to events related to a request. One of these events is `on_send`, which is triggered when the response is sent.

In order to implement `on_send`, `Rack::Events` wraps the body that is passed down the middleware chain in a `Rack::Events::EventedBodyProxy`, a subclass of `BodyProxy` with [a custom `#each` implementation](https://github.com/rack/rack/blob/main/lib/rack/events.rb#L89-L92) that calls the `on_send` method on the handlers and calls `#each` on the proxied body.

According to [the spec](https://github.com/rack/rack/blob/main/SPEC.rdoc#label-The+Body), given a body that implements both `#each` and `#call`, a conforming Rack server must call `#each`. Streaming bodies must therefore only implement `#call`:

> A Body that responds to both `each` and `call` must be treated as an Enumerable Body, not a Streaming Body. If it responds to `each`, you must call `each` and not `call`. If the Body doesn't respond to `each`, then you can assume it responds to `call`.

Due to `EventedBodyProxy`'s custom `#each` instrumentation, the body will always respond to `#each`, and therefore will always be treated as an enumerable body, and not as a streaming body. The Rack server will call `#each` on the `EventedBodyProxy`, which will in turn call `#each` on the body it proxies -- **even when the proxied body does not implement `#each`**.

## Quick "fix"

In `EventedBodyProxy`'s implementation:

```ruby
def respond_to?(method, include_all=false)
  return @body.respond_to?(:each, include_all) if method == :each
  super
end
```

This should cause it to only respond to `#each` if the body it proxies also responds to `#each`, preventing it from breaking streaming bodies' requests.

**This would not be a proper fix, as it does not implement the `on_send` event for streaming bodies.** `EventedBodyProxy` does not implement `#call`, and so it will proxy it directly to the proxied body (as per `BodyProxy`'s `method_missing`, which it inherits from) without ever calling `on_send` on the handlers. A proper fix, I believe, would need to call `on_send` when `close` is called on the stream, probably by wrapping it in some sort of `EventedStreamProxy`.

## Reproduction case

```ruby
# config.ru
require "rack/events"

class StreamingBody
  def call(stream)
    stream.write "Hello world!"
    stream.close
  end
end

# Uncomment this line to trigger the error
# use Rack::Events, []

run ->(env) {
  [200, {}, StreamingBody.new]
}
```

```ruby
# Gemfile
source "https://rubygems.org"

gem "rack", "3.2.0"
gem "rackup"
gem "puma"
```

Run `bundle install`, then `bundle exec rackup`. Uncomment the `use Rack::Events` line to trigger the error. Reproduced locally using Rack 3.2.0, Rackup 2.2.1 and Puma 6.6.0 on Ruby 3.1.1.


Discussion:
The quick fix looks okay to me, but I don't have a strong opinion.
Turns out the proper fix was easier than I expected it to be, see #2375. This was wrong:

> A proper fix, I believe, would need to call `on_send` when `close` is called on the stream, probably by wrapping it in some sort of `EventedStreamProxy`.

The `on_send` event is, paraphrasing the documentation, defined as "the web server has started interacting with the body object". The implementation for `on_send` for `#each` only reports that `#each` has been called -- not, say, about each time that the block passed to `#each` is invoked. So, similarly, I believe it is correct for the implementation for `#call` to only report that `#call` has been called, without intercepting the stream given to `#call` to detect whether it is interacted with.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fef6c6042..f95aa95c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,11 +11,16 @@ All notable changes to this project will be documented in this file. For info on
 ### Added
 
 - Add support for `rack.response_finished` to `Rack::TempfileReaper`. ([#2363](https://github.com/rack/rack/pull/2363), [@skipkayhil](https://github.com/skipkayhil))
+- Add support for streaming bodies when using `Rack::Events`. ([#2375](github.com/rack/rack/pull/2375), [@unflxw](https://github.com/unflxw))
 
 ### Changed
 
 - Raise before exceeding a part limit, not after. ([#2362](https://github.com/rack/rack/pull/2362), [@matthew-puku](https://github.com/matthew-puku))
 
+### Fixed
+
+- Fix an issue where a `NoMethodError` would be raised when using `Rack::Events` with streaming bodies. ([#2375](github.com/rack/rack/pull/2375), [@unflxw](https://github.com/unflxw))
+
 ## [3.2.0] - 2025-07-31
 
 This release continues Rack's evolution toward a cleaner, more efficient foundation while maintaining backward compatibility for most applications. The breaking changes primarily affect deprecated functionality, so most users should experience a smooth upgrade with improved performance and standards compliance.
diff --git a/lib/rack/events.rb b/lib/rack/events.rb
index c7bb201f0..1e73c9d9d 100644
--- a/lib/rack/events.rb
+++ b/lib/rack/events.rb
@@ -29,12 +29,13 @@ module Rack
   #
   # * on_send(request, response)
   #
-  #   The webserver has started iterating over the response body and presumably
-  #   has started sending data over the wire. This method is always called with
-  #   a request object and the response object.  The response object is
-  #   constructed from the rack triple that the application returned.  Changes
-  #   SHOULD NOT be made to the response object as the webserver has already
-  #   started sending data.  Any mutations will likely result in an exception.
+  #   The webserver has started iterating over the response body, or has called
+  #   the streaming body, and presumably has started sending data over the
+  #   wire. This method is always called with a request object and the response
+  #   object. The response object is constructed from the rack triple that the
+  #   application returned.  Changes SHOULD NOT be made to the response object
+  #   as the webserver has already started sending data.  Any mutations will
+  #   likely result in an exception.
   #
   # * on_finish(request, response)
   #
@@ -90,6 +91,20 @@ def each
         @handlers.reverse_each { |handler| handler.on_send request, response }
         super
       end
+
+      def call(stream)
+        @handlers.reverse_each { |handler| handler.on_send request, response }
+        super
+      end
+
+      def respond_to?(method_name, include_all = false)
+        case method_name
+        when :each, :call
+          @body.respond_to?(method_name, include_all)
+        else
+          super
+        end
+      end
     end
 
     class BufferedResponse < Rack::Response::Raw # :nodoc:


Test patch:
diff --git a/test/spec_events.rb b/test/spec_events.rb
index e40d72be2..b85b37e8a 100644
--- a/test/spec_events.rb
+++ b/test/spec_events.rb
@@ -84,6 +84,21 @@ def test_send_is_called_on_each
       ], events
     end
 
+    def test_send_is_called_on_call
+      events = []
+      ret = [200, {}, lambda { |stream| stream.close }]
+      app = lambda { |env| events << [app, :call]; ret }
+      se = EventMiddleware.new events
+      e = Events.new app, [se]
+      triple = e.call({})
+      triple[2].call(StringIO.new)
+      assert_equal [[se, :on_start],
+                    [app, :call],
+                    [se, :on_commit],
+                    [se, :on_send],
+      ], events
+    end
+
     def test_finish_is_called_on_close
       events = []
       ret = [200, {}, []]
@@ -133,5 +148,12 @@ def test_finish_is_called_if_there_is_an_exception
                     [se, :on_finish],
       ], events
     end
+
+    def test_evented_body_proxy_respond_to_each_matches_body
+      app = lambda { |env| [200, {}, lambda { |stream| stream.close }] }
+      e = Events.new app, []
+      triple = e.call({})
+      refute triple[2].respond_to?(:each)
+    end
   end
 end