From 82c1665b5afc7a4c6f450082169d0f7e11d9e99b Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 12 Oct 2026 16:12:44 +0200 Subject: [PATCH 1/7] Give each Ractor its own attribute cache so components render on non-main Ractors Rendering inside a non-main Ractor raised `can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor`: the cache is a FIFO (a Hash behind a Monitor), which a non-main Ractor can never read through a constant. `Phlex.attribute_cache` now returns ATTRIBUTE_CACHE on the main Ractor and a Ractor-local FIFO anywhere else (`Ractor.store_if_absent` on Ruby 3.4+, ractor-local storage before that), sized like the main one including the expansion for every view_template seen so far. It is resolved once per render into Phlex::SGML::State, so element methods pay an ivar read instead of a constant lookup and nothing per element touches Ractor storage. Not covered yet: `tag(:name, ...)` reads the element registries, which are mutable Hashes in module instance variables and so still unreadable from a non-main Ractor. Making them copy-on-write costs one transient Hash per registration at boot, which is enough to shift the 160-byte size pool across a page boundary before the first GC and park bench.rb's uncompiled run in a `nofree` major-GC loop (2x slower, 2 vs ~380 majors); that wants an explicit freeze step instead, in a follow-up. Text escaping goes through ERB::Escape, whose html_escape is only marked Ractor-safe since erb 5.0.2 (Ruby 4.0 ships one); the render test is gated on that. Before Ruby 4.0 a non-main Ractor cannot autoload, so on 3.2/3.3 eager-load Phlex first. --- lib/phlex.rb | 45 ++++++++++++++++++++++++-- lib/phlex/html.rb | 6 ++-- lib/phlex/sgml.rb | 2 +- lib/phlex/sgml/elements.rb | 6 ++-- lib/phlex/sgml/state.rb | 3 +- lib/phlex/svg.rb | 4 +-- quickdraw/ractor.test.rb | 65 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 quickdraw/ractor.test.rb diff --git a/lib/phlex.rb b/lib/phlex.rb index 576378dc..35bf2013 100644 --- a/lib/phlex.rb +++ b/lib/phlex.rb @@ -27,7 +27,15 @@ module Phlex DEPLOYED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) CACHED_FILES = Set.new - ATTRIBUTE_CACHE = FIFO.new(max_bytesize: 2 ** 20) + ATTRIBUTE_CACHE_MAX_BYTESIZE = 2 ** 20 + + # The main Ractor’s attribute cache. A FIFO is not shareable, so a non-main + # Ractor can’t read this constant — it gets its own through Phlex.attribute_cache. + ATTRIBUTE_CACHE = FIFO.new(max_bytesize: ATTRIBUTE_CACHE_MAX_BYTESIZE) + + # Bytes every attribute cache has been expanded by so far (see + # __expand_attribute_cache__), so a Ractor-local cache starts the same size. + @attribute_cache_expansion = 0 UNBOUND_INSTANCE_METHOD_METHOD = Module.instance_method(:instance_method) @@ -49,10 +57,43 @@ def self.__expand_attribute_cache__(file_path) unless CACHED_FILES.include?(file_path) CACHED_FILES << file_path - Phlex::ATTRIBUTE_CACHE.expand(File.size(file_path)) + bytes = File.size(file_path) + @attribute_cache_expansion += bytes + Phlex::ATTRIBUTE_CACHE.expand(bytes) + end + end + + # The attribute cache for the current Ractor: ATTRIBUTE_CACHE on the main + # Ractor, a Ractor-local FIFO anywhere else (sized like the main one, + # including the expansion for every view_template seen so far). Resolved + # once per render into Phlex::SGML::State, never per element. + # + # Before Ruby 4.0 a non-main Ractor can’t autoload, so on Ruby 3.2 and 3.3 + # call `Phlex::Loader.eager_load` before spawning one. + if Ractor.respond_to?(:store_if_absent) # Ruby 3.4+ + def self.attribute_cache + if Ractor.main? + ATTRIBUTE_CACHE + else + Ractor.store_if_absent(:__phlex_attribute_cache__) { __new_attribute_cache__ } + end + end + else + def self.attribute_cache + ractor = Ractor.current + + if ractor == Ractor.main + ATTRIBUTE_CACHE + else + ractor[:__phlex_attribute_cache__] ||= __new_attribute_cache__ + end end end + def self.__new_attribute_cache__ + FIFO.new(max_bytesize: ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion) + end + # Generate an HTML string using Phlex’ HTML DSL def self.html(&block) HTML.call do |component| diff --git a/lib/phlex/html.rb b/lib/phlex/html.rb index 01905a73..be62fff8 100644 --- a/lib/phlex/html.rb +++ b/lib/phlex/html.rb @@ -58,7 +58,7 @@ def tag(name, **attributes, &) if (tag = StandardElements.__registered_elements__[name]) || ((tag = name.name.tr("_", "-")).include?("-") && tag.match?(/\A[a-z][a-z0-9-]*\z/)) if attributes.length > 0 # with attributes if block_given # with content block - buffer << "<#{tag}" << Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" begin if tag == "svg" render Phlex::SVG.new(&) @@ -69,7 +69,7 @@ def tag(name, **attributes, &) buffer << "" end else # without content - buffer << "<#{tag}" << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" end else # without attributes if block_given # with content block @@ -99,7 +99,7 @@ def tag(name, **attributes, &) Phlex::SGML::Elements.public_send(normalizer, attributes) end - buffer << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end diff --git a/lib/phlex/sgml.rb b/lib/phlex/sgml.rb index 22dd6a51..43214095 100644 --- a/lib/phlex/sgml.rb +++ b/lib/phlex/sgml.rb @@ -434,7 +434,7 @@ def json_escape(string) end private def __attributes__(attributes) - Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + @_state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } end private_class_method def self.method_added(method_name) diff --git a/lib/phlex/sgml/elements.rb b/lib/phlex/sgml/elements.rb index 91ca9b7c..c1c52fe8 100644 --- a/lib/phlex/sgml/elements.rb +++ b/lib/phlex/sgml/elements.rb @@ -94,7 +94,7 @@ def #{method_name}(**attributes) buffer << "<#{tag}" begin #{Phlex::SGML::Elements.normalizer_call(method_name)} - buffer << Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end @@ -125,7 +125,7 @@ def #{method_name}(**attributes) buffer << "<#{tag}" begin #{Phlex::SGML::Elements.normalizer_call(method_name)} - buffer << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end @@ -191,7 +191,7 @@ def #{method_name}(**attributes) buffer << "<#{tag}" begin #{Phlex::SGML::Elements.normalizer_call(method_name)} - buffer << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } + buffer << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } ensure buffer << ">" end diff --git a/lib/phlex/sgml/state.rb b/lib/phlex/sgml/state.rb index f05519d9..0c82d04d 100644 --- a/lib/phlex/sgml/state.rb +++ b/lib/phlex/sgml/state.rb @@ -12,11 +12,12 @@ def initialize(user_context: {}, output_buffer:, fragments:) @cache_stack = [] @halt_signal = nil @output_buffer = output_buffer + @attribute_cache = Phlex.attribute_cache end attr_accessor :capturing, :user_context - attr_reader :fragments, :fragment_depth, :output_buffer, :buffer, :should_render + attr_reader :fragments, :fragment_depth, :output_buffer, :buffer, :should_render, :attribute_cache # Kept as a stored boolean so the check is a plain attribute read. alias_method :should_render?, :should_render diff --git a/lib/phlex/svg.rb b/lib/phlex/svg.rb index a8db0dfb..c5e0a3e4 100644 --- a/lib/phlex/svg.rb +++ b/lib/phlex/svg.rb @@ -46,14 +46,14 @@ def tag(name, **attributes, &) if (tag = StandardElements.__registered_elements__[name]) || ((tag = name.name.tr("_", "-")).include?("-") && tag.match?(/\A[a-z][a-z0-9-]*\z/)) if attributes.length > 0 # with attributes if block_given # with content block - buffer << "<#{tag}" << Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" begin __yield_content__(&) ensure buffer << "" end else # without content - buffer << "<#{tag}" << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">" end else # without attributes if block_given # with content block diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb new file mode 100644 index 00000000..06421f9d --- /dev/null +++ b/quickdraw/ractor.test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class RactorTest < Quickdraw::Test + class Example < Phlex::HTML + register_element :trix_editor + + def view_template + div(class: "card", id: "example") do + span(data: { count: 1 }) { "Hello" } + img(src: "/a.png", alt: "a") + trix_editor(class: "custom") { "Custom" } + svg(viewBox: "0 0 1 1") { |s| s.path(d: "M0 0") } + end + end + end + + def self.run_in_ractor(&) + experimental = Warning[:experimental] + Warning[:experimental] = false + ractor = Ractor.new(&) + ractor.respond_to?(:value) ? ractor.value : ractor.take + ensure + Warning[:experimental] = experimental + end + + def run_in_ractor(&) = self.class.run_in_ractor(&) + + # Phlex escapes text through ERB::Escape, whose html_escape is only marked + # Ractor-safe since erb 5.0.2 — whether the render test runs depends on the + # erb in the bundle (Ruby 4.0 ships a safe one; Ruby 3.3 and 3.4 do not). + ERB_ESCAPE_RACTOR_SAFE = + Ractor.respond_to?(:store_if_absent) && begin + report = Thread.report_on_exception + Thread.report_on_exception = false + run_in_ractor { ERB::Escape.html_escape("<") } == "<" + rescue Ractor::Error + false + ensure + Thread.report_on_exception = report + end + + test "the main Ractor uses ATTRIBUTE_CACHE" do + assert_equal Phlex.attribute_cache.equal?(Phlex::ATTRIBUTE_CACHE), true + end + + if Ractor.respond_to?(:store_if_absent) + test "a non-main Ractor gets its own attribute cache" do + main_cache_id = Phlex::ATTRIBUTE_CACHE.object_id + ractor_cache_ids = run_in_ractor { [Phlex.attribute_cache.object_id, Phlex.attribute_cache.object_id] } + + assert_equal ractor_cache_ids.uniq.size, 1 + assert_equal ractor_cache_ids.include?(main_cache_id), false + end + + test "a non-main Ractor's cache is as large as the main one" do + assert_equal run_in_ractor { Phlex.attribute_cache.max_bytesize }, Phlex::ATTRIBUTE_CACHE.max_bytesize + end + end + + if ERB_ESCAPE_RACTOR_SAFE + test "renders inside a non-main Ractor with the same output" do + assert_equal run_in_ractor { Example.call }, Example.call + end + end +end From 1aff532e37dbc48c9c334228ef353b6ba0c35138 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 12 Oct 2026 17:06:59 +0200 Subject: [PATCH 2/7] Load on Rubies without Ractor (TruffleRuby, JRuby) lib/phlex.rb called Ractor.respond_to? at load time, so on TruffleRuby, which has no Ractor constant, requiring Phlex raised NameError. Without Ractors there is only ever one cache, so attribute_cache returns ATTRIBUTE_CACHE; the Ractor tests are skipped there. --- lib/phlex.rb | 9 +++++++-- quickdraw/ractor.test.rb | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/phlex.rb b/lib/phlex.rb index 35bf2013..7132a20e 100644 --- a/lib/phlex.rb +++ b/lib/phlex.rb @@ -69,8 +69,13 @@ def self.__expand_attribute_cache__(file_path) # once per render into Phlex::SGML::State, never per element. # # Before Ruby 4.0 a non-main Ractor can’t autoload, so on Ruby 3.2 and 3.3 - # call `Phlex::Loader.eager_load` before spawning one. - if Ractor.respond_to?(:store_if_absent) # Ruby 3.4+ + # call `Phlex::Loader.eager_load` before spawning one. Rubies without Ractors + # (TruffleRuby, JRuby) always use ATTRIBUTE_CACHE. + if !defined?(::Ractor) + def self.attribute_cache + ATTRIBUTE_CACHE + end + elsif ::Ractor.respond_to?(:store_if_absent) # Ruby 3.4+ def self.attribute_cache if Ractor.main? ATTRIBUTE_CACHE diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb index 06421f9d..7530ba09 100644 --- a/quickdraw/ractor.test.rb +++ b/quickdraw/ractor.test.rb @@ -25,15 +25,18 @@ def self.run_in_ractor(&) def run_in_ractor(&) = self.class.run_in_ractor(&) + # Ractor-local storage needs Ruby 3.4+; TruffleRuby and JRuby have no Ractors. + RACTORS = defined?(::Ractor) && ::Ractor.respond_to?(:store_if_absent) + # Phlex escapes text through ERB::Escape, whose html_escape is only marked # Ractor-safe since erb 5.0.2 — whether the render test runs depends on the # erb in the bundle (Ruby 4.0 ships a safe one; Ruby 3.3 and 3.4 do not). ERB_ESCAPE_RACTOR_SAFE = - Ractor.respond_to?(:store_if_absent) && begin + RACTORS && begin report = Thread.report_on_exception Thread.report_on_exception = false run_in_ractor { ERB::Escape.html_escape("<") } == "<" - rescue Ractor::Error + rescue ::Ractor::Error false ensure Thread.report_on_exception = report @@ -43,7 +46,7 @@ def run_in_ractor(&) = self.class.run_in_ractor(&) assert_equal Phlex.attribute_cache.equal?(Phlex::ATTRIBUTE_CACHE), true end - if Ractor.respond_to?(:store_if_absent) + if RACTORS test "a non-main Ractor gets its own attribute cache" do main_cache_id = Phlex::ATTRIBUTE_CACHE.object_id ractor_cache_ids = run_in_ractor { [Phlex.attribute_cache.object_id, Phlex.attribute_cache.object_id] } From e803f75e28dd44357edf92fc9fcbefda411fb1d8 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 12 Oct 2026 17:10:56 +0200 Subject: [PATCH 3/7] Grow Ractor-local attribute caches with templates defined later __expand_attribute_cache__ only grows ATTRIBUTE_CACHE and the expansion counter, so a Ractor whose cache already existed kept the size it was created with: in a lazily loaded app whose later templates pass the 1 MiB floor, worker Ractors would evict and regenerate attributes the main one keeps. A Ractor-local cache now catches up when it is next fetched, which is once per render; the main Ractor's path is unchanged. --- lib/phlex.rb | 13 +++++++++++-- quickdraw/ractor.test.rb | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/phlex.rb b/lib/phlex.rb index 7132a20e..796b3220 100644 --- a/lib/phlex.rb +++ b/lib/phlex.rb @@ -80,7 +80,7 @@ def self.attribute_cache if Ractor.main? ATTRIBUTE_CACHE else - Ractor.store_if_absent(:__phlex_attribute_cache__) { __new_attribute_cache__ } + __sized_attribute_cache__(Ractor.store_if_absent(:__phlex_attribute_cache__) { __new_attribute_cache__ }) end end else @@ -90,7 +90,7 @@ def self.attribute_cache if ractor == Ractor.main ATTRIBUTE_CACHE else - ractor[:__phlex_attribute_cache__] ||= __new_attribute_cache__ + __sized_attribute_cache__(ractor[:__phlex_attribute_cache__] ||= __new_attribute_cache__) end end end @@ -99,6 +99,15 @@ def self.__new_attribute_cache__ FIFO.new(max_bytesize: ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion) end + # __expand_attribute_cache__ only reaches the main cache, so a Ractor-local + # cache catches up with templates defined since it was created when it's + # next fetched (once per render). + def self.__sized_attribute_cache__(cache) + shortfall = ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion - cache.max_bytesize + cache.expand(shortfall) if shortfall > 0 + cache + end + # Generate an HTML string using Phlex’ HTML DSL def self.html(&block) HTML.call do |component| diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb index 7530ba09..f6186f6b 100644 --- a/quickdraw/ractor.test.rb +++ b/quickdraw/ractor.test.rb @@ -58,6 +58,28 @@ def run_in_ractor(&) = self.class.run_in_ractor(&) test "a non-main Ractor's cache is as large as the main one" do assert_equal run_in_ractor { Phlex.attribute_cache.max_bytesize }, Phlex::ATTRIBUTE_CACHE.max_bytesize end + + test "a non-main Ractor's cache grows with templates defined after it was created" do + experimental = Warning[:experimental] + Warning[:experimental] = false + ractor = Ractor.new do + Ractor.main << Phlex.attribute_cache.max_bytesize # before + Ractor.receive + Phlex.attribute_cache.max_bytesize # after + end + + before = Ractor.receive + Phlex.__expand_attribute_cache__(File.expand_path("../bench.rb", __dir__)) + expected = Phlex::ATTRIBUTE_CACHE.max_bytesize + + ractor << :go + after = ractor.respond_to?(:value) ? ractor.value : ractor.take + + assert_equal after, expected + assert_equal after > before, true + ensure + Warning[:experimental] = experimental + end end if ERB_ESCAPE_RACTOR_SAFE From 4289d5908a65b07d13635eb66bd87870bc176ff2 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 12 Oct 2026 17:31:28 +0200 Subject: [PATCH 4/7] Render on worker Ractors with lazy compilation enabled With Phlex::Compiler.enable!, every render went through compile_on_first_render, which takes Compiler::MUTEX before checking whether the component is already compiled. A non-main Ractor can't read a Mutex through a constant, so compiler-enabled apps couldn't render there at all. A compiled component now skips the lock (the flag is only set once its compilation has finished), and only the main Ractor compiles: workers render whatever is defined, compiled or not. --- lib/phlex/compiler.rb | 16 +++++++++++++++- quickdraw/ractor.test.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/phlex/compiler.rb b/lib/phlex/compiler.rb index 33cf8527..6affd0ba 100644 --- a/lib/phlex/compiler.rb +++ b/lib/phlex/compiler.rb @@ -91,10 +91,24 @@ def self.compiled?(component) component.instance_variable_get(:@__phlex_compiled__) == true end + # Only the main Ractor compiles: compiling redefines methods and needs MUTEX, + # neither of which a non-main Ractor can touch. Those render whatever is + # defined, compiled or not. + if !defined?(::Ractor) + def self.main_ractor? = true + elsif ::Ractor.respond_to?(:main?) + def self.main_ractor? = ::Ractor.main? + else + def self.main_ractor? = ::Ractor.current == ::Ractor.main + end + # The failure check and the compilation share the lock, so two first # renders racing each other still compile and report once. The handler - # runs outside it, since it may well render something. + # runs outside it, since it may well render something. A compiled + # component skips the lock: the flag is set once compilation has finished. def self.compile_on_first_render(component) + return if compiled?(component) || !main_ractor? + error = MUTEX.synchronize do return if compiled?(component) || FAILURES.key?(component) diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb index f6186f6b..501ed098 100644 --- a/quickdraw/ractor.test.rb +++ b/quickdraw/ractor.test.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "tmpdir" + class RactorTest < Quickdraw::Test class Example < Phlex::HTML register_element :trix_editor @@ -86,5 +88,32 @@ def run_in_ractor(&) = self.class.run_in_ractor(&) test "renders inside a non-main Ractor with the same output" do assert_equal run_in_ractor { Example.call }, Example.call end + + test "renders inside a non-main Ractor with lazy compilation enabled" do + Dir.mktmpdir do |dir| + path = File.join(dir, "ractor_lazy.rb") + File.write(path, <<~RUBY) + # frozen_string_literal: true + class RactorLazyCompiled < Phlex::HTML + def view_template = div(class: "compiled") { "compiled" } + end + + class RactorLazyUncompiled < Phlex::HTML + def view_template = div(class: "uncompiled") { "uncompiled" } + end + RUBY + load path + + Phlex::Compiler.enable! + begin + expected = RactorLazyCompiled.call # the main Ractor compiles it + + assert_equal run_in_ractor { RactorLazyCompiled.call }, expected + assert_equal run_in_ractor { RactorLazyUncompiled.call }, %(
uncompiled
) + ensure + Phlex::Compiler.disable! + end + end + end end end From 63ea2e0ee8984dd39dda78b6eb6749aff8feb281 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 12 Oct 2026 17:45:57 +0200 Subject: [PATCH 5/7] Read compiled source maps and resize caches safely on worker Ractors SGML#cache (through Phlex.__source_line__) and the exception mapping in compiled code read COMPILED_SOURCE_MAPS, a mutable Hash in a constant that a non-main Ractor can't touch: a cached fragment couldn't render there, and an error raised by a compiled component came out as Ractor::IsolationError. The compiler, which only runs on the main Ractor under its lock, now also publishes a frozen, shareable copy after each write, and both readers use it. A Ractor-local attribute cache was topped up with compare-then-expand, so threads in one worker racing past a stale size could each add the shortfall. FIFO#grow_to raises the limit to a target under the FIFO's lock instead. --- lib/phlex.rb | 17 ++++++++++++++--- lib/phlex/compiler.rb | 5 ++++- lib/phlex/fifo.rb | 8 ++++++++ lib/phlex/sgml.rb | 2 +- quickdraw/fifo.test.rb | 19 +++++++++++++++++++ quickdraw/ractor.test.rb | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lib/phlex.rb b/lib/phlex.rb index 796b3220..d61c894d 100644 --- a/lib/phlex.rb +++ b/lib/phlex.rb @@ -43,9 +43,20 @@ module Phlex # Lives here so the runtime can consult it without loading the compiler. COMPILED_SOURCE_MAPS = {} + # A frozen, Ractor-shareable copy of COMPILED_SOURCE_MAPS, republished by the + # compiler after each write, so renders on any Ractor can read it. + @compiled_source_maps = {}.freeze + + def self.__compiled_source_maps__ = @compiled_source_maps + + # Only the compiler calls this, on the main Ractor and under its lock. + def self.__publish_compiled_source_map__(path, generation) + @compiled_source_maps = @compiled_source_maps.merge(path => generation).freeze + end + # The original line for a location that may be in compiled code. def self.__source_line__(location) - if (generation = COMPILED_SOURCE_MAPS[location.path]) + if (generation = @compiled_source_maps[location.path]) generation.lines[location.lineno] || location.lineno else location.lineno @@ -103,8 +114,8 @@ def self.__new_attribute_cache__ # cache catches up with templates defined since it was created when it's # next fetched (once per render). def self.__sized_attribute_cache__(cache) - shortfall = ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion - cache.max_bytesize - cache.expand(shortfall) if shortfall > 0 + target = ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion + cache.grow_to(target) if cache.max_bytesize < target cache end diff --git a/lib/phlex/compiler.rb b/lib/phlex/compiler.rb index 6affd0ba..8111b2bc 100644 --- a/lib/phlex/compiler.rb +++ b/lib/phlex/compiler.rb @@ -197,7 +197,10 @@ def self.compile_file(path, components: loaded_components, recompile: false, inl formatting_result.source_map.each_with_index do |original_line, generated_line| lines[generated_line] = original_line if original_line end - MAP[compiled_path] = Generation.new(path:, lines: lines.freeze) + generation = Generation.new(path: -path, lines: lines.freeze) + generation = ::Ractor.make_shareable(generation) if defined?(::Ractor) + MAP[compiled_path] = generation + Phlex.__publish_compiled_source_map__(-compiled_path, generation) results.each do |result| result.visibilities.each do |name, visibility| diff --git a/lib/phlex/fifo.rb b/lib/phlex/fifo.rb index 51a22349..cf9df852 100644 --- a/lib/phlex/fifo.rb +++ b/lib/phlex/fifo.rb @@ -19,6 +19,14 @@ def expand(bytes) end end + # Raises the limit to `bytes` unless it's already that large. Unlike `expand`, + # callers racing to the same target grow it once. + def grow_to(bytes) + @mutex.synchronize do + @max_bytesize = bytes if bytes > @max_bytesize + end + end + def [](key) k, v = @store[key.hash] v if k.eql?(key) diff --git a/lib/phlex/sgml.rb b/lib/phlex/sgml.rb index 43214095..4c2fe887 100644 --- a/lib/phlex/sgml.rb +++ b/lib/phlex/sgml.rb @@ -503,7 +503,7 @@ def __map_exception__(exception) exception.set_backtrace( locations.map do |loc| - if (generation = Phlex::COMPILED_SOURCE_MAPS[loc.path]) && (line = generation.lines[loc.lineno]) + if (generation = Phlex.__compiled_source_maps__[loc.path]) && (line = generation.lines[loc.lineno]) "#{generation.path}:#{line}:in '#{loc.label}'" else loc.to_s diff --git a/quickdraw/fifo.test.rb b/quickdraw/fifo.test.rb index df047a1f..cbaa5b1d 100644 --- a/quickdraw/fifo.test.rb +++ b/quickdraw/fifo.test.rb @@ -96,4 +96,23 @@ class FIFOTest < Quickdraw::Test assert_equal fifo.fetch(1) { "a" * 11 }, "a" * 11 assert_equal fifo.size, 0 end + + test "grow_to raises the limit to a target and never lowers it" do + fifo = Phlex::FIFO.new(max_bytesize: 10) + + fifo.grow_to(25) + assert_equal fifo.max_bytesize, 25 + + fifo.grow_to(25) + fifo.grow_to(5) + assert_equal fifo.max_bytesize, 25 + end + + test "grow_to from racing threads grows to the target once" do + fifo = Phlex::FIFO.new(max_bytesize: 10) + + Array.new(8) { Thread.new { 100.times { fifo.grow_to(25) } } }.each(&:join) + + assert_equal fifo.max_bytesize, 25 + end end diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb index 501ed098..efe1d8c2 100644 --- a/quickdraw/ractor.test.rb +++ b/quickdraw/ractor.test.rb @@ -16,6 +16,13 @@ def view_template end end + class Cached < Phlex::HTML + # One store per Ractor: a FIFOCacheStore can't be shared between them. + def cache_store = Ractor.store_if_absent(:ractor_test_cache_store) { Phlex::FIFOCacheStore.new } + + def view_template = cache { div { "cached" } } + end + def self.run_in_ractor(&) experimental = Warning[:experimental] Warning[:experimental] = false @@ -89,6 +96,35 @@ def run_in_ractor(&) = self.class.run_in_ractor(&) assert_equal run_in_ractor { Example.call }, Example.call end + test "caches a fragment inside a non-main Ractor" do + assert_equal run_in_ractor { [Cached.call, Cached.call] }, [%(
cached
)] * 2 + end + + test "a compiled component's error keeps its class inside a non-main Ractor" do + Dir.mktmpdir do |dir| + path = File.join(dir, "ractor_raising.rb") + File.write(path, <<~RUBY) + # frozen_string_literal: true + class RactorRaising < Phlex::HTML + def view_template + div { raise ArgumentError, "boom" } + end + end + RUBY + load path + Phlex::Compiler.compile(RactorRaising) + + error = begin + run_in_ractor { RactorRaising.call } + nil + rescue Ractor::RemoteError => e + e.cause + end + + assert_equal [error.class, error.message], [ArgumentError, "boom"] + end + end + test "renders inside a non-main Ractor with lazy compilation enabled" do Dir.mktmpdir do |dir| path = File.join(dir, "ractor_lazy.rb") From 350b633c00ebe7f44764a9d70bac5a7a83635071 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 12 Oct 2026 19:12:16 +0200 Subject: [PATCH 6/7] Render tag(:name, ...) on worker Ractors by freezing the element registries tag looks names up in the built-in modules' element registries, mutable Hashes in module instance variables that a non-main Ractor can't read. Each built-in module now ends its body with __freeze_registries__, which makes both registries shareable in place: no copy, and nothing changes for the registrations before it. A registration on a registry that is already frozen swaps in a frozen copy instead of mutating it, and stores a frozen copy of the tag so the caller's String is never frozen. Component classes' own registries are never frozen. --- lib/phlex/html/standard_elements.rb | 2 + lib/phlex/html/void_elements.rb | 2 + lib/phlex/sgml/elements.rb | 45 ++++++++++-- lib/phlex/svg/standard_elements.rb | 2 + quickdraw/ractor.test.rb | 22 ++++++ quickdraw/sgml/element_registry.test.rb | 94 +++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 quickdraw/sgml/element_registry.test.rb diff --git a/lib/phlex/html/standard_elements.rb b/lib/phlex/html/standard_elements.rb index 1bff3552..f969e902 100644 --- a/lib/phlex/html/standard_elements.rb +++ b/lib/phlex/html/standard_elements.rb @@ -630,4 +630,6 @@ module Phlex::HTML::StandardElements # [MDN Docs](https://developer.mozilla.org/docs/Web/HTML/Element/wbr) # [Spec](https://html.spec.whatwg.org/#the-wbr-element) register_element def wbr(**attributes, &content) = nil + + __freeze_registries__ end diff --git a/lib/phlex/html/void_elements.rb b/lib/phlex/html/void_elements.rb index 3eeb2ca3..c2333df3 100644 --- a/lib/phlex/html/void_elements.rb +++ b/lib/phlex/html/void_elements.rb @@ -63,4 +63,6 @@ module Phlex::HTML::VoidElements # [MDN Docs](https://developer.mozilla.org/docs/Web/HTML/Element/track) # [Spec](https://html.spec.whatwg.org/#the-track-element) __register_void_element__ def track(**attributes) = nil + + __freeze_registries__ end diff --git a/lib/phlex/sgml/elements.rb b/lib/phlex/sgml/elements.rb index c1c52fe8..b7f390f3 100644 --- a/lib/phlex/sgml/elements.rb +++ b/lib/phlex/sgml/elements.rb @@ -72,6 +72,16 @@ def __registered_void_elements__ @__registered_void_elements__ ||= {} end + # The built-in element modules call this once their bodies have registered + # every element. It freezes both registries in place, without copying them, + # so `tag` can read them from any Ractor. A later registration on a frozen + # registry swaps in a frozen copy instead of mutating it. + def __freeze_registries__ + __freeze_registry__(__registered_elements__) + __freeze_registry__(__registered_void_elements__) + nil + end + def register_element(method_name, tag: method_name.name.tr("_", "-")) # Aliasing the method to itself suppresses Ruby's method redefinition warning. alias_method(method_name, method_name) if method_defined?(method_name) @@ -167,8 +177,7 @@ def #{method_name}(**attributes) end RUBY - __registered_elements__[method_name] = tag - __registered_void_elements__.delete(method_name) + __record_element__(method_name, tag, void: false) method_name end @@ -203,9 +212,37 @@ def #{method_name}(**attributes) end RUBY - __registered_elements__[method_name] = tag - __registered_void_elements__[method_name] = tag + __record_element__(method_name, tag, void: true) method_name end + + private def __record_element__(method_name, tag, void:) + elements = __registered_elements__ + void_elements = __registered_void_elements__ + + if elements.frozen? || void_elements.frozen? + tag = -tag.to_s + @__registered_elements__ = __freeze_registry__(elements.merge(method_name => tag)) + @__registered_void_elements__ = __freeze_registry__( + void ? void_elements.merge(method_name => tag) : void_elements.except(method_name) + ) + else + elements[method_name] = tag + + if void + void_elements[method_name] = tag + else + void_elements.delete(method_name) + end + end + end + + private def __freeze_registry__(registry) + if defined?(::Ractor) + ::Ractor.make_shareable(registry) + else + registry.each_value(&:freeze).freeze + end + end end diff --git a/lib/phlex/svg/standard_elements.rb b/lib/phlex/svg/standard_elements.rb index 17fe24d1..76ceafad 100644 --- a/lib/phlex/svg/standard_elements.rb +++ b/lib/phlex/svg/standard_elements.rb @@ -418,4 +418,6 @@ module Phlex::SVG::StandardElements **attributes, &content ) = nil + + __freeze_registries__ end diff --git a/quickdraw/ractor.test.rb b/quickdraw/ractor.test.rb index efe1d8c2..822c9a60 100644 --- a/quickdraw/ractor.test.rb +++ b/quickdraw/ractor.test.rb @@ -10,12 +10,23 @@ def view_template div(class: "card", id: "example") do span(data: { count: 1 }) { "Hello" } img(src: "/a.png", alt: "a") + tag(:p, class: "dynamic") { "Dynamic" } trix_editor(class: "custom") { "Custom" } svg(viewBox: "0 0 1 1") { |s| s.path(d: "M0 0") } end end end + class TagExample < Phlex::HTML + def view_template + tag(:div, class: "a") { "x" } + tag(:img, src: "/a.png", srcset: ["/a.png 1x", "/b.png 2x"]) + tag(:my_widget, data: { k: 1 }) { "w" } + tag(:svg, viewBox: "0 0 1 1") { |s| s.tag(:path, d: "M0 0") } + tag(:br) + end + end + class Cached < Phlex::HTML # One store per Ractor: a FIFOCacheStore can't be shared between them. def cache_store = Ractor.store_if_absent(:ractor_test_cache_store) { Phlex::FIFOCacheStore.new } @@ -56,6 +67,13 @@ def run_in_ractor(&) = self.class.run_in_ractor(&) end if RACTORS + test "the built-in element registries are shareable" do + [Phlex::HTML::StandardElements, Phlex::HTML::VoidElements, Phlex::SVG::StandardElements].each do |elements| + assert Ractor.shareable?(elements.__registered_elements__) + assert Ractor.shareable?(elements.__registered_void_elements__) + end + end + test "a non-main Ractor gets its own attribute cache" do main_cache_id = Phlex::ATTRIBUTE_CACHE.object_id ractor_cache_ids = run_in_ractor { [Phlex.attribute_cache.object_id, Phlex.attribute_cache.object_id] } @@ -96,6 +114,10 @@ def run_in_ractor(&) = self.class.run_in_ractor(&) assert_equal run_in_ractor { Example.call }, Example.call end + test "tag renders inside a non-main Ractor" do + assert_equal run_in_ractor { TagExample.call }, TagExample.call + end + test "caches a fragment inside a non-main Ractor" do assert_equal run_in_ractor { [Cached.call, Cached.call] }, [%(
cached
)] * 2 end diff --git a/quickdraw/sgml/element_registry.test.rb b/quickdraw/sgml/element_registry.test.rb new file mode 100644 index 00000000..eb0d128f --- /dev/null +++ b/quickdraw/sgml/element_registry.test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class ElementRegistryTest < Quickdraw::Test + BUILT_INS = [ + Phlex::HTML::StandardElements, + Phlex::HTML::VoidElements, + Phlex::SVG::StandardElements, + ].freeze + + def new_elements_module + Module.new { extend Phlex::SGML::Elements } + end + + test "the built-in registries are frozen once their elements are registered" do + BUILT_INS.each do |elements| + assert elements.__registered_elements__.frozen? + assert elements.__registered_void_elements__.frozen? + assert elements.__registered_elements__.each_value.all?(&:frozen?) + end + end + + test "registering after the freeze replaces the registry with a frozen copy" do + elements = new_elements_module + elements.register_element :foo_bar + elements.__freeze_registries__ + before = elements.__registered_elements__ + + elements.register_element :baz_qux + + assert_equal elements.__registered_elements__, { foo_bar: "foo-bar", baz_qux: "baz-qux" } + assert elements.__registered_elements__.frozen? + assert_equal before, { foo_bar: "foo-bar" } + assert before.frozen? + end + + test "registering a void element after the freeze updates both registries" do + elements = new_elements_module + elements.__register_void_element__ :foo_bar + elements.__freeze_registries__ + + elements.__register_void_element__ :baz_qux + + assert_equal elements.__registered_elements__, { foo_bar: "foo-bar", baz_qux: "baz-qux" } + assert_equal elements.__registered_void_elements__, { foo_bar: "foo-bar", baz_qux: "baz-qux" } + assert elements.__registered_void_elements__.frozen? + end + + test "re-registering a void element as standard after the freeze removes it from the void registry" do + elements = new_elements_module + elements.__register_void_element__ :foo_bar + elements.__freeze_registries__ + + elements.register_element :foo_bar + + assert_equal elements.__registered_void_elements__, {} + assert elements.__registered_void_elements__.frozen? + end + + test "registering after the freeze never freezes the caller's tag" do + elements = new_elements_module + elements.__freeze_registries__ + tag = +"my-tag" + + elements.register_element(:my_tag, tag:) + elements.register_element :other_tag, tag: :"other-tag" + + refute tag.frozen? + assert_equal elements.__registered_elements__, { my_tag: "my-tag", other_tag: "other-tag" } + end + + if defined?(::Ractor) + test "registries stay shareable after a registration that follows the freeze" do + elements = new_elements_module + elements.register_element :foo_bar + elements.__freeze_registries__ + elements.register_element :baz_qux + + assert Ractor.shareable?(elements.__registered_elements__) + assert Ractor.shareable?(elements.__registered_void_elements__) + end + end + + test "a component's own registry stays mutable" do + component = Class.new(Phlex::HTML) do + register_element :trix_editor + + def view_template = trix_editor { "Hello" } + end + + refute component.__registered_elements__.frozen? + assert_equal component.__registered_elements__, { trix_editor: "trix-editor" } + assert_equal component.call, "Hello" + end +end From 3dabb933287d77f240a7f3c97ea8f634b1ac2638 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Tue, 13 Oct 2026 17:50:02 +0200 Subject: [PATCH 7/7] Keep main-Ractor renders waiting for a recompilation 4289d59 let any compiled component skip Compiler::MUTEX, on the main Ractor too. A recompilation (an element or helper override) holds that lock but leaves the compiled flag set, so a render starting on another main-Ractor thread meanwhile ran the old inlined method instead of waiting. Only worker Ractors, which can't take the lock or compile, skip it now; the main Ractor's path is the one it had before this PR. --- lib/phlex/compiler.rb | 8 +++--- quickdraw/compiler/lifecycle.test.rb | 39 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/phlex/compiler.rb b/lib/phlex/compiler.rb index 8111b2bc..247c5292 100644 --- a/lib/phlex/compiler.rb +++ b/lib/phlex/compiler.rb @@ -103,11 +103,11 @@ def self.main_ractor? = ::Ractor.current == ::Ractor.main end # The failure check and the compilation share the lock, so two first - # renders racing each other still compile and report once. The handler - # runs outside it, since it may well render something. A compiled - # component skips the lock: the flag is set once compilation has finished. + # renders racing each other still compile and report once, and a render + # that starts while a recompilation holds it waits for the new methods. + # The handler runs outside it, since it may well render something. def self.compile_on_first_render(component) - return if compiled?(component) || !main_ractor? + return unless main_ractor? error = MUTEX.synchronize do return if compiled?(component) || FAILURES.key?(component) diff --git a/quickdraw/compiler/lifecycle.test.rb b/quickdraw/compiler/lifecycle.test.rb index cdcb9a60..df5e3b37 100644 --- a/quickdraw/compiler/lifecycle.test.rb +++ b/quickdraw/compiler/lifecycle.test.rb @@ -81,6 +81,45 @@ def footer_content = span { "Footer" } end end + test "a lazy render of a compiled component waits for a recompilation in progress" do + with_component_files( + "waits.rb" => <<~RUBY + # frozen_string_literal: true + class LifecycleWaits < Phlex::HTML + def view_template = div { "waits" } + end + RUBY + ) do + Phlex::Compiler.enable! + begin + LifecycleWaits.call + assert Phlex::Compiler.compiled?(LifecycleWaits) + + locked = Queue.new + release = Queue.new + recompiling = Thread.new do + Phlex::Compiler::MUTEX.synchronize do + locked << true + release.pop + end + end + locked.pop + + render = Thread.new { LifecycleWaits.call } + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2 + Thread.pass until render.status == "sleep" || !render.alive? || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + + assert render.alive? + release << true + assert_equal render.value, "
waits
" + recompiling.join + ensure + release&.<< true + Phlex::Compiler.disable! + end + end + end + test "lazy compilation compiles a component on its first render" do with_component_files( "lazy.rb" => <<~RUBY