From 95ad01bdb4c207239b5c0bd96899c9809cdad055 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Mon, 22 Jun 2026 21:29:19 -0400 Subject: [PATCH 01/10] Fix InheritableOptions#keys to match the behaviour of #key? which includes the parent's keys. --- activesupport/lib/active_support/ordered_options.rb | 4 ++++ activesupport/test/ordered_options_test.rb | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/activesupport/lib/active_support/ordered_options.rb b/activesupport/lib/active_support/ordered_options.rb index d65f20181f458..7a50f03131fbd 100644 --- a/activesupport/lib/active_support/ordered_options.rb +++ b/activesupport/lib/active_support/ordered_options.rb @@ -127,6 +127,10 @@ def key?(key) super || @parent.key?(key) end + def keys + @parent.keys | super + end + def overridden?(key) !!(@parent && @parent.key?(key) && own_key?(key.to_sym)) end diff --git a/activesupport/test/ordered_options_test.rb b/activesupport/test/ordered_options_test.rb index dc41cd8a3c434..efc7265663e6e 100644 --- a/activesupport/test/ordered_options_test.rb +++ b/activesupport/test/ordered_options_test.rb @@ -250,6 +250,14 @@ def test_inheritable_options_key assert_not object.key?(:four) end + def test_inheritable_options_keys + object = ActiveSupport::InheritableOptions.new(one: "first value") + object[:two] = "second value" + object["three"] = "third value" + + assert_equal [:one, :two, :three], object.keys + end + def test_inheritable_options_overridden object = ActiveSupport::InheritableOptions.new(one: "first value", two: "second value", three: "third value") object["one"] = "first value override" From 26fed27f99408e24793773f7a8b66e110b950293 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Wed, 10 Jun 2026 13:25:00 -0400 Subject: [PATCH 02/10] Remove InheritableOption's default proc when freezing. This proc relies on referencing the instances ivar, so we can't make it shareable without making the instance shareable but we can't make the instance sharebale without making it shareable. Instead, we can merge the parent since we are now frozen and do expect to override anymore keys. --- .../lib/active_support/ordered_options.rb | 25 ++++++++++++++--- activesupport/test/ordered_options_test.rb | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/activesupport/lib/active_support/ordered_options.rb b/activesupport/lib/active_support/ordered_options.rb index 7a50f03131fbd..bf04a0c8bef2e 100644 --- a/activesupport/lib/active_support/ordered_options.rb +++ b/activesupport/lib/active_support/ordered_options.rb @@ -100,6 +100,16 @@ def initialize(parent = nil) end end + def freeze + return self if frozen? + + @own_keys = own_keys.dup.freeze + self.default_proc = nil + @parent.freeze + replace(to_h) + super + end + def to_h @parent.to_h.merge(self) end @@ -120,13 +130,13 @@ def pretty_print(pp) pp.pp_hash(to_h) end - alias_method :own_key?, :key? - private :own_key? - def key?(key) super || @parent.key?(key) end + alias_method :_own_keys, :keys + private :_own_keys + def keys @parent.keys | super end @@ -147,5 +157,14 @@ def each(&block) to_h.each(&block) self end + + private + def own_key?(key) + own_keys.include?(key.to_sym) + end + + def own_keys + @own_keys || _own_keys + end end end diff --git a/activesupport/test/ordered_options_test.rb b/activesupport/test/ordered_options_test.rb index efc7265663e6e..b8d7d4323d8c3 100644 --- a/activesupport/test/ordered_options_test.rb +++ b/activesupport/test/ordered_options_test.rb @@ -3,8 +3,11 @@ require "pp" require_relative "abstract_unit" require "active_support/ordered_options" +require "active_support/testing/ractors_assertions" class OrderedOptionsTest < ActiveSupport::TestCase + include ActiveSupport::Testing::RactorsAssertions + def test_usage a = ActiveSupport::OrderedOptions.new @@ -355,4 +358,28 @@ def test_inheritable_options_pp PP.pp(object, io) assert_equal({ one: "first value", two: "second value", three: "third value" }.inspect, io.string.strip) end + + def test_inheritable_options_ractor_shareable + object = ActiveSupport::InheritableOptions.new(one: "first value") + object[:two] = "second value" + object["three"] = "third value" + + assert_ractor_make_shareable(object) + + assert_equal "first value", object[:one] + assert_equal "second value", object[:two] + assert_equal "third value", object[:three] + end + + def test_overridden_works_when_frozen + object = ActiveSupport::InheritableOptions.new(one: "first value", two: "second value") + object[:two] = "second value" + object["three"] = "third value" + + object.freeze + + assert object.overridden?(:two) + assert_not object.overridden?(:one) + assert_not object.overridden?(:three) + end end From 295761ca0a592e4d457e75d240f9ddc46e589c3e Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Thu, 4 Jun 2026 16:23:08 -0400 Subject: [PATCH 03/10] Remove some mutating default procs from hashes that are frozen when sharing the application. Default procs need to be shareable in order to make their hash shareable. These procs all mutate the hash with a default option on key miss. It doesn't make sense to keep that behaviour on a frozen hash so we can just remove them. These are all configuration hashes we expect to be fully populated by the time the application is booted. --- .../journey/gtg/transition_table.rb | 12 ++++++++++++ railties/lib/rails/configuration.rb | 16 ++++++++++++++++ railties/lib/rails/initializable.rb | 11 +++++++++++ 3 files changed, 39 insertions(+) diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb index 239135ec8af0d..14f8d14fc4c09 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -22,6 +22,18 @@ def initialize @memos = Hash.new { |h, k| h[k] = [] } end + def freeze + return self if self.frozen? + + @memos.default_proc = nil + @stdparam_states.freeze + @regexp_states.freeze + @string_states.freeze + @accepting.freeze + @memos.freeze + super + end + def add_accepting(state) @accepting[state] = true end diff --git a/railties/lib/rails/configuration.rb b/railties/lib/rails/configuration.rb index 61819fad87a0c..7da834d03983d 100644 --- a/railties/lib/rails/configuration.rb +++ b/railties/lib/rails/configuration.rb @@ -116,9 +116,25 @@ def initialize @after_generate_callbacks = [] end + def freeze + return self if self.frozen? + + @aliases.default_proc = nil + @options.default_proc = nil + @aliases.freeze + @options.freeze + @fallbacks.freeze + @templates.freeze + @hidden_namespaces.freeze + @after_generate_callbacks.freeze + super + end + def initialize_copy(source) @aliases = @aliases.deep_dup + @aliases.default_proc = ->(h, k) { h[k] = {} } @options = @options.deep_dup + @options.default_proc = ->(h, k) { h[k] = {} } @fallbacks = @fallbacks.deep_dup @templates = @templates.dup end diff --git a/railties/lib/rails/initializable.rb b/railties/lib/rails/initializable.rb index 52a1c841532d0..a52698080c243 100644 --- a/railties/lib/rails/initializable.rb +++ b/railties/lib/rails/initializable.rb @@ -47,6 +47,17 @@ def initialize(initializers = nil) concat(initializers) if initializers end + def freeze + return self if self.frozen? + + @order.default_proc = nil + @resolve.default_proc = nil + @order.freeze + @resolve.freeze + @collection.freeze + super + end + def to_a @collection end From 30e8227854bfd479acb678920a86f59fde4b1a66 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Thu, 4 Jun 2026 20:23:25 -0400 Subject: [PATCH 04/10] Make MessageVerifiers proc ractor shareable --- activerecord/lib/active_record/railtie.rb | 6 +++--- railties/lib/rails/application.rb | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 1497db7cd2366..158d609b1c8ab 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -351,12 +351,12 @@ class Railtie < Rails::Railtie # :nodoc: ActiveRecord.message_verifiers = app.message_verifiers use_legacy_signed_id_verifier = app.config.active_record.use_legacy_signed_id_verifier - legacy_options = { digest: "SHA256", serializer: JSON, url_safe: true } + legacy_options = { digest: "SHA256", serializer: JSON, url_safe: true }.freeze if use_legacy_signed_id_verifier == :generate_and_verify - app.message_verifiers.prepend { |salt| legacy_options if salt == "active_record/signed_id" } + app.message_verifiers.prepend(&ActiveSupport::Ractors.shareable_proc { |salt| legacy_options if salt == "active_record/signed_id" }) elsif use_legacy_signed_id_verifier == :verify - app.message_verifiers.rotate { |salt| legacy_options if salt == "active_record/signed_id" } + app.message_verifiers.rotate(&ActiveSupport::Ractors.shareable_proc { |salt| legacy_options if salt == "active_record/signed_id" }) elsif use_legacy_signed_id_verifier raise ArgumentError, "Unrecognized value for config.active_record.use_legacy_signed_id_verifier: #{use_legacy_signed_id_verifier.inspect}" end diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 9f189d54310e1..2566cdf1b8845 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -215,9 +215,9 @@ def key_generator(secret_key_base = self.secret_key_base) # def message_verifiers @message_verifiers ||= - ActiveSupport::MessageVerifiers.new do |salt, secret_key_base: self.secret_key_base| - key_generator(secret_key_base).generate_key(salt) - end.rotate_defaults + ActiveSupport::MessageVerifiers.new(&ActiveSupport::Ractors.shareable_proc do |salt, secret_key_base: Rails.application.secret_key_base| + Rails.application.key_generator(secret_key_base).generate_key(salt) + end).rotate_defaults end # Returns a message verifier object. From aa7bc019947649b150f2e92a585e812cae5785e2 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Mon, 15 Jun 2026 15:22:39 -0400 Subject: [PATCH 05/10] Make CachingKeyGenerator's cache Ractor-local when frozen. This uses a Concurrent::Map internally which is not Ractor safe. For now we will make this cache Ractor local when running in Ractor mode. --- .../lib/active_support/key_generator.rb | 14 ++++++++++++- activesupport/test/key_generator_test.rb | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/key_generator.rb b/activesupport/lib/active_support/key_generator.rb index 0cca53dd32970..e98cea16bc02c 100644 --- a/activesupport/lib/active_support/key_generator.rb +++ b/activesupport/lib/active_support/key_generator.rb @@ -62,9 +62,21 @@ def initialize(key_generator) @cache_keys = Concurrent::Map.new end + def freeze + @key = "_caching_key_generator_#{object_id}".to_sym + Ractor[@key] = @cache_keys + @cache_keys = nil + super + end + # Returns a derived key suitable for use. def generate_key(*args) - @cache_keys[args.join("|")] ||= @key_generator.generate_key(*args) + cache_keys[args.join("|")] ||= @key_generator.generate_key(*args) end + + private + def cache_keys + @cache_keys || Ractor[@key] ||= Concurrent::Map.new + end end end diff --git a/activesupport/test/key_generator_test.rb b/activesupport/test/key_generator_test.rb index 87cc7c8de0922..6ce1d12c586c1 100644 --- a/activesupport/test/key_generator_test.rb +++ b/activesupport/test/key_generator_test.rb @@ -100,5 +100,25 @@ def setup assert_not_equal derived_key, different_length_key end + + test "CachingKeyGenerator can work across ractors" do + # OpenSSL::Digest are not Ractor-safe, but the fix is already merged upstream. This test can be updated + # to use our implementation once a version of Ruby ships with ruby/openssl@502bc6c + key_generator = Class.new(ActiveSupport::KeyGenerator) do + def generate_key(salt, key_size) + OpenSSL::PKCS5.pbkdf2_hmac(@secret, salt, @iterations, key_size, "SHA1") + end + end.new("foo", iterations: 2) + caching_generator = ActiveSupport::CachingKeyGenerator.new(key_generator) + ActiveSupport::Ractors.make_shareable(caching_generator) + + port = Ractor::Port.new + Ractor.new(port, caching_generator) do |port, caching_generator| + port.send caching_generator.generate_key("some_salt", 32) + end.join + key = port.receive + + assert_equal key, caching_generator.generate_key("some_salt", 32) + end end end From 0c3e6a249a4cb4b7db235af6c3f3a26f92857972 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Mon, 15 Jun 2026 15:29:11 -0400 Subject: [PATCH 06/10] Introduce on_ractor helper to improve the mechanics of testing on non-main ractor --- .../active_support/testing/ractors_assertions.rb | 16 ++++++++++++++++ activesupport/test/key_generator_test.rb | 11 ++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/activesupport/lib/active_support/testing/ractors_assertions.rb b/activesupport/lib/active_support/testing/ractors_assertions.rb index 22242d67b9a41..144903de943e1 100644 --- a/activesupport/lib/active_support/testing/ractors_assertions.rb +++ b/activesupport/lib/active_support/testing/ractors_assertions.rb @@ -5,6 +5,18 @@ module Testing module RactorsAssertions # :nodoc: all private if RUBY_VERSION >= "4.0" + def on_ractor(*args, &block) + block = Ractor.shareable_proc(&block) + + port = Ractor::Port.new + + Ractor.new(port, block, args) do |port, block, args| + port.send block.call(*args) + end.join + + port.receive + end + def assert_ractor_make_shareable(obj) assert_nothing_raised { Ractor.make_shareable(obj) } end @@ -13,6 +25,10 @@ def assert_ractor_shareable(obj) assert Ractor.shareable?(obj), "Expected #{obj.inspect} to be shareable, but it is not." end else + def on_ractor(*args) + yield(*args) + end + def assert_ractor_make_shareable(obj) assert true end diff --git a/activesupport/test/key_generator_test.rb b/activesupport/test/key_generator_test.rb index 6ce1d12c586c1..9d761624c2be1 100644 --- a/activesupport/test/key_generator_test.rb +++ b/activesupport/test/key_generator_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "abstract_unit" +require "active_support/testing/ractors_assertions" begin require "openssl" @@ -66,6 +67,8 @@ def setup end class CachingKeyGeneratorTest < ActiveSupport::TestCase + include ActiveSupport::Testing::RactorsAssertions + def setup @secret = SecureRandom.hex(64) @generator = ActiveSupport::KeyGenerator.new(@secret, iterations: 2) @@ -112,11 +115,9 @@ def generate_key(salt, key_size) caching_generator = ActiveSupport::CachingKeyGenerator.new(key_generator) ActiveSupport::Ractors.make_shareable(caching_generator) - port = Ractor::Port.new - Ractor.new(port, caching_generator) do |port, caching_generator| - port.send caching_generator.generate_key("some_salt", 32) - end.join - key = port.receive + key = on_ractor(caching_generator) do |caching_generator| + caching_generator.generate_key("some_salt", 32) + end assert_equal key, caching_generator.generate_key("some_salt", 32) end From 5e917e6e49591748d1c7a7e6198a15097bc42228 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Tue, 9 Jun 2026 21:42:03 -0400 Subject: [PATCH 07/10] Make sure engine's app is created before freezing, and throw away the mutex --- railties/lib/rails/engine.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/railties/lib/rails/engine.rb b/railties/lib/rails/engine.rb index f726a8c50ea8b..f407a1da688b3 100644 --- a/railties/lib/rails/engine.rb +++ b/railties/lib/rails/engine.rb @@ -448,6 +448,12 @@ def initialize super end + def freeze + app + @app_build_lock = nil + super + end + # Load console and invoke the registered hooks. # Check Rails::Railtie.console for more info. def load_console(app = self) From 6cd73f6669c9a158a48279cca68ea9b43bd3c5b8 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Tue, 9 Jun 2026 21:58:38 -0400 Subject: [PATCH 08/10] Make coerce_same_site_protection proc shareable. It just returns a symbol so it is safe to share. --- railties/lib/rails/application.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 2566cdf1b8845..45225108b71fc 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -772,7 +772,7 @@ def build_middleware end def coerce_same_site_protection(protection) - protection.respond_to?(:call) ? protection : proc { protection } + protection.respond_to?(:call) ? protection : ActiveSupport::Ractors.shareable_proc { protection } end def filter_parameters From ab8cd6b831ab86b63c420d0bbcab17969a58879f Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Tue, 9 Jun 2026 21:30:45 -0400 Subject: [PATCH 09/10] Introduce ractorize! method This method prepares the application for sharing and then calls Ractor.make_shareable on it. This is an experimental feature. Currently this is only supported in eager loaded environments. --- railties/lib/rails/application.rb | 11 +++++++ railties/test/application/ractors_test.rb | 37 +++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 railties/test/application/ractors_test.rb diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 45225108b71fc..3e4ab7955d3ea 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -668,6 +668,17 @@ def eager_load! Rails.autoloaders.each(&:eager_load) end + def ractorize! # :nodoc: + warn "WARNING: Rails' Ractor support is experimental. Don't try this at home!", category: :experimental, uplevel: 1 + + env_config + routes + + @autoloaders, @reloaders, @routes_reloader = nil, nil, nil + + Ractor.make_shareable(self) + end + protected alias :build_middleware_stack :app diff --git a/railties/test/application/ractors_test.rb b/railties/test/application/ractors_test.rb new file mode 100644 index 0000000000000..072d1a0ffb277 --- /dev/null +++ b/railties/test/application/ractors_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" +require "active_support/testing/ractors_assertions" + +if RUBY_VERSION >= "4.0" + module ApplicationTests + class RactorsTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + include ActiveSupport::Testing::RactorsAssertions + + def setup + build_app + + add_to_env_config "production", "ActiveSupport::Ractors.unshareable_proc_action = :raise" + + # Remove some defaults that are not compatible + add_to_env_config "production", "config.logger = ActiveSupport::Logger.new(nil)" + add_to_env_config "production", "config.public_file_server.enabled = false" + add_to_env_config "production", "config.cache_store = :null_store" + add_to_env_config "production", "config.action_cable.mount_path = nil" + end + + def teardown + teardown_app + end + + test "ractorize! makes the app shareable in production mode" do + app "production" + + Rails.application.ractorize! + + assert_ractor_shareable Rails.application + end + end + end +end From 75d3fe36d747534cb5bdd527ed928ee7e1b2b788 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Tue, 23 Jun 2026 19:34:10 -0400 Subject: [PATCH 10/10] Allow ractor warnings on ci --- tools/strict_warnings.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/strict_warnings.rb b/tools/strict_warnings.rb index b20daea67f625..f91baa3dbe125 100644 --- a/tools/strict_warnings.rb +++ b/tools/strict_warnings.rb @@ -14,6 +14,8 @@ class WarningError < StandardError; end # Expected non-verbose warning emitted by Rails. /Ignoring .*\.yml because it has expired/, /Failed to validate the schema cache because/, + /Rails' Ractor support is experimental/, + /Ractor API is experimental and may change in future versions of Ruby/, ) SUPPRESSED_WARNINGS = Regexp.union(