From 847a0197ae59b2b18ca5a6eed76b50aa153090f9 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Fri, 10 Jul 2026 14:50:37 +0200 Subject: [PATCH] Build helper routes from a settings copy, not shared mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_head_not_allowed_methods_and_options_methods used without_root_prefix_and_versioning to delete the root-prefix/versioning keys from the shared class-level namespace_inheritable and restore them in an ensure. Compilation runs when a fresh API instance is built, which can happen at runtime (mount, helpers, or route definitions call change!) while another instance is still serving. A request handled during that window could observe the temporarily-missing keys — e.g. Instance#cascade? reads namespace_inheritable[:version_options] and would fall back to true. Read the settings the helper routes need from a local copy (namespace_inheritable.to_hash minus the versioning keys) and pass it into collect_route_config_per_pattern instead. Nothing in that path reads the stripped keys, so behavior is unchanged, and the shared settings object is no longer mutated during compilation. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + lib/grape/api/instance.rb | 27 ++++++++++----------------- spec/grape/api_spec.rb | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e383979..fc4b0f5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ #### Fixes * [#2767](https://github.com/ruby-grape/grape/pull/2767): Update rubocop to 1.88.0 and rubocop-rspec to 3.10.2 - [@ericproulx](https://github.com/ericproulx). +* [#2791](https://github.com/ruby-grape/grape/pull/2791): Build HEAD/OPTIONS/405 helper routes from a copy of the inheritable settings instead of temporarily mutating the shared class-level settings during compilation - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.2 (2026-07-05) diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index fcf012ccc..5e9e5615d 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -150,14 +150,19 @@ def add_head_not_allowed_methods_and_options_methods # contain already versioning information when using path versioning. all_routes = self.class.endpoints.flat_map(&:routes) - # Disable versioning so adding a route won't prepend versioning - # informations again. - without_root_prefix_and_versioning { collect_route_config_per_pattern(all_routes) } + # Read the settings the helper routes need from a *copy* with the + # root-prefix/versioning keys stripped, so adding these routes won't + # prepend versioning information again. This used to delete those keys + # from the shared class-level settings and restore them in an ensure; + # a request served concurrently on another instance during a runtime + # recompile could observe the missing keys (e.g. via #cascade?). A + # local copy keeps that mutation off the shared object. + namespace_inheritable = self.class.inheritable_setting.namespace_inheritable.to_hash.except(*ROOT_PREFIX_VERSIONING_KEYS) + collect_route_config_per_pattern(all_routes, namespace_inheritable) end - def collect_route_config_per_pattern(all_routes) + def collect_route_config_per_pattern(all_routes, namespace_inheritable) routes_by_regexp = all_routes.group_by(&:pattern_regexp) - namespace_inheritable = self.class.inheritable_setting.namespace_inheritable # Build the configuration based on the first endpoint and the collection of methods supported. routes_by_regexp.each_value do |routes| @@ -177,18 +182,6 @@ def collect_route_config_per_pattern(all_routes) ROOT_PREFIX_VERSIONING_KEYS = %i[version version_options root_prefix].freeze private_constant :ROOT_PREFIX_VERSIONING_KEYS - - # Allows definition of endpoints that ignore the versioning configuration - # used by the rest of your API. - def without_root_prefix_and_versioning - inheritable_setting = self.class.inheritable_setting - deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEYS) - yield - ensure - ROOT_PREFIX_VERSIONING_KEYS.zip(deleted_values) do |key, value| - inheritable_setting.namespace_inheritable[key] = value - end - end end end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 0bb42eb7f..fcab87d18 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -7,6 +7,27 @@ let(:app) { subject } + describe 'thread-safe (re)compilation' do + # Regression: building the HEAD/OPTIONS/405 helper routes used to delete the + # root-prefix/versioning keys from the shared class-level settings and + # restore them in an ensure. A request served concurrently on another + # instance during a runtime recompile could observe the missing keys (e.g. + # via #cascade?). Compilation must not mutate the shared settings. + it 'does not mutate the shared inheritable settings while (re)compiling' do + subject.version 'v1', using: :path, cascade: false + subject.get('/x') { 'x' } + subject.compile! + + shared = subject.base_instance.inheritable_setting.namespace_inheritable + expect(shared).not_to receive(:delete) + + subject.change! + subject.compile! + + expect(shared[:version_options]).to be_present + end + end + describe '.prefix' do it 'routes root through with the prefix' do subject.prefix 'awesome/sauce'