Skip to content

Build helper routes from a settings copy, not shared mutation#2791

Open
ericproulx wants to merge 1 commit into
masterfrom
fix/recompile-settings-mutation-race
Open

Build helper routes from a settings copy, not shared mutation#2791
ericproulx wants to merge 1 commit into
masterfrom
fix/recompile-settings-mutation-race

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Follow-up to #2790, from the same thread-safety review.

Problem

When a Grape::API instance is built, add_head_not_allowed_methods_and_options_methods wraps its work in without_root_prefix_and_versioning, which deletes the root-prefix/versioning keys from the shared class-level namespace_inheritable and restores them in an ensure:

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

Instance construction happens whenever the API is (re)compiled, and that can occur at runtime — mount, helpers, and route definitions all call change!, which nils @instance so the next request rebuilds it. If another instance is serving a request during the delete→restore window, it can observe the missing keys. Concretely, Instance#cascade? reads namespace_inheritable[:version_options] and would fall back to true, so a response's X-Cascade header could be stripped (or not) incorrectly for that request.

This is narrow (only affects apps that mutate their API definition at runtime while serving) but it's a real shared-state mutation on a settings object that live requests read.

Fix

Nothing in collect_route_config_per_pattern actually reads the stripped keys — it only reads :do_not_route_head / :do_not_route_options, and the routes/paths are already built before the block. So instead of mutating the shared object, read what the helper routes need from a local copy with the versioning keys removed:

namespace_inheritable = self.class.inheritable_setting.namespace_inheritable.to_hash.except(*ROOT_PREFIX_VERSIONING_KEYS)
collect_route_config_per_pattern(all_routes, namespace_inheritable)

without_root_prefix_and_versioning is removed; the shared settings are never mutated during compilation. Behavior is unchanged.

Tests

New regression in spec/grape/api_spec.rb asserts that (re)compiling a versioned API does not delete from the shared namespace_inheritable and that :version_options stays present. Verified it fails against the old delete/restore implementation. Full suite: 2352 examples, 0 failures. RuboCop clean.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@ericproulx ericproulx force-pushed the fix/recompile-settings-mutation-race branch from 1a14108 to 847a019 Compare July 10, 2026 12:51
@github-actions

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@github-actions

Copy link
Copy Markdown

Danger Report

Warnings

  • Unless you're refactoring existing code or improving documentation, please update CHANGELOG.md.

Markdowns

Here's an example of a CHANGELOG.md entry:

* [#2791](https://github.com/ruby-grape/grape/pull/2791): Build helper routes from a settings copy, not shared mutation - [@ericproulx](https://github.com/ericproulx).

View run

@ericproulx ericproulx requested a review from dblock July 10, 2026 12:55
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