Remove request-time mutable state from Array and custom-type coercers#2817
Open
ericproulx wants to merge 1 commit into
Open
Remove request-time mutable state from Array and custom-type coercers#2817ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
Two coercers created or mutated state at request time on instances that are shared across requests (and cached in Types::CoercerCache): - ArrayCoercer memoized @elem_coercer with ||= inside #call, so the first request to hit a shared instance wrote a new ivar (and could seed the unsynchronized DryTypes cache). Build it eagerly in initialize instead; rename the now-unused @subtype accordingly. SetCoercer inherits the eager build via super. - CustomTypeCoercer#collection_symbolizer called map! on whatever the user's coerce_with method returned. A frozen return raised FrozenError, which CoerceValidator swallowed as a spurious "is invalid" 400; the input could also be the user's own object. dup before map! avoids the mutation and, unlike map, keeps a Set a Set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
fix-coercer-request-time-state
branch
from
July 25, 2026 18:50
f9c273f to
a8b4b85
Compare
Danger ReportNo issues found. |
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two coercers in the types layer created or mutated state at request time on instances that are shared across requests — coercers are built once at definition time and cached in
Types::CoercerCache. Found during the same validation-subsystem audit as #2816. Neither is a hard crash on MRI, but both violate the layer's "no request-time mutable state" contract and one produces a user-visible wrong result.1.
ArrayCoercer— lazy element coercer built at call timecoerce_elementscalls this per element, so the first request to reach a shared coercer instance writes a new ivar, and that first call can also seedDryTypes::ParamsCache— aGrape::Util::Cachebacked by an unsynchronized default-blockHash. Same shared-mutable-default-hash shape flagged elsewhere in the codebase.SetCoercersubclassesArrayCoercerand inherits the lazy path.Fix: build the element coercer eagerly in
initialize(renaming the now-redundant@subtypeivar to@elem_coercer).SetCoercergets the eager build for free viasuper.2.
CustomTypeCoercer—map!on the user's returned collectionmethodis the user'scoerce_with(or a custom type'sparse). Callingmap!on its return value mutates an object Grape doesn't own. If that value is frozen,map!raisesFrozenError, whichCoerceValidatorcatches under its blanketrescue StandardErrorand reports as a spurious"<param> is invalid"400 — even though coercion succeeded.Fix:
dupbeforemap!.dupreturns an unfrozen shallow copy, so the user's object is never mutated, and — unlike switching tomap— it preserves the class, keeping aSetaSet(Set#mapreturns anArray). The symbolized hashes are fresh (deep_symbolize_keys), so nothing shared is touched.Tests
array_coercer_spec.rb: a frozen coercer instance still coerces — a deterministic proof that#callcreates no lazy ivar (a frozen instance with a lazy||=would raiseFrozenError).custom_type_coercer_spec.rb: a coercion method returning a frozen array is symbolized without raising; the existing Set spec continues to assert theSetreturn type is preserved.No behavioral change for the ordinary (unfrozen, non-shared-contention) paths. Full suite: 2369 examples, 0 failures. RuboCop clean.
🤖 Generated with Claude Code