Skip to content

Freeze coercers at construction and synchronize Grape::Util::Cache#2819

Open
ericproulx wants to merge 1 commit into
fix-coercer-request-time-statefrom
freeze-coercers-and-sync-caches
Open

Freeze coercers at construction and synchronize Grape::Util::Cache#2819
ericproulx wants to merge 1 commit into
fix-coercer-request-time-statefrom
freeze-coercers-and-sync-caches

Conversation

@ericproulx

@ericproulx ericproulx commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Thread-safety hardening for the coercion layer, following the validation-subsystem audit (#2816/#2817). Three changes, one theme: make shared state provably read-only after construction, and synchronize the one write path that genuinely can run concurrently.

Stacked on #2817 (fix-coercer-request-time-state) — the coercer freeze depends on its eager @elem_coercer build; freezing on top of master's lazy ||= would raise FrozenError at request time. GitHub will retarget this PR to master automatically once #2817 merges. Also merges cleanly with #2814's VariantCollectionCoercer change (disjoint hunks).

1. Coercers freeze at construction — via a named Grape::Util::FreezeOnNew module

Validators have had this guarantee for a while (Base.newsuper.freeze); coercers — equally shared across requests and additionally cached process-wide in Types::CoercerCache — did not. The blocker is even documented in DeepFreeze's comment: "Coercers (e.g. ArrayCoercer) — use lazy ivar memoization at request time". #2817 removed that memoization, so the exclusion is obsolete.

The idiom now has a name instead of five hand-written copies:

module Grape::Util::FreezeOnNew
  def new(...)
    super.freeze
  end
end

extend Grape::Util::FreezeOnNew is declared on each hierarchy base — DryTypeCoercer (covers Primitive/Array/Set via singleton-class inheritance), CustomTypeCoercer (covers the collection subclass), MultipleTypeCoercer (whose internal @type_coercers array is also frozen), and VariantCollectionCoercer. Validators::Base's hand-written new/super.freeze and ContractScopeValidator's end-of-initialize freeze are converted to the same declaration, so a grep for FreezeOnNew now enumerates every class with the immutability guarantee.

The module deliberately wraps new, never initialize: the freeze lands after the full subclass initialize chainSetCoercer assigns @coercer = nil after super, which an initialize-wrapper would break. The freeze is shallow: user-supplied coerce_with procs and custom type classes are never frozen.

Payoff: any future request-time ||= in a coercer becomes a deterministic FrozenError in the first spec run instead of a latent JRuby race. The full suite passing with every coercer frozen is itself the proof that no hidden mutation remains.

2. Grape::Util::Cache lookups are Monitor-synchronized

The compile-time Instance::LOCK serializes everything written during Instance.new — but the validation caches (CoercerCache, DryTypes::ParamsCache/StrictCache) are written at API definition time: a params block runs when the class body is evaluated, outside any lock. Two non-exotic scenarios make that concurrent:

  • Parallel eager loading (Rails 7+ eager loads in parallel in production) — two files defining Grape endpoints load on different threads, both writing CoercerCache.
  • Dev-mode lazy autoload under a threaded server — two concurrent first requests autoload two different API files (Ruby's require lock is per-feature).

On MRI the GVL makes this benign; on JRuby/TruffleRuby (in the edge CI matrix) concurrent unsynchronized Hash writes can corrupt or raise. One Monitor in the base class covers all eight cache subclasses. Monitor rather than Mutex because CoercerCache re-enters itself: building a MultipleTypeCoercer calls Types.build_coercer per member type, which lands back in the same cache — a non-reentrant lock would deadlock (there's a spec pinning exactly this path). Overhead is an uncontended lock on boot-frequency lookups; the caches are never read on the hot request path.

3. Route#regexp_capture_index assigned eagerly

base_route.rb had @regexp_capture_index ||= CaptureIndexCache[@index] — a lazy memo on shared Route instances reached from request-time route matching (router.rb optimized/greedy match). It was safe only by side effect: to_regexp happened to call it during router compilation, warming it before any request. Same fragile shape as the pre-#2817 ArrayCoercer. The index is now assigned directly in to_regexp (where @index was already being stored — that ivar is gone), the reader is a plain attr_reader, and the request path is provably read-only. This also clears the way for freezing Route instances entirely later.

Tests

  • types_spec.rb: every coercer shape (Integer, Array[Integer], nested Array[Array[Integer]], Set[Integer], JSON, multiple [Integer, String], coerce_with method, custom type, direct VariantCollectionCoercer) returns frozen from build_coercer, and a frozen multiple-type coercer still coerces.
  • New spec/grape/util/cache_spec.rb: 8 threads racing a cold key compute it exactly once with consistent results; the reentrant CoercerCache path completes (deadlock regression guard).
  • route_spec.rb: regexp_capture_index readable on a frozen route after to_regexp — proof no ivar is written after compilation.
  • New spec/grape/util/freeze_on_new_spec.rb: pins the module's two load-bearing properties — subclass coverage via singleton-class inheritance, and freezing only after the whole initialize chain (subclasses may assign ivars after super).

Full suite: 2385 examples, 0 failures. RuboCop clean.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the freeze-coercers-and-sync-caches branch from d35752e to 9205570 Compare July 25, 2026 19:31
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock July 25, 2026 19:32
Extends the validator freeze contract to the whole coercion layer,
naming the mechanism as Grape::Util::FreezeOnNew — extend into a class
whose instances are shared across requests and new returns a frozen
instance. A future request-time lazy ivar write raises FrozenError in
the first spec run instead of being a latent race. The module wraps new,
never initialize, so the freeze lands after the entire initialize chain
(SetCoercer assigns ivars after super); extending a hierarchy base
covers its subclasses via singleton-class inheritance.

- Extended into DryTypeCoercer (covers Primitive/Array/Set),
  CustomTypeCoercer (+ collection subclass), MultipleTypeCoercer
  (internal @type_coercers also frozen) and VariantCollectionCoercer.
  Validators::Base's hand-written new/super.freeze and
  ContractScopeValidator's end-of-initialize freeze now use the same
  module. DeepFreeze's obsolete "coercers memoize lazily" note updated.

- Grape::Util::Cache lookups are Monitor-synchronized. The caches are
  written at API *definition* time (params blocks run at class-body
  evaluation), which the compile-time Instance::LOCK does not cover and
  which can be concurrent (parallel eager loading, concurrent autoload
  of different API files). Monitor rather than Mutex because CoercerCache
  re-enters itself when a multiple-type coercer builds member coercers.

- Route#regexp_capture_index is assigned eagerly in #to_regexp instead
  of memoized on first read: the reader runs during request-time route
  matching on shared instances. Previously safe only because to_regexp
  happened to warm it during compilation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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