Stop reassigning method parameters across lib - #2908
Merged
Conversation
Danger ReportNo issues found. |
ericproulx
marked this pull request as ready for review
September 5, 2026 18:05
ericproulx
force-pushed
the
no-param-reassign-error-middleware
branch
2 times, most recently
from
September 5, 2026 18:31
ab3d147 to
9d7711c
Compare
A parameter reassigned partway through a method means the name documents one thing in the signature and holds another below it. Every site in `lib` now binds the derived value to its own local, so a parameter keeps what the caller passed for the whole method. Two of them were reassigned to hide a mutation of the argument itself, and those stop writing into what they were given: * `Middleware::Formatter#ensure_content_type` merged a Content-Type into the response headers Hash in place, then returned it. * `ParamsScope#process_oneof!` wrote the collected variants back into the options Hash from the `requires`/`optional` call site. Now `#collected_oneof` returns them and the caller merges. `Endpoint::Options` is left alone; it is fixed in #2907. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
no-param-reassign-error-middleware
branch
from
September 6, 2026 11:02
9d7711c to
d67b897
Compare
3 tasks
ericproulx
added a commit
that referenced
this pull request
Sep 6, 2026
#2908 stopped `Grape::Middleware::Formatter#ensure_content_type` from writing into the headers Hash it was handed, returning `headers.merge(Rack::CONTENT_TYPE => ...)` instead. That copy runs on almost every response, and the inline pair made it cost three allocations rather than one: Ruby builds the one-pair Hash literal, then copies it again converting the implicit keyword Hash into a positional argument for `Hash#merge`, and `Rack::Headers#merge` dups the receiver on top of that. Measured over 2000 requests through a formatted endpoint: merge 19906.25 kB / 196,000 objects in place 18968.75 kB / 190,000 objects Three objects per response on the hot path, for a header the caller is about to send anyway. The negotiation goes back to writing into `headers`, and the method carries a `!` so the mutation is visible at the call site. `build_formatted_response` drops the `typed_headers` local it only needed in order to hold the copy, so `headers` is still never reassigned, which is what #2908 was after. The trade is that a mounted plain Rack app returning a frozen or shared headers Hash is written into again, as it was before #2908. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ericproulx
added a commit
that referenced
this pull request
Sep 6, 2026
#2908 stopped `Grape::Middleware::Formatter#ensure_content_type` from writing into the headers Hash it was handed, returning `headers.merge(Rack::CONTENT_TYPE => ...)` instead. That copy runs on almost every response, and the inline pair made it cost three allocations rather than one: Ruby builds the one-pair Hash literal, then copies it again converting the implicit keyword Hash into a positional argument for `Hash#merge`, and `Rack::Headers#merge` dups the receiver on top of that. Measured over 2000 requests through a formatted endpoint: merge 19906.25 kB / 196,000 objects in place 18968.75 kB / 190,000 objects Three objects per response on the hot path, for a header the caller is about to send anyway. The negotiation goes back to writing into `headers`, and the method carries a `!` so the mutation is visible at the call site. `build_formatted_response` drops the `typed_headers` local it only needed in order to hold the copy, so `headers` is still never reassigned, which is what #2908 was after. The trade is that a mounted plain Rack app returning a frozen or shared headers Hash is written into again, as it was before #2908. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
A method that reassigns one of its parameters gives the same name two meanings: what the signature documents, and what the lines below the reassignment actually hold. Nothing escapes to the caller — Ruby parameters are local bindings — but the reader has to carry the reassignment for the rest of the method, and a later edit that moves a line above it changes behaviour silently.
This is a sweep of every such site in
lib. Each derived value gets its own local; the parameter keeps what the caller passed.The two that mattered
Most of the diff is naming. Two sites were reassignment wrapped around a mutation of the argument itself, and those did escape.
Middleware::Formatter#ensure_content_typewrote into the response headers Hash and returned the same object, which the caller then reassigned over its own parameter:The Hash belongs to the response the app returned; negotiating a content type for it is not a reason to reach back into it. It merges now.
Grape::Util::Header#mergepreserves the class and its case-insensitive lookup on both Rack 2 (Rack::Utils::HeaderHash) and Rack 3 (Rack::Headers).ParamsScope#process_oneof!wrote the collected variants back into the options Hash therequires/optionalcall site built:#validatesonly copied that Hash on therequiredpath (validations.merge(presence: …)), so on theoptionalpath the write landed on the caller's Hash. It is contained today only because**optsmanufactures a fresh Hash per call — an accidental defensive copy, one signature change away from being load-bearing.#collected_oneofreturns the variants now and#validatesmerges them.The rest
dsl/desc.rbdescoptions→resolved_optionsdsl/inside_route.rberror!status→resolved_statusdsl/parameters.rbparamsparams→scopeddsl/parameters.rbdeclareas→declared_asdsl/routing.rbmountopts→mount_optsdsl/routing.rbroute_paramrequirements→param_requirementserror_formatter/base.rbpresentmessage→payloadexceptions/validation.rbinitializemessage→translatedmiddleware/error.rbOptions#initializerescue_options,default_error_formattermiddleware/error.rbrack_responsemessage→bodymiddleware/error.rbrun_rescue_handlerhandler→callablemiddleware/formatter.rbbuild_formatted_responseheaders→typed_headersmiddleware/formatter.rbread_rack_inputbody→parsedmiddleware/stack.rbinsert,insert_afterindex→atutil/path_normalizer.rbcallpath→normalizedvalidations/contract_scope.rbinitializecontract→declared/schemavalidations/params_scope.rbvalidatesvalidations→declaredvalidations/types.rbcreate_coercer_instancetype→mappedtypes/multiple_type_coercer.rbcallval→candidatetypes/variant_collection_coercer.rbcallvalue→coercedvalidations/validations_spec.rbvalidate_value_coercioncoerce_type→element_typeThree are worth a second look:
run_rescue_handleris the strongest case, because the type changed rather than the value.handlerarrives as aSymbol, aMethodor aProc—rescue_from with: :some_endpoint_methodproduces the first,find_handler'smethod(:error_response)the second, arescue_fromblock the third — andarity/&are valid only after resolution. The method re-enters itself from five places, so tracing the recursion meant re-deriving the shape on each pass.callablestates the invariant.path_normalizer.rbanderror_formatter/base.rbreassign in order to protect the caller:path = "/#{path}"before a chain ofsqueeze!/delete_suffix!/gsub!, andmessage = message.dupbeforedelete(:with). The naming was what made that hard to see — it reads as mutating the argument.normalizedandpayloadsay the mutated object is a fresh one, andpath_normalizergets a comment recording why the bangs are safe.middleware/error.rb'sOptions#initializeneededsuper(...)with explicit keywords instead of zsuper, since baresuperforwards whatever the parameters currently hold — which is exactly the pressure that producesx ||= defaultin aDatainitializer.Not in scope
Grape::Endpoint::Optionshas the same shape and is fixed separately in #2907, where the reassignment is hiding a real bug rather than only obscuring one.Verification
bundle exec rspec— 2703 examples, 0 failures.bundle exec rubocop— 343 files, no offenses.gemfiles/grape_entity.gemfile(exerciseserror_formatter/base.rb#present) — 2726 examples, 0 failures.gemfiles/dry_validation.gemfile(exercisescontract_scope.rb) — 13 examples, 0 failures.gemfiles/multi_xml.gemfilehas 3 pre-existing failures inapi_spec.rb's XML error-format examples; identical onmasterat the same seed, so unrelated to this change.No new specs: nothing new is asserted, and the behavioural half is covered by the existing formatter and
oneofsuites.🤖 Generated with Claude Code