diff --git a/lib/phlex.rb b/lib/phlex.rb index 576378dc..d61c894d 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) @@ -35,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 @@ -49,8 +68,55 @@ 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. 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 + else + __sized_attribute_cache__(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 + __sized_attribute_cache__(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 + + # __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) + target = ATTRIBUTE_CACHE_MAX_BYTESIZE + @attribute_cache_expansion + cache.grow_to(target) if cache.max_bytesize < target + cache end # Generate an HTML string using Phlex’ HTML DSL diff --git a/lib/phlex/compiler.rb b/lib/phlex/compiler.rb index 33cf8527..247c5292 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. + # 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 unless main_ractor? + error = MUTEX.synchronize do return if compiled?(component) || FAILURES.key?(component) @@ -183,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/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 << "#{tag}>" end else # without content - buffer << "<#{tag}" << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">#{tag}>" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">#{tag}>" 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/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.rb b/lib/phlex/sgml.rb index 22dd6a51..4c2fe887 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) @@ -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/lib/phlex/sgml/elements.rb b/lib/phlex/sgml/elements.rb index 91ca9b7c..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) @@ -94,7 +104,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 +135,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 << ">#{tag}>" end @@ -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 @@ -191,7 +200,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 @@ -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/sgml/state.rb b/lib/phlex/sgml/state.rb index b7c0b97a..5a10f1e3 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 << "#{tag}>" end else # without content - buffer << "<#{tag}" << ::Phlex::ATTRIBUTE_CACHE.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">#{tag}>" + buffer << "<#{tag}" << state.attribute_cache.fetch(attributes) { Phlex::SGML::Attributes.generate_attributes(attributes) } << ">#{tag}>" end else # without attributes if block_given # with content block 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/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, "