A design that passes a quick manual test and raises
AbstractController::DoubleRenderError on every single request once it meets a
real Rails app — and the one-line Rails internals detail that explains why.
Say you want a controller action's own HTML, as a string, so you can convert it to a PDF. The
action hasn't run yet when a before_action fires, so a natural-looking approach is:
build a second instance of the same controller, copy the request/response/params onto it, call
the action method on that copy directly, copy its instance variables back onto the real
controller, then render_to_string the view using those variables:
def html_to_pdf
shadow = self.class.new
shadow.request = request
shadow.response = response
shadow.params = params
shadow.send(action_name)
shadow.instance_variables.each do |ivar|
instance_variable_set(ivar, shadow.instance_variable_get(ivar))
end
html = render_to_string(template: "#{controller_name}/#{action_name}")
send_data convert_to_pdf(html), disposition: "attachment"
end
Called directly against a bare ActionController::Base instance in an isolated
script, this works. Run it as a real before_action against a real Rails dispatch,
and send_data raises AbstractController::DoubleRenderError —
every single time — before it ever gets a chance to send anything.
response= is not a plain setter
ActionController::Metal#response= is documented, in Rails' own source, as:
# Assign the response and mark it as committed. No further processing will
# occur.
def response=(response)
set_response!(response)
# Force `performed?` to return true:
@_response_body = true
end
It isn't a generic attribute writer. Assigning a response is Rails' own signal that this
controller instance should be treated as already-rendered — @_response_body = true
is set unconditionally, the instant you assign it. shadow.response = response in
the design above does exactly that to the shadow instance. Harmless on its own — the
shadow is thrown away — except for the very next step:
shadow.instance_variables.each do |ivar|
instance_variable_set(ivar, shadow.instance_variable_get(ivar))
end
shadow.instance_variables includes every ivar Rails itself uses internally, not
just the ones the action set — @_response_body among them, now true.
Copying it back onto the real controller sets its @_response_body to
true too. performed? (response_body || response.committed?)
now returns true on the real controller, before send_data ever runs. And
ActionController::Rendering#render — which send_data calls
internally — starts with exactly one guard:
def render(*args)
raise ::AbstractController::DoubleRenderError if response_body
super
end
response_body is already truthy. The error fires immediately, on every request,
with a message about calling render/redirect twice — even though,
from the action's own code, it looks like render was never called at all.
Call the method directly against a bare controller instance — no real request dispatch, no
callback chain — and nothing ever sets performed? in the first place, because
nothing in that harness exercises Rails' actual callback/render machinery closely enough to
trip the guard. The bug is specifically about what response= does inside a real
ActionController::Metal object mid-dispatch; a script that merely calls the method
in isolation never puts the object in the state where that matters. It takes a real
ActionDispatch request — an integration test, or an actual HTTP request — to
surface it at all.
There's a second, quieter problem with the same design, visible without needing
DoubleRenderError at all: the shadow controller never ran any of the real
controller's other callbacks. A before_action :set_record declared
elsewhere in the chain never touches the shadow — so whatever instance variable it would have
set is simply absent when the view renders, silently, with no error to point at the cause.
around_action, not before_action
The actual problem being solved is sequencing: "act after the action runs" from a hook that, as
a before_action, runs before it. An around_action solves
that directly — yield runs the rest of the callback chain and the real action, on
the real controller instance, in its normal place, with every other callback and instance
variable exactly as it would be for an ordinary request:
def html_to_pdf
return yield unless request.format == :pdf
original_format = request.format
request.format = :html # so Rails' own implicit render resolves the .html template,
begin # not a .pdf one that doesn't exist
yield
ensure
request.format = original_format
end
html = render_to_string(template: "#{controller_name}/#{action_name}")
pdf_bytes = convert_to_pdf(html)
send_file_headers!(disposition: "attachment", filename: "export.pdf")
self.status = 200
self.response_body = pdf_bytes # not send_data/render — see below
end
Forcing request.format to :html around the yield matters
on its own: without it, Rails' implicit-render machinery tries to resolve a template for
whatever format the request actually came in as — .pdf — finds none, and raises
ActionController::UnknownFormat before the action's own render ever gets a chance
to happen. That discarded render is intentional; the real HTML capture is the explicit
render_to_string call afterward, once format is restored and every instance
variable the real chain set is in place.
One more wrinkle: the discarded yield render already set
performed? to true on the real controller (this time correctly — it rendered the
real template), and self.response_body = nil does not clear it back
(Rails' own response_body= only clears the Rack response body in the falsy branch,
not the internal @_response_body ivar performed? actually reads) —
so a second call to render/send_data would still raise
DoubleRenderError, correctly this time. The way out is the same either way: don't
call render/send_data again at all. Setting
self.response_body = directly (as above) sets the Rack response body and the
ivar together, without going through render's guard a second time.
BrowserPool fits in.