Repository: rack/rack (Ruby)

Issue:
Mounted Grape App -> Rack::Lint::LintError: Response body is already closed
Hi 👋,

I'm working on a feature to add `linting` in [Grape](https://github.com/ruby-grape/grape) by adding `Rack::Lint` in the stack. It works well but I'm encountering a `Rack::Lint::LintError: Response body is already closed` when a Grape API is mounted in a Rails app.

When calling it, Rails's middlewares stack will deal with a `Rack::Lint::Wrapper` body instead of the real one. This ain't the issue but if we look at `Rack::Etag`, it might call [body.to_ary](https://github.com/rack/rack/blob/4f15e7b814922af79605be4b02c5b7c3044ba206/lib/rack/etag.rb#L32). Indeed, the `to_ary` function will be the [one](https://github.com/rack/rack/blob/4f15e7b814922af79605be4b02c5b7c3044ba206/lib/rack/lint.rb#L906) in `Rack::Lint::Wrapper` and it will `close` the body. Unfortunately, when calling `each` on it, it will raise a `Rack::Lint::LintError` even though our original linting was valid.

I've looked into `Rack::Lint` and somehow, it was [setting](https://github.com/rack/rack/blob/df6c47357f6c6bec2d585f45f417285d813d9b3a/lib/rack/lint.rb#L734) `@closed = false` when calling `each` in Rack 2, but this is not the case anymore.

I've open an [issue](https://github.com/ruby-grape/grape/issues/2559) on our side if you want to take a look at my example.

Thanks!


Discussion:
Thanks for your report. Wow, this bug has existed for a long time. Introduced in https://github.com/rack/rack/pull/1887

Looks like we do all the right things except we don't return the right body. Whoops.





Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c9dc88f3..2e5e0c918 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,10 @@ All notable changes to this project will be documented in this file. For info on
 - Fix the specification for `SERVER_PORT` which was incorrectly documented as required to be an `Integer` if present - it must be a `String` containing digits only. ([#2296](https://github.com/rack/rack/pull/2296), [@ioquatix])
 - `SERVER_NAME` and `HTTP_HOST` are now more strictly validated according to the relevant specifications. ([#2298](https://github.com/rack/rack/pull/2298), [@ioquatix])
 
+## [3.1.13] - 2025-04-13
+
+- Ensure `Rack::ETag` correctly updates response body. ([#2324](https://github.com/rack/rack/pull/2324), [@ioquatix])
+
 ## [3.1.12] - 2025-03-11
 
 ### Security
@@ -166,6 +170,10 @@ Rack v3.1 is primarily a maintenance release that removes features deprecated in
 
 - In `Rack::Files`, ignore the `Range` header if served file is 0 bytes. ([#2159](https://github.com/rack/rack/pull/2159), [@zarqman])
 
+## [3.0.15] - 2025-04-13
+
+- Ensure `Rack::ETag` correctly updates response body. ([#2324](https://github.com/rack/rack/pull/2324), [@ioquatix])
+
 ## [3.0.14] - 2025-03-11
 
 ### Security
diff --git a/lib/rack/etag.rb b/lib/rack/etag.rb
index fa78b472f..1841beb37 100644
--- a/lib/rack/etag.rb
+++ b/lib/rack/etag.rb
@@ -32,6 +32,9 @@ def call(env)
         body = body.to_ary
         digest = digest_body(body)
         headers[ETAG_STRING] = %(W/"#{digest}") if digest
+
+        # Body was modified, so we need to re-assign it:
+        response[2] = body
       end
 
       unless headers[CACHE_CONTROL]


Test patch:
diff --git a/test/spec_etag.rb b/test/spec_etag.rb
index e1670dfa5..75eb399cf 100644
--- a/test/spec_etag.rb
+++ b/test/spec_etag.rb
@@ -28,6 +28,16 @@ def sendfile_body
     response[1]['etag'].must_equal "W/\"dffd6021bb2bd5b0af676290809ec3a5\""
   end
 
+  it "returns a valid response body when using a linted app" do
+    app = lambda { |env| [200, { 'content-type' => 'text/plain' }, ["Hello, World!"]] }
+    response = etag(Rack::Lint.new(app)).call(request)
+    response[1]['etag'].must_equal "W/\"dffd6021bb2bd5b0af676290809ec3a5\""
+
+    response[2].each do |chunk|
+      chunk.must_equal "Hello, World!"
+    end
+  end
+
   it "set etag if none is set if status is 201" do
     app = lambda { |env| [201, { 'content-type' => 'text/plain' }, ["Hello, World!"]] }
     response = etag(app).call(request)