Return the compiled instance from Grape::API::Instance.compile! - #2835
Open
ericproulx wants to merge 1 commit into
Open
Return the compiled instance from Grape::API::Instance.compile!#2835ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
.call and .recognize_path compiled the API and then read @instance again to serve the request: def call(env) compile! @instance.call(env) end change! (which `helpers` and `mount` invoke on a live API) sets @instance to nil, so a change! landing between the two reads leaves both methods calling nil -- NoMethodError: undefined method 'call' for nil. Have compile! hand back the instance it compiled and let both callers use that value, so the read is atomic. compile! previously returned nil when the API was already compiled, so always returning the instance is a strict superset of its old contract. The window is small in practice -- a request thread spends nearly all of its time inside `new` when recompiling -- so this is a latent race rather than one reproduced under natural contention; the specs drive change! into the window deterministically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Danger ReportNo issues found. |
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
.calland.recognize_pathcompile the API and then read@instanceagain to serve the request:change!sets@instance = nil, and it runs fromDSL::Helpers#helpersandDSL::Routing#mount— i.e. whenever an API is modified. Achange!landing between the two reads leaves both methods callingnil:The
LOCKguards building the instance; it does not cover the second read.Fix
Have
compile!return the instance it compiled, and let both callers use that value so the read is atomic:compile!→return if @instance(nil once compiled)@instance || LOCK.synchronize { @instance ||= new }compile!; @instance.call(env)compile!.call(env)compile!; @instance.router.recognize_path(path)compile!.router.recognize_path(path)compile!previously returnednilwhen already compiled and the instance otherwise, so always returning the instance is a strict superset of its old contract.On reproducibility
Being straight about this: I could not trigger it under natural contention. 16 reader threads against 300k
change!calls produced no failure, because a reader spends nearly all of its time insidenewwhen recompiling, making the window proportionally tiny. What is demonstrable is that the window is unsafe when hit — driving achange!between the two reads reproduces theNoMethodErrorevery time.So this is a latent race, not an observed production failure. It is worth closing because the fix is free and strictly simplifies the code.
Notes
thread-safe (re)compilationblock: one pinningcompile!'s return contract (including the already-compiled call, which is where the old code returnednil), and two drivingchange!into the window forcall/recognize_path. All three fail without the change and pass with it — verified in both directions.bundle exec rubocopandbundle exec rspec(2519 examples) pass.🤖 Generated with Claude Code