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/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/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/lib/active_support/ordered_options.rb b/activesupport/lib/active_support/ordered_options.rb index d65f20181f458..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,17 @@ 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 + def overridden?(key) !!(@parent && @parent.key?(key) && own_key?(key.to_sym)) end @@ -143,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/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 87cc7c8de0922..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) @@ -100,5 +103,23 @@ 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) + + 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 end end diff --git a/activesupport/test/ordered_options_test.rb b/activesupport/test/ordered_options_test.rb index dc41cd8a3c434..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 @@ -250,6 +253,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" @@ -347,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 diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 9f189d54310e1..3e4ab7955d3ea 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. @@ -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 @@ -772,7 +783,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 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/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) 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 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 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(