Skip to content
72 changes: 69 additions & 3 deletions lib/phlex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,36 @@ 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)

# Filled by the compiler: compiled path => Phlex::Compiler::Generation.
# 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
Expand All @@ -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
Comment thread
mhenrixon marked this conversation as resolved.
Resolved
# (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__ })
Comment thread
mhenrixon marked this conversation as resolved.
Resolved
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
Expand Down
23 changes: 20 additions & 3 deletions lib/phlex/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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|
Expand Down
8 changes: 8 additions & 0 deletions lib/phlex/fifo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions lib/phlex/html.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(&)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/phlex/html/standard_elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions lib/phlex/html/void_elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/phlex/sgml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions lib/phlex/sgml/elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
3 changes: 2 additions & 1 deletion lib/phlex/sgml/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
mhenrixon marked this conversation as resolved.
Resolved
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
Expand Down
4 changes: 2 additions & 2 deletions lib/phlex/svg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/phlex/svg/standard_elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -418,4 +418,6 @@ module Phlex::SVG::StandardElements
**attributes,
&content
) = nil

__freeze_registries__
end
39 changes: 39 additions & 0 deletions quickdraw/compiler/lifecycle.test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<div>waits</div>"
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
Expand Down
Loading
Loading