Skip to content

Resolve the ip list on the main Ractor under ractorize! - #254

Open
mhenrixon wants to merge 1 commit into
modosc:mainfrom
mhenrixon:ractor-support
Open

mhenrixon wants to merge 1 commit into
modosc:mainfrom
mhenrixon:ractor-support

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Oct 17, 2026 •

Copy link
Copy Markdown

Rails main has an experimental Rails.application.ractorize! that serves requests from non-main Ractors on Ruby 4.0. With cloudflare-rails loaded, every request fails there, because ActionDispatch::RemoteIp#proxies calls Importer.cloudflare_ips on each request:

Ractor::IsolationError: can not access class variables from non-main Ractors (@@options from Rails::Railtie::Configuration)
  lib/cloudflare_rails/importer.rb:55:in 'CloudflareRails::Importer.fetch_with_cache'

That config read is only the first error. Before writing the fix, I worked through what happens after it in a Rails main app:

  1. Rails.application.config.cloudflare.* is a class variable read on Ruby 4.0.
  2. If the config is read at boot instead, the fetch then works inside the Ractor, but @ips ||= … raises can not set instance variables of classes/modules by non-main Ractors. A non-main Ractor can't set the memo on any Ruby.
  3. The rescue then returns FallbackIps::IPS_V4 + IPS_V6. Those are frozen Arrays of unfrozen IPAddrs, so a non-main Ractor can't read those constants either, and the request fails.

Change

Importer.make_shareable! resolves the list on the main Ractor and stores it with Ractor.make_shareable, whether it fetched the list or fell back. The Railtie calls it from Rails' before_ractorize load hook, the same hook Rails' own railties use. Older Rails never runs that hook, and outside ractorize! nothing changes.

Under ractorize!, the list is therefore resolved once at boot, and non-main Ractors only read it. A fetch that already succeeded was memoized for the life of the process anyway. One difference: if the fetch fails at boot, the fallback stays until restart, where today each request would retry. That retry can't work from a non-main Ractor anyway, because it can't set @ips (step 2).

I didn't move the config reads to boot. Once the list is memoized, the request path no longer reaches them, and moving them doesn't fix anything by itself (step 2). That also keeps this PR out of #243's way: #243 rewrites cloudflare_ips/fetch_with_cache, but this only adds a method next to them and sets @ips directly, so the two compose.

Tests

New .make_shareable! examples: with a successful fetch and with 404s, the list is memoized (one request to the v4 endpoint in total) and Ractor.shareable?. They skip without Ractor. bundle exec rake (all three rack-attack modes) and rubocop pass on Ruby 3.3.8/Rails 7.2, 3.4.11/8.0, 4.0.7/8.1 and 4.0.7/edge.

I also checked it in a Rails main app under ractorize!, with two requests running in a non-main Ractor:

  • Before: the first request (GET /posts) fails with the error above.
  • After: both requests return 200.
  • After, with outbound HTTP blocked: there is one cloudflare-rails: error fetching ip addresses … falling back to defaults line at ractorize!, none per request, and the requests return 200.
  • Inside a Ractor, with REMOTE_ADDR=173.245.48.1 (a Cloudflare range) and X-Forwarded-For: 203.0.113.9: request.ip is 203.0.113.9 and request.cloudflare? is true.

I didn't add a CHANGELOG entry, because recent PRs don't add one. I can add one if you'd like.

Conformance case

spec/cloudflare/rails_spec.rb:132: "makes the fetched ips shareable" (and :145, the same for the fallback list)

BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bundle exec rspec spec/cloudflare/rails_spec.rb -e "make_shareable!"
  • Without the fix (8ca4a9e), Ruby 4.0.7: the specs call the new Importer.make_shareable!, so they can't run there. The bug they cover: with @ips memoized on the main Ractor, Importer.cloudflare_ips called from a Ractor fails with Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@ips from CloudflareRails::Importer).
  • With this PR: passes on Ruby 4.0.7, and the same call returns the 22 fallback ranges.

The specs check Ractor.shareable? on both the fetched list and the fallback, so either path going back to an unfrozen value fails them; they skip where Ractor is unavailable.

Rails main's experimental `Rails.application.ractorize!` serves requests
from non-main Ractors. There, the first `Importer.cloudflare_ips` call
fails: reading `Rails.application.config.cloudflare` goes through a class
variable (Ruby 4.0), a non-main Ractor can't set `@ips`, and the fallback
constants hold unfrozen IPAddrs.

On Rails' `before_ractorize` hook, resolve the list once on the main
Ractor, fetched or the fallback, and store it frozen and shareable.
Without `ractorize!` the hook never runs and nothing changes.
@mhenrixon mhenrixon moved this to Waiting on maintainers in Ractor-safe Rails Oct 18, 2026
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