Repository: rack/rack (Ruby)

Issue:
Multiple writes to Rack::Response result in incorrect content-length header in Rack 3
## Problem

After upgrading to Rack 3, I found that `Rack::Response` generates an incorrect content-length header when:

- it is constructed with an empty array for the body, and
- `write` is called multiple times

Here is a simple reproduction script:

```ruby
require "rack"

status = 200
headers = {}
response = Rack::Response.new([], status, headers)

# Write 11 total bytes
response.write "hello"
response.write "world"
response.write "!"

# Resulting content-length should be 11, but is 26
response.headers
# => {"content-length"=>"26"}
```

## Underlying cause

When `Rack::Response` is constructed with an empty array, it sets this internal state:

https://github.com/rack/rack/blob/0cd4d40a759f3e68f13c0467091cf836d086e9a1/lib/rack/response.rb#L82

This causes the value of `@length` to accumulate on every call to `Rack::Response#write`:

https://github.com/rack/rack/blob/0cd4d40a759f3e68f13c0467091cf836d086e9a1/lib/rack/response.rb#L317-L323

When `chunked?` is `false`, `@length` is incremented yet again, and then emitted as a content-length header:

https://github.com/rack/rack/blob/0cd4d40a759f3e68f13c0467091cf836d086e9a1/lib/rack/response.rb#L348-L350

The result is that the content-length value is too large, and the error gets larger on every call to `write`.


Discussion:
Without digging into it, it appears that the latter `@length += chunk.bytesize` is incorrect. Do you think removing it is the correct course of action?
I'm actually not sure I understand the intention of the existing code, in particular why `@buffered` is set to `nil` in the constructor and lazily initialized later. Since I may be missing the point of `@buffered`, take what follows with a grain of salt.

The problem I see is that there is a missing assignment of `@buffered` in one of the conditional branches within `buffered_body!`.

Abbreviated, the code currently looks like this:

```ruby
if @buffered.nil?
  if @body.is_a?(Array)
    # ...
    # ⚠️ @buffered is not set in this branch
  elsif @body.respond_to?(:each)
    # ...
    @buffered = true
  else
    @buffered = false
  end
end
```

It seems like the point of this code was to lazily init `@buffered` to true or false, but currently that isn't happening in the first branch. So I think the fix is to add `@buffered = true`, like this:

```diff
--- lib/rack/response.rb
+++ lib/rack/response.rb
@@ -318,12 +318,14 @@ module Rack
           if @body.is_a?(Array)
             # The user supplied body was an array:
             @body = @body.compact
             @body.each do |part|
               @length += part.to_s.bytesize
             end
+
+            @buffered = true
           elsif @body.respond_to?(:each)
             # Turn the user supplied body into a buffered array:
             body = @body
             @body = Array.new
 
             body.each do |part|
```

With this one-line change, I get the correct content-length result when running my reproduction script.
Do you mind making a PR?
OK, I've opened PR #2150 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43526e0a3..692453d39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. For info on
 - `set_cookie_header` utility now supports the `partitioned` cookie attribute. This is required by Chrome in some embedded contexts. ([#2131](https://github.com/rack/rack/pull/2131), [@flavio-b])
 - Remove non-standard status codes 306, 509, & 510 and update descriptions for 413, 422, & 451. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn])
 - Add fallback lookup and deprecation warning for obsolete status symbols. ([#2137](https://github.com/rack/rack/pull/2137), [@wtn])
+- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson])
 
 ## [3.0.8] - 2023-06-14
 
diff --git a/lib/rack/response.rb b/lib/rack/response.rb
index 20a871595..bfc4197c7 100644
--- a/lib/rack/response.rb
+++ b/lib/rack/response.rb
@@ -321,6 +321,8 @@ def buffered_body!
             @body.each do |part|
               @length += part.to_s.bytesize
             end
+
+            @buffered = true
           elsif @body.respond_to?(:each)
             # Turn the user supplied body into a buffered array:
             body = @body


Test patch:
diff --git a/test/spec_response.rb b/test/spec_response.rb
index 6a6957e57..2c53eb8c4 100644
--- a/test/spec_response.rb
+++ b/test/spec_response.rb
@@ -408,7 +408,7 @@ def object_with_each.each
     status.must_equal 404
   end
 
-  it "correctly updates content-type when writing when not initialized with body" do
+  it "correctly updates content-length when writing when initialized without body" do
     r = Rack::Response.new
     r.write('foo')
     r.write('bar')
@@ -419,20 +419,39 @@ def object_with_each.each
     header['content-length'].must_equal '9'
   end
 
-  it "correctly updates content-type when writing when initialized with body" do
+  it "correctly updates content-length when writing when initialized with Array body" do
+    r = Rack::Response.new(["foo"])
+    r.write('bar')
+    r.write('baz')
+    _, header, body = r.finish
+    str = "".dup; body.each { |part| str << part }
+    str.must_equal "foobarbaz"
+    header['content-length'].must_equal '9'
+  end
+
+  it "correctly updates content-length when writing when initialized with String body" do
+    r = Rack::Response.new("foo")
+    r.write('bar')
+    r.write('baz')
+    _, header, body = r.finish
+    str = "".dup; body.each { |part| str << part }
+    str.must_equal "foobarbaz"
+    header['content-length'].must_equal '9'
+  end
+
+  it "correctly updates content-length when writing when initialized with object body that responds to #each" do
     obj = Object.new
     def obj.each
       yield 'foo'
       yield 'bar'
     end
-    ["foobar", ["foo", "bar"], obj].each do
-      r = Rack::Response.new(["foo", "bar"])
-      r.write('baz')
-      _, header, body = r.finish
-      str = "".dup; body.each { |part| str << part }
-      str.must_equal "foobarbaz"
-      header['content-length'].must_equal '9'
-    end
+    r = Rack::Response.new(obj)
+    r.write('baz')
+    r.write('baz')
+    _, header, body = r.finish
+    str = "".dup; body.each { |part| str << part }
+    str.must_equal "foobarbazbaz"
+    header['content-length'].must_equal '12'
   end
 
   it "doesn't return invalid responses" do