#2177·sinatra

Should `pass` reset all response state (headers + status), not just content-type?

Author: SeanLFCreated Jul 6, 2026Updated Jul 21, 2026

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:

ruby
response.delete_header('content-type') unless @pinned_response

Anything else a route sets before it passes survives:

ruby
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.status before conditions/block run.
  • Restore only on the real :pass throw — not when the block returns normally (before-filters run through the same process_route and must keep the content-type they set; naive rollback breaks provides negotiation, 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

  1. Is full response-state rollback on pass the desired behaviour, or is content-type-only reset the intended contract?
  2. 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.