From bb47697cd8ef97f169ac7978463dc790d9445472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Tue, 4 Aug 2026 15:46:05 +0200 Subject: [PATCH 1/2] Make ERB template compilation readable from non-main Ractors --- actionview/lib/action_view/base.rb | 2 +- actionview/lib/action_view/template/handlers/erb.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb index 1e865a872dc02..2de5962736456 100644 --- a/actionview/lib/action_view/base.rb +++ b/actionview/lib/action_view/base.rb @@ -177,7 +177,7 @@ class Base cattr_accessor :automatically_disable_submit_tag, default: true # Annotate rendered view with file names - cattr_accessor :annotate_rendered_view_with_filenames, default: false + class_attribute :annotate_rendered_view_with_filenames, default: false class_attribute :_routes class_attribute :logger diff --git a/actionview/lib/action_view/template/handlers/erb.rb b/actionview/lib/action_view/template/handlers/erb.rb index c990d98fe2f5b..dff3080480dec 100644 --- a/actionview/lib/action_view/template/handlers/erb.rb +++ b/actionview/lib/action_view/template/handlers/erb.rb @@ -22,7 +22,7 @@ class ERB # :nodoc: # Strip trailing newlines from rendered output class_attribute :strip_trailing_newlines, default: false - ENCODING_TAG = Regexp.new("\\A(<%#{ENCODING_FLAG}-?%>)[ \\t]*") + ENCODING_TAG = Regexp.new("\\A(<%#{ENCODING_FLAG}-?%>)[ \\t]*").freeze LocationParsingError = Class.new(StandardError) # :nodoc: From 455508eaa1d1cd23c2b392e55c01d9a6b95d4c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Tue, 4 Aug 2026 15:46:05 +0200 Subject: [PATCH 2/2] Build non-strict templates in a Ractor-local cache when frozen UnboundTemplate#freeze required strict locals because a single template can serve every locals set only when the template ignores render-time locals. Without strict locals, one template exists per locals set, which cannot be known at boot: freeze now keeps the unbound shell frozen and shareable, and bind_locals builds unfrozen templates on demand in a Ractor-local two-level cache (store_if_absent, keyed by the unbound template then by normalized locals). Each Ractor compiles a locals set at most once, mirroring the per-process behavior of the unfrozen cache. Requires concurrent-ruby 1.3.8, which froze the Concurrent::NULL sentinel that every Concurrent::Map operation reads. The Ractor test compiles through the private compile method: ActiveSupport::Notifications does not support instrumenting from non-main Ractors yet, which is the next blocker for worker-side compile! and render. With non-strict templates freezable, ractorize! now also freezes every file system resolver after eager loading and compiling it, making the template caches shareable for stock applications. --- .../lib/action_view/unbound_template.rb | 30 ++++++-- .../template/file_system_resolver_test.rb | 71 ++++++++++++++++--- railties/lib/rails/application.rb | 1 + railties/test/application/ractors_test.rb | 8 ++- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/actionview/lib/action_view/unbound_template.rb b/actionview/lib/action_view/unbound_template.rb index 3b73e72513ea3..273964fa6f29a 100644 --- a/actionview/lib/action_view/unbound_template.rb +++ b/actionview/lib/action_view/unbound_template.rb @@ -19,17 +19,29 @@ def initialize(source, identifier, details:, virtual_path:) end def bind_locals(locals) - @strict_locals_template || @templates[locals] || build_bound_template(locals) + if @strict_locals_template + @strict_locals_template + elsif frozen? + locals = normalize_locals(locals) + ractor_local_templates.compute_if_absent(locals) { build_template(locals) } + else + @templates[locals] || build_bound_template(locals) + end end def built_templates # :nodoc: - @strict_locals_template ? [@strict_locals_template] : @templates.values + if @strict_locals_template + [@strict_locals_template] + elsif @templates + @templates.values + else + templates = ractor_local_store[self] + templates ? templates.values : [] + end end def freeze # :nodoc: - unless bind_locals([]).strict_locals? - raise ArgumentError, "Cannot freeze #{@virtual_path.inspect}: templates must declare strict locals (e.g. `<%# locals: () %>`) to be frozen." - end + bind_locals([]) @source.freeze @identifier.freeze @virtual_path.freeze @@ -41,6 +53,14 @@ def freeze # :nodoc: end private + def ractor_local_templates + ractor_local_store.compute_if_absent(self) { Concurrent::Map.new } + end + + def ractor_local_store + ActiveSupport::Ractors.store_if_absent(:action_view_bound_templates) { Concurrent::Map.new } + end + def build_bound_template(locals) @write_lock.synchronize do return @strict_locals_template if @strict_locals_template diff --git a/actionview/test/template/file_system_resolver_test.rb b/actionview/test/template/file_system_resolver_test.rb index 00fee88ca5f5d..6d44b4834d07d 100644 --- a/actionview/test/template/file_system_resolver_test.rb +++ b/actionview/test/template/file_system_resolver_test.rb @@ -79,24 +79,31 @@ def test_freeze_after_eager_load_makes_resolver_shareable assert_predicate templates[0], :frozen? end - def test_freeze_raises_for_non_strict_partial + def test_freeze_keeps_non_strict_templates_renderable with_file "test/_card.html.erb", "<%= post %>" resolver = ActionView::FileSystemResolver.new(tmpdir) - resolver.eager_load_templates + resolver.eager_load_templates(compile_view) + resolver.freeze - error = assert_raises(ArgumentError) { resolver.freeze } - assert_match "test/_card", error.message - assert_match "strict locals", error.message + assert_ractor_shareable resolver + + template = find_all(resolver, "card", "test", true, [:post])[0] + assert_not_predicate template, :frozen? + assert_equal "hello", template.render(compile_view, { post: "hello" }) end - def test_freeze_raises_for_non_strict_template - with_file "test/hello_world.html.erb", "no locals here" + def test_frozen_non_strict_templates_are_cached_per_locals + with_file "test/_card.html.erb", "<%= post %>" resolver = ActionView::FileSystemResolver.new(tmpdir) - resolver.eager_load_templates + resolver.eager_load_templates(compile_view) + resolver.freeze - error = assert_raises(ArgumentError) { resolver.freeze } - assert_match "test/hello_world", error.message - assert_match "strict locals", error.message + a = find_all(resolver, "card", "test", true, [:post])[0] + b = find_all(resolver, "card", "test", true, [:post])[0] + c = find_all(resolver, "card", "test", true, [:post, :other])[0] + + assert_same a, b + assert_not_same a, c end def test_frozen_resolver_returns_empty_for_missing_template @@ -108,3 +115,45 @@ def test_frozen_resolver_returns_empty_for_missing_template assert_empty find_all(resolver, "nonexistent") end end + +class FileSystemResolverRactorTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + include ActiveSupport::Testing::RactorsAssertions + + # Compiling methods into FakeView from another Ractor might stop working with + # https://bugs.ruby-lang.org/issues/22226 but it is how non-strict templates could be + # compiled, with the view class container built in the main Ractor and other Ractors + # compiling methods into it. + class FakeView + def initialize(output_buffer) + @output_buffer = output_buffer + end + end + + test "non-strict templates compile inside a non-main Ractor" do + Dir.mktmpdir do |dir| + Dir.mkdir(File.join(dir, "test")) + File.write(File.join(dir, "test", "_card.html.erb"), "<%= post %>") + + Mime.eager_load! + ActionView::Template::Handlers::ERB.escape_ignore_list.freeze + + resolver = ActionView::FileSystemResolver.new(dir) + resolver.eager_load_templates + resolver.freeze + + rendered = on_ractor(resolver) do |resolver| + details = { locale: [:en].freeze, formats: [:html].freeze, variants: [].freeze, handlers: [:erb].freeze }.freeze + template = resolver.find_all("card", "test", true, details, nil, [:post])[0] + # compile! also instruments, which Notifications does not support + # from non-main Ractors yet; compile without instrumentation. + template.send(:compile, FakeView) + + buffer = ActionView::OutputBuffer.new + FakeView.new(buffer).send(template.method_name, { post: "hello" }, buffer).to_s + end + + assert_equal "hello", rendered + end + end +end diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index fdffbdd37cbbc..a2afb05c50f30 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -677,6 +677,7 @@ def ractorize! # :nodoc: view = ActionView::LookupContext.view_context_class.new(ActionView::LookupContext.new([]), {}, nil) ActionView::PathRegistry.all_file_system_resolvers.each do |resolver| resolver.eager_load_templates(view) + resolver.freeze end end diff --git a/railties/test/application/ractors_test.rb b/railties/test/application/ractors_test.rb index 7b86228afa397..0712c70698e51 100644 --- a/railties/test/application/ractors_test.rb +++ b/railties/test/application/ractors_test.rb @@ -41,8 +41,12 @@ def teardown ractorize! - templates = ActionView::PathRegistry.all_file_system_resolvers.flat_map(&:built_templates) - assert_not_empty templates + resolvers = ActionView::PathRegistry.all_file_system_resolvers + assert_not_empty resolvers + resolvers.each do |resolver| + assert_predicate resolver, :frozen? + assert_ractor_shareable resolver + end end test "error reporting works after the application is ractorized" do