Skip to content

Commit a7c8a20

Browse files
authored
Merge pull request rails#51508 from ioquatix/actiondispatch-response-body-to_ary
Don't buffer things that shouldn't be buffered.
2 parents bfc45da + a3388f7 commit a7c8a20

File tree

6 files changed

+127
-30
lines changed

6 files changed

+127
-30
lines changed

actionpack/lib/action_controller/metal/live.rb

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,6 @@ def initialize(response)
171171
@ignore_disconnect = false
172172
end
173173

174-
# ActionDispatch::Response delegates #to_ary to the internal
175-
# ActionDispatch::Response::Buffer, defining #to_ary is an indicator that the
176-
# response body can be buffered and/or cached by Rack middlewares, this is not
177-
# the case for Live responses so we undefine it for this Buffer subclass.
178-
undef_method :to_ary
179-
180174
def write(string)
181175
unless @response.committed?
182176
@response.headers["Cache-Control"] ||= "no-cache"

actionpack/lib/action_dispatch/http/response.rb

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,22 @@ def initialize(response, buf)
105105
@str_body = nil
106106
end
107107

108+
BODY_METHODS = { to_ary: true }
109+
110+
def respond_to?(method, include_private = false)
111+
if BODY_METHODS.key?(method)
112+
@buf.respond_to?(method)
113+
else
114+
super
115+
end
116+
end
117+
108118
def to_ary
109-
@buf.respond_to?(:to_ary) ?
110-
@buf.to_ary :
111-
@buf.each
119+
if @str_body
120+
[body]
121+
else
122+
@buf = @buf.to_ary
123+
end
112124
end
113125

114126
def body
@@ -328,7 +340,13 @@ def message
328340
# Returns the content of the response as a string. This contains the contents of
329341
# any calls to `render`.
330342
def body
331-
@stream.body
343+
if @stream.respond_to?(:to_ary)
344+
@stream.to_ary.join
345+
elsif @stream.respond_to?(:body)
346+
@stream.body
347+
else
348+
@stream
349+
end
332350
end
333351

334352
def write(string)
@@ -337,11 +355,16 @@ def write(string)
337355

338356
# Allows you to manually set or override the response body.
339357
def body=(body)
340-
if body.respond_to?(:to_path)
341-
@stream = body
342-
else
343-
synchronize do
344-
@stream = build_buffer self, munge_body_object(body)
358+
# Prevent ActionController::Metal::Live::Response from committing the response prematurely.
359+
synchronize do
360+
if body.respond_to?(:to_str)
361+
@stream = build_buffer(self, [body])
362+
elsif body.respond_to?(:to_path)
363+
@stream = body
364+
elsif body.respond_to?(:to_ary)
365+
@stream = build_buffer(self, body)
366+
else
367+
@stream = body
345368
end
346369
end
347370
end
@@ -482,10 +505,6 @@ def build_buffer(response, body)
482505
Buffer.new response, body
483506
end
484507

485-
def munge_body_object(body)
486-
body.respond_to?(:each) ? body : [body]
487-
end
488-
489508
def assign_default_content_type_and_charset!
490509
return if media_type
491510

@@ -499,6 +518,8 @@ def initialize(response)
499518
@response = response
500519
end
501520

521+
attr :response
522+
502523
def close
503524
# Rack "close" maps to Response#abort, and **not** Response#close (which is used
504525
# when the controller's finished writing)

actionpack/test/controller/new_base/render_streaming_test.rb

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,44 +45,78 @@ def explicit_cache
4545

4646
class StreamingTest < Rack::TestCase
4747
test "rendering with streaming enabled at the class level" do
48+
env = Rack::MockRequest.env_for("/render_streaming/basic/hello_world")
49+
status, headers, body = app.call(env)
50+
assert_streaming!(status, headers, body)
51+
assert_chunks ["Hello world", ", I'm here!"], body
52+
4853
get "/render_streaming/basic/hello_world"
4954
assert_body "Hello world, I'm here!"
50-
assert_streaming!
5155
end
5256

5357
test "rendering with streaming given to render" do
58+
env = Rack::MockRequest.env_for("/render_streaming/basic/explicit")
59+
status, headers, body = app.call(env)
60+
assert_streaming!(status, headers, body)
61+
assert_chunks ["Hello world", ", I'm here!"], body
62+
5463
get "/render_streaming/basic/explicit"
5564
assert_body "Hello world, I'm here!"
56-
assert_streaming!
65+
assert_cache_control!
5766
end
5867

5968
test "rendering with streaming do not override explicit cache control given to render" do
69+
env = Rack::MockRequest.env_for("/render_streaming/basic/explicit_cache")
70+
status, headers, body = app.call(env)
71+
assert_streaming!(status, headers, body)
72+
assert_chunks ["Hello world", ", I'm here!"], body
73+
6074
get "/render_streaming/basic/explicit_cache"
6175
assert_body "Hello world, I'm here!"
62-
assert_streaming! "private"
76+
assert_cache_control! "private"
6377
end
6478

6579
test "rendering with streaming no layout" do
80+
env = Rack::MockRequest.env_for("/render_streaming/basic/no_layout")
81+
status, headers, body = app.call(env)
82+
assert_streaming!(status, headers, body)
83+
assert_chunks ["Hello world"], body
84+
6685
get "/render_streaming/basic/no_layout"
6786
assert_body "Hello world"
68-
assert_streaming!
87+
assert_cache_control!
6988
end
7089

