Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions actionpack/lib/action_dispatch/journey/gtg/transition_table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should also do @memos.freeze here altho its not necessary when calling Ractor.make_shareable, it seems weird to remove the default proc without freezing the hash.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it's super weird and something Ractor.make_shareable really doesn't help with because it calls freeze, checks that the object itself is frozen then moves on to freeze objects referenced by the object.
On the one hand, it makes sense, because e.g. [Object.new].freeze.first.frozen? # => false, on the other hand when designing classes it feels not helpful:

class Foo
  def initialize = @foo = []
end
Ractor.shareable? Foo.new.freeze # => false
foo = Foo.new.freeze
Ractor.make_shareable foo
Ractor.shareable? foo # => true

There's no tool to let class authors know that @foo wasn't frozen, that would help them write a comprehensive freeze implementation.

I'm not sure what it should look like though, maybe Ractor.make_shareable(obj, freeze: false) that would raise if any referenced object is not already frozen maybe?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should call freeze. I know it's not necessary if you're going to turn around and call make_shareable, but I usually expect calling freeze on a complex object to freeze its references.

@etiennebarrie Could you just try passing the frozen object to a Ractor and see if it raises an exception? 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure 👍 I made this and the others freeze all their internals.

@stdparam_states.freeze
@regexp_states.freeze
@string_states.freeze
@accepting.freeze
@memos.freeze
super
end

def add_accepting(state)
@accepting[state] = true
end
Expand Down
6 changes: 3 additions & 3 deletions activerecord/lib/active_record/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Comment thread
andrewn617 marked this conversation as resolved.
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
Expand Down
14 changes: 13 additions & 1 deletion activesupport/lib/active_support/key_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 26 additions & 3 deletions activesupport/lib/active_support/ordered_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the contract of inheritable options.

h=ActiveSupport::InheritableOptions.new(foo: 42)
h[:foo] # => 42
h.default_proc=nil
h[:foo] # => nil

IIRC I had something like:

def freeze
  replace(to_h)
  super
end

But that is a bit naive, trades memory for performance, we may want to go the opposite way and also make @parent frozen (and the default proc ractor shareable, since this one doesn't mutate, that should work).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah sorry 🤦 This doesn't follow the pattern of the others my bad.

I think freezing the @parent would work, but we need to pass it as self of the shareable proc I believe. And it could have unshareable values within so I think we will need to expand edouard's work to have a Ractors.make_shareable_attempt to try to freeze it 🤔

I'll think about it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually if the self of the proc is shareable, and it doesn't capture variables, the proc could in theory be shareable too.
But there's a catch22 where the hash can't be made shareable because it has the proc that is not shareable because the hash is not shareable yet.

So unless I'm missing something, I don't think there's a good way to make a Hash with a default proc shareable, even if the default proc could in theory be shareable.

@andrewn617 andrewn617 Jun 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I don't think we can do it in the initializer. People can mutate the parent of course, so we can't freeze it then. We could do:

    def freeze
      Ractors.make_shareable(@parent)
      self.default_proc = if @parent.kind_of?(OrderedOptions)
          ActiveSupport::Ractors.shareable_proc(self: @parent) { |h, k| _get(k) }
        else
          ActiveSupport::Ractors.shareable_proc(self: @parent) { |h, k| self[k] }
        end
      super
    end

That seems to work. But to your other comment above, it feels like we need way to hook into make_shareable, since the behaviour really makes no sense as part of freeze. And actually itll break in ruby 3 if we do this since shareable_proc is a no op, we will still have the inherited option as self in the block so I think we will need to add a check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, for now I think replace(to_h) is still the best solution we have.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry missed your earlier message cause we posted around same time. But yeah, making default proc shareable seems too weird, I think replace(to_h) is fine for beta support you are right.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This thing has some APIs beyond a normal hash so I don't think its fair game to just to_h it 🤔 .

Instead I think its safer to merge the parent's entries at freezing time then remove the default proc. That way it still quacks like an inheritable options in all ways.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, using replace keeps the class so dynamic accessors work but overridden? will need to be handled separately.

inherited = {foo: 42, bar: 21}
options = ActiveSupport::InheritableOptions.new(inherited)
options.bar = 21
options.baz = 7

assert_equal 42, options.foo
refute options.overridden?(:foo)
assert options.overridden?(:bar)
refute options.overridden?(:baz)
refute options.key?(:qux)

We'll need to keep a copy of the keys before freezing.

@andrewn617 andrewn617 Jun 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking about this last night........... I think I will keep track of the keys when we call []= that will simplify the overridden

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'll pull this into its own PR since it's becoming a bit involved


def to_h
@parent.to_h.merge(self)
end
Expand All @@ -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
Expand All @@ -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
16 changes: 16 additions & 0 deletions activesupport/lib/active_support/testing/ractors_assertions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions activesupport/test/key_generator_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "abstract_unit"
require "active_support/testing/ractors_assertions"

begin
require "openssl"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
35 changes: 35 additions & 0 deletions activesupport/test/ordered_options_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
19 changes: 15 additions & 4 deletions railties/lib/rails/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions railties/lib/rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions railties/lib/rails/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions railties/lib/rails/initializable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions railties/test/application/ractors_test.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions tools/strict_warnings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading