Description
In the response.rb#write_body
method, there's this line (in the if / elseif block):
elsif @body.respond_to?(:body_parts)
This was added by this commit:
7b87c78
... with the commit message:
with Rails 3.x ... thus check for body_parts on the body object
... we use raw data streaming (via passing an enumerator to reponse_body) with jruby-rack & Jetty. The streaming worked fine with Rails 3. About a year ago we upgraded to Rails 4. This introduced a subtle bug in that, the streaming no longer worked, but it did not fail outright, what happened is that it fell back to behavior where it would buffer the entire response body in memory, then send it to the client when done.
In many cases this was ok, however, we're streaming the results of an SQL query to the client, so it could result in arbitrarily huge files. In some cases, it was essentially behaving as a massive memory leak which could crash the application server.
I dug into it and found eventually that the line @body.respond_to?(:body_parts)
blocks the write_body method. body_parts method in Rails 4 looks like so:
def body_parts
parts = []
@stream.each { |x| parts << x }
parts
end
I can only assume that the body_parts method hangs out there till the entire response body has been streamed, blocking the flushing of the response. If I comment out that block, ie, make it look like this:
def write_body(response)
body = nil
begin
if @body.respond_to?(:call) && ! @body.respond_to?(:each)
@body.call response.getOutputStream
elsif @body.respond_to?(:to_path) # send_file
send_file @body.to_path, response
elsif @body.respond_to?(:to_channel) &&
! object_polluted_with_anyio?(@body, :to_channel)
body = @body.to_channel # so that we close the channel
transfer_channel body, response.getOutputStream
elsif @body.respond_to?(:to_inputstream) &&
! object_polluted_with_anyio?(@body, :to_inputstream)
body = @body.to_inputstream # so that we close the stream
body = Channels.newChannel(body) # closing the channel closes the stream
transfer_channel body, response.getOutputStream
# elsif @body.respond_to?(:body_parts) && @body.body_parts.respond_to?(:to_channel) &&
# ! object_polluted_with_anyio?(@body.body_parts, :to_channel)
# # ActionDispatch::Response "raw" body access in case it's a File
# body = @body.body_parts.to_channel # so that we close the channel
# transfer_channel body, response.getOutputStream
else
if dechunk?
write_body_dechunked response.getOutputStream
else
output_stream = response.getOutputStream
# 1.8 has a String#each method but 1.9 does not :
method = @body.respond_to?(:each_line) ? :each_line : :each
@body.send(method) do |line|
output_stream.write(line.to_java_bytes)
output_stream.flush if flush?
end
end
end
rescue LocalJumpError
# HACK: deal with objects that don't comply with Rack specification
@body = [ @body.to_s ]
retry
rescue java.io.IOException => e
raise e if ! client_abort_exception?(e) || ! self.class.swallow_client_abort?
ensure
@body.close if @body.respond_to?(:close)
body && body.close rescue nil
end
end
... as I said if I comment out that part, the streaming starts working again.
The commit message mentioned this fix being for Rails 3, so I am hoping this part is not necessary for Rails 4. I will report back if our QA process finds any issues with this change.