Give each Ractor its own attribute cache so components render on non-main Ractors

MH
9d ago
+485-21

Summary

Rendering a component inside a non-main Ractor fails on every Phlex release:

can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor (Ractor::IsolationError)

A non-main Ractor can't read an unshareable object through a constant or a module instance variable, and the render path had several. This PR removes each one, without changing anything for code that only ever renders on the main Ractor.

  • Attribute cache. Phlex.attribute_cache 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). It is resolved once per render into Phlex::SGML::State, so element methods read an ivar and nothing per element touches Ractor storage. A Ractor-local cache is sized like the main one and catches up when later templates expand it; FIFO#grow_to does that under the FIFO's lock, so racing threads in one Ractor grow it once.
  • tag(:name, …). tag reads the built-in modules' element registries. Each built-in module now ends its body with __freeze_registries__, which makes both registries shareable in place (no copy). A registration on an already-frozen registry swaps in a frozen copy instead of mutating it. Component classes' own registries (register_element in a component) are never frozen.
  • Compiler. With Phlex::Compiler.enable!, only the main Ractor compiles; other Ractors skip the compiler lock (which they can't take) and render whatever is defined, compiled or not. The main Ractor's path is unchanged, so a render there still waits for a recompilation in progress.
  • Compiled source maps. cache (via Phlex.__source_line__) and the exception mapping in compiled code now read a frozen, shareable copy of COMPILED_SOURCE_MAPS that the compiler republishes after each write. Without it, cache failed on a worker and an error raised by a compiled component surfaced as Ractor::IsolationError.
  • Rubies without Ractors (TruffleRuby, JRuby) always use ATTRIBUTE_CACHE.

Verified on Ruby 4.0.6: standard, void, custom (register_element), SVG and tag(…) elements, cache, and compiled components all render on worker Ractors byte-identical to the main Ractor.

Environment notes

  • Text goes through ERB::Escape.html_escape, which is only marked Ractor-safe since erb 5.0.2 (Ruby 4.0 bundles one; 3.3/3.4's bundled erb is not). The render tests probe for it and only run when it holds.
  • Before Ruby 4.0 a non-main Ractor cannot autoload in general, so on 3.2/3.3 call Phlex::Loader.eager_load first. On 4.0 a worker-triggered autoload runs on the main Ractor (verified for Phlex::SVG).
  • The head lanes need refract with Prism's ErrorRecoveryNode support (yippee-fun/refract#4, merged); they pass on this branch since merging main.

Benchmarks (bench.rb verbatim, Ruby 3.4.2 +YJIT, same machine)

Against main:

this PRmain
Page (before compile)3.477k i/s3.516k i/s
Dynamic list (before compile)15.795k i/s15.632k i/s
Page (after compile)91.683k i/s93.344k i/s
Dynamic list (after compile)34.874k i/s34.687k i/s

The tag() commit against the commit before it, three alternating runs each, uncompiled Page: 3.53k / 3.00k / 3.21k (with) vs 1.71k / 2.97k / 3.34k (without); 22–25 major GCs both ways. Within noise; the bench fixtures don't call tag.

A note for anyone benchmarking this area: the uncompiled run is sensitive to heap layout. An earlier version of the tag() fix that replaced the registry with a new frozen Hash on every registration did slow bench.rb ~2×, which is why the registries are frozen in place once instead. Custom harnesses that GC.start before measuring flip into a nofree major-GC loop on unrelated changes, so compare with bench.rb and its major-GC count.

Test plan

  • bundle exec qt in a clean checkout of main + this branch: Ruby 3.4.2 — 3571 passed, 4 failed, 0 errors; Ruby 4.0.6 — 3577 passed, 4 failed, 0 errors. The 4 failures are the same on an untouched main.
  • bundle exec rubocop — no offenses
  • quickdraw/ractor.test.rb: per-Ractor cache identity, size and growth; rendering, tag, cache, compiled components and their errors on a worker Ractor; the built-in registries are shareable
  • quickdraw/sgml/element_registry.test.rb: registries frozen after load; registering after the freeze (standard, void, void → standard, Symbol and caller-owned String tags) keeps them frozen and shareable without mutating earlier snapshots; component registries stay mutable
  • quickdraw/fifo.test.rb: grow_to semantics and racing threads
  • Each new test fails with its fix reverted
  • Load + tag + late registration on Ruby 3.2.8 and 3.3.8; a simulated Ractor-less Ruby (remove_const(:Ractor))

Conformance case

quickdraw/ractor.test.rb:113: "renders inside a non-main Ractor with the same output"

bundle exec qt -p 1 ./quickdraw/ractor.test.rb
  • Without the fix (fdfb78e), Ruby 4.0.7: fails with Ractor::IsolationError: can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor from div. The registry test fails on @__registered_elements__ from Phlex::HTML::StandardElements. (On main, running the whole file hangs at the cache-growth test, whose Ractor raises before it replies, so run the render test alone there.)
  • With this PR: the file passes on Ruby 4.0.7.

The Example component uses attributes, data:, a void element, tag, a registered custom element and SVG, so a new shared cache or registry on any of those paths fails it; the Ractor tests are defined only on Rubies with Ractor.store_if_absent, and the render tests only where ERB::Escape is Ractor-safe.