Skip to content

Give point-in-time copies their own stackable and rescue-handler stores - #2841

Open
ericproulx wants to merge 1 commit into
masterfrom
fix/stackable-copy-aliasing
Open

Give point-in-time copies their own stackable and rescue-handler stores#2841
ericproulx wants to merge 1 commit into
masterfrom
fix/stackable-copy-aliasing

Conversation

@ericproulx

@ericproulx ericproulx commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

InheritableSetting#point_in_time_copy copied a scope's stackable store and its rescue-handler maps shallowly, so the nested Arrays and Hashes stayed shared with the source. A registration made after an endpoint was defined therefore still reached that endpoint — but only when the key already held a registration at the time the copy was taken, since otherwise the writer allocated a fresh store on the source alone.

The outcome depended on something the API never expressed:

use Middleware1
get('/x') { }
use Middleware2          # applied to GET /x

get('/x') { }
use Middleware2          # did NOT apply to GET /x
rescue_from(ArgumentError) { }
get('/x') { raise Boom }
rescue_from(Boom) { }    # rescued GET /x

get('/x') { raise Boom }
rescue_from(Boom) { }    # did NOT rescue GET /x
before after
use MW1 · route · use MW2 MW1, MW2 MW1
route · use MW2
rescue_from A · route · rescue_from B B rescues the route raises
route · rescue_from B raises raises

Two APIs stating the same thing, behaving differently. Not a deliberate "late registration" feature either — helpers defined after an endpoint already did not leak, because they resolve down a different path.

Approach

Dup the nested stores as well as the Hash holding them. A copy is a point in time: what the source registers afterwards must not reach it.

Inheritance is untouched — it resolves by walking #parent, so a scope still sees values an enclosing scope gains later. That is what the existing decouples namespace stackable values spec actually exercised: its value lives on the parent, so nothing was ever shared and it passed with or without this change. The new specs register on the scope itself before taking the copy, which is the shape that leaked.

Backward compatibility

UPGRADING entry added. An API that declares use / helpers / before / rescue_from below its routes and relies on it applying to them will now see it silently not run. That arrangement only ever worked when an earlier registration for the same key happened to seed the store, so it was never dependable; the fix is to move the registration above the routes it should cover, which is where the documentation has always placed it.

Perf

Definition-time only. 300 endpoints under scopes carrying 8 middleware, 4 helpers and 6 filters:

allocations boot
before 463,062 0.127s
after 464,862 (+0.4%) 0.128s

Test plan

  • 5 new unit examples in inheritable_setting_spec.rb (stackable and rescue-handler leakage in both directions, sibling independence, base-only handlers) and 4 integration examples in api_spec.rb covering use and rescue_from in both the seeded and unseeded arrangements; verified the behavioural ones fail without the lib/ change.
  • Full RSpec suite passes locally (2552 examples, 0 failures).
  • RuboCop clean.
  • CI green.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the fix/stackable-copy-aliasing branch from 0ef3dc2 to eb7cf0d Compare August 1, 2026 09:04
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock August 1, 2026 09:42
InheritableSetting#point_in_time_copy copied a scope's stackable store and
its rescue-handler maps shallowly, so the nested Arrays and Hashes stayed
shared with the source. A registration made after an endpoint was defined
therefore still reached that endpoint -- but only when the key already held a
registration at the time the copy was taken, since otherwise the writer
allocated a fresh store on the source alone.

That made the outcome depend on something the API never expressed:

    use Middleware1
    get('/x') { }
    use Middleware2      # applied to GET /x

    get('/x') { }
    use Middleware2      # did NOT apply to GET /x

and the same for rescue_from:

    rescue_from ArgumentError { }
    get('/x') { raise Boom }
    rescue_from Boom { }     # rescued GET /x

    get('/x') { raise Boom }
    rescue_from Boom { }     # did NOT rescue GET /x

Two APIs stating the same thing, behaving differently. Not a deliberate
"late registration" feature -- helpers defined after an endpoint already did
not leak, because they resolve down a different path.

Dup the nested stores as well as the Hash holding them. A copy is a point in
time: what the source registers afterwards must not reach it. Inheritance is
untouched -- it resolves by walking #parent, so a scope still sees values an
enclosing scope gains later, which is what the existing "decouples namespace
stackable values" spec actually exercised (its value lives on the parent, so
nothing was ever shared and it passed either way).

Boot cost is negligible: 300 endpoints under scopes carrying middleware,
helpers and filters allocate 1800 more objects (+0.4%) with no measurable
change in time, all at definition time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix/stackable-copy-aliasing branch from eb7cf0d to 2599501 Compare August 1, 2026 09:56
@ericproulx ericproulx changed the title Give point-in-time copies their own stackable arrays Give point-in-time copies their own stackable and rescue-handler stores Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant