Give each Ractor its own attribute cache so components render on non-main Ractors
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_cachereturnsATTRIBUTE_CACHEon the main Ractor and a Ractor-local FIFO anywhere else (Ractor.store_if_absenton Ruby 3.4+, ractor-local storage before that). It is resolved once per render intoPhlex::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_todoes that under the FIFO's lock, so racing threads in one Ractor grow it once. tag(:name, …).tagreads 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_elementin 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(viaPhlex.__source_line__) and the exception mapping in compiled code now read a frozen, shareable copy ofCOMPILED_SOURCE_MAPSthat the compiler republishes after each write. Without it,cachefailed on a worker and an error raised by a compiled component surfaced asRactor::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_loadfirst. On 4.0 a worker-triggered autoload runs on the main Ractor (verified forPhlex::SVG). - The
headlanes need refract with Prism'sErrorRecoveryNodesupport (yippee-fun/refract#4, merged); they pass on this branch since mergingmain.
Benchmarks (bench.rb verbatim, Ruby 3.4.2 +YJIT, same machine)
Against main:
| this PR | main | |
|---|---|---|
| Page (before compile) | 3.477k i/s | 3.516k i/s |
| Dynamic list (before compile) | 15.795k i/s | 15.632k i/s |
| Page (after compile) | 91.683k i/s | 93.344k i/s |
| Dynamic list (after compile) | 34.874k i/s | 34.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 qtin a clean checkout ofmain+ 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 untouchedmain. -
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_tosemantics 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 withRactor::IsolationError: can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractorfromdiv. 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.