Should `pass` reset all response state (headers + status), not just content-type?
Summary
Should pass reset all response state a route touched (headers + status), rather than only the content-type? Today Sinatra resets just the content-type between route attempts, so a route that sets any other response state and then passes leaks it into the next handler.
This came out of #2175 (Mustermann 4.0 / Mustermann::Set routing). Filing it separately because it's a behaviour/contract change, not a bugfix.
Current behaviour
In route!, the only response state reset between route attempts is the content-type:
response.delete_header('content-type') unless @pinned_responseAnything else a route sets before it passes survives:
class App < Sinatra::Base
get('/a') do
headers['X-Foo'] = 'bar'
status 201
pass { 'fallback' }
end
end
# GET /a => X-Foo: bar, status 201 (both leak from the abandoned route into the pass block)Why this surfaced now
#2175 fixes a narrower instance of the same class of bug: the Mustermann::Set path iterates only matching routes, so the per-iteration content-type reset was skipped before the trailing pass-block fallthrough, and content_type :json; pass { ... } leaked application/json. While fixing it I found main also leaks content-type for non-GET verbs — its single-match reset only happens incidentally for GET, because iterating the built-in GET /__sinatra__/:image.png route runs the reset; there's no equivalent for POST/PUT/etc. #2175 makes the content-type reset uniform across verbs.
That raises the general question: content-type is really just one special case of "a passed route shouldn't leave a trace." Should pass roll the whole response back to its pre-route baseline?
Proof of concept
A gated rollback works and is cheap:
- Snapshot
response.headers+response.statusbefore conditions/block run. - Restore only on the real
:passthrow — not when the block returns normally (before-filters run through the sameprocess_routeand must keep the content-type they set; naive rollback breaksprovidesnegotiation, superclass-filter content types, and error-handler content types).
With that gating, the full core + routing suites pass. Measured cost (vs the current fix, benchmark-ips interleaved + memory_profiler):
- +160 bytes and +1 object per request (a shallow
headers.dup), 0 retained (no leak). - No measurable throughput change across the handle and pass paths.
Trade-off / why not just do it
It's a contract change: a route that today sets a header (or status) and then passes would stop leaking it. That's almost certainly the right behaviour, but any code relying on the current leak — undocumented as it is — would change. So it likely belongs in a major (5.0), same bucket as the Mustermann 4.0 bump that requires Ruby 3.3+.
Questions for maintainers
- Is full response-state rollback on
passthe desired behaviour, or is content-type-only reset the intended contract? - If desirable, is 5.0 the right place to land it?
Happy to open a PR with the gated-rollback PoC + tests if there's appetite. Related: #2175, #2163.
Source: sinatra/sinatra