Skip to content

Return the compiled instance from Grape::API::Instance.compile! - #2835

Open
ericproulx wants to merge 1 commit into
masterfrom
fix-compile-instance-race
Open

Return the compiled instance from Grape::API::Instance.compile!#2835
ericproulx wants to merge 1 commit into
masterfrom
fix-compile-instance-race

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

.call and .recognize_path compile the API and then read @instance again to serve the request:

def call(env)
  compile!             # read 1 (inside compile!)
  @instance.call(env)  # read 2 — unsynchronized
end

change! sets @instance = nil, and it runs from DSL::Helpers#helpers and DSL::Routing#mount — i.e. whenever an API is modified. A change! landing between the two reads leaves both methods calling nil:

NoMethodError: undefined method 'call' for nil
NoMethodError: undefined method 'router' for nil

The LOCK guards 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:

Before After
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 returned nil when 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 inside new when recompiling, making the window proportionally tiny. What is demonstrable is that the window is unsafe when hit — driving a change! between the two reads reproduces the NoMethodError every 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

  • Three specs added to the existing thread-safe (re)compilation block: one pinning compile!'s return contract (including the already-compiled call, which is where the old code returned nil), and two driving change! into the window for call / recognize_path. All three fail without the change and pass with it — verified in both directions.
  • Full bundle exec rubocop and bundle exec rspec (2519 examples) pass.

🤖 Generated with Claude Code

.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>
@github-actions

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock July 27, 2026 21:30
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