7190
test "skip rendering with streaming at render level" do
91+
env = Rack::MockRequest.env_for("/render_streaming/basic/skip")
92+
status, _, body = app.call(env)
93+
assert_equal 200, status
94+
assert_chunks ["Hello world, I'm here!"], body
95+
7296
get "/render_streaming/basic/skip"
7397
assert_body "Hello world, I'm here!"
7498
end
7599

76100
test "rendering with layout exception" do
101+
env = Rack::MockRequest.env_for("/render_streaming/basic/layout_exception")
102+
status, headers, body = app.call(env)
103+
assert_streaming!(status, headers, body)
104+
assert_chunks ["<body class=\"", "\"><script>window.location = \"/500.html\"</script></html>"], body
105+
77106
get "/render_streaming/basic/layout_exception"
78107
assert_body "<body class=\"\"><script>window.location = \"/500.html\"</script></html>"
79-
assert_streaming!
108+
assert_cache_control!
80109
end
81110

82111
test "rendering with template exception" do
112+
env = Rack::MockRequest.env_for("/render_streaming/basic/template_exception")
113+
status, headers, body = app.call(env)
114+
assert_streaming!(status, headers, body)
115+
assert_chunks ["\"><script>window.location = \"/500.html\"</script></html>"], body
116+
83117
get "/render_streaming/basic/template_exception"
84118
assert_body "\"><script>window.location = \"/500.html\"</script></html>"
85-
assert_streaming!
119+
assert_cache_control!
86120
end
87121

88122
test "rendering with template exception logs the exception" do
@@ -98,9 +132,28 @@ class StreamingTest < Rack::TestCase
98132
end
99133
end
100134

101-
def assert_streaming!(cache = "no-cache")
102-
assert_status 200
103-
assert_equal cache, headers["cache-control"]
135+
def assert_streaming!(status, headers, body)
136+
assert_equal 200, status
137+
138+
# It should not have a content length
139+
assert_nil headers["content-length"]
140+
141+
# The body should not respond to `#to_ary`
142+
assert_not_respond_to body, :to_ary
143+
end
144+
145+
def assert_cache_control!(value = "no-cache", headers: self.headers)
146+
assert_equal value, headers["cache-control"]
147+
end
148+
149+
def assert_chunks(expected, body)
150+
index = 0
151+
body.each do |chunk|
152+
assert_equal expected[index], chunk
153+
index += 1
154+
end
155+
156+
assert_equal expected.size, index
104157
end
105158
end
106159
end

actionpack/test/dispatch/response_test.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ def test_write_after_close
4242
def test_each_isnt_called_if_str_body_is_written
4343
# Controller writes and reads response body
4444
each_counter = 0
45-
@response.body = Object.new.tap { |o| o.singleton_class.define_method(:each) { |&block| each_counter += 1; block.call "foo" } }
45+
46+
@response.body = Object.new.tap do |object|
47+
object.singleton_class.define_method(:each) { |&block| each_counter += 1; block.call "foo" }
48+
object.singleton_class.define_method(:to_ary) { enum_for(:each).to_a }
49+
end
50+
4651
@response["X-Foo"] = @response.body
4752

4853
assert_equal 1, each_counter, "#each was not called once"
@@ -647,4 +652,17 @@ class ResponseIntegrationTest < ActionDispatch::IntegrationTest
647652
assert_equal("utf-8", @response.charset)
648653
assert_equal("012345678910", @response.body)
649654
end
655+
656+
test "response does not buffer enumerator body" do
657+
# This is an enumerable body, and it should not be buffered:
658+
body = Enumerator.new do |enumerator|
659+
enumerator << "Hello World"
660+
end
661+
662+
# The response created here should not attempt to buffer the body:
663+
response = ActionDispatch::Response.new(200, { "content-type" => "text/plain" }, body)
664+
665+
# The body should be the same enumerator object, i.e. it should be passed through unchanged:
666+
assert_equal body, response.body
667+
end
650668
end

actionview/lib/action_view/renderer/streaming_template_renderer.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ def each(&block)
2525
self
2626
end
2727

28+
# Returns the complete body as a string.
29+
def body
30+
buffer = String.new
31+
each { |part| buffer << part }
32+
buffer
33+
end
34+
2835
private
2936
# This is the same logging logic as in ShowExceptions middleware.
3037
def log_error(exception)
@@ -42,7 +49,7 @@ def log_error(exception)
4249
# object that responds to each. This object is initialized with a block
4350
# that knows how to render the template.
4451
def render_template(view, template, layout_name = nil, locals = {}) # :nodoc:
45-
return [super.body] unless layout_name && template.supports_streaming?
52+
return [super.body] unless template.supports_streaming?
4653

4754
locals ||= {}
4855
layout = find_layout(layout_name, locals.keys, [formats.first])

actionview/lib/action_view/template/raw_file.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ def identifier
2020
def render(*args)
2121
::File.read(@filename)
2222
end
23+
24+
def supports_streaming?
25+
false
26+
end
2327
end
2428
end
2529
end

0 commit comments

Comments
 (0)