Repository navigation
Description
On Rails main, load_defaults 8.2 makes Herb the ERB implementation (config.action_view.erb_implementation = :herb). Rails main also has an experimental Rails.application.ractorize!, which serves requests from Ruby 4.0 Ractors. ERB templates compile on first render, inside the request Ractor, so with 8.2 defaults every ERB page fails in a ractorized app. The Rails-side writeup is in Shopify/ractor-safe-rails#23, section 2.
#2261 proposed marking the native extension Ractor-safe and was closed unmerged. Its test covered parsing, lexing, extraction and diffing, not Herb::Engine. This issue covers what's still missing on main (ca4a42c7, built locally) and in 0.11.0.
Reproduction
Ruby 4.0.7, ruby -W:no-experimental repro.rb:
require "herb"
require "herb/engine"
def in_ractor(label, &block)
puts "#{label}: #{Ractor.new(&block).value.inspect}"
rescue Ractor::RemoteError => e
puts "#{label}: #{e.cause.class}: #{e.cause.message}"
end
in_ractor("Herb.parse") { Herb.parse("<p><%= 1 %></p>").success? }
in_ractor("Herb::Engine.new") { Herb::Engine.new("<p><%= 1 %></p>").src.size }
Herb.configuration # memoized on the main Ractor
in_ractor("Herb.configuration") { Herb.configuration.class }The output is the same on main and 0.11.0:
Herb.parse: Ractor::UnsafeError: ractor unsafe method called from not main ractor
Herb::Engine.new: Ractor::IsolationError: can not access non-shareable objects in constant Herb::Configuration::DEFAULTS by non-main ractor.
Herb.configuration: Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@configuration from Herb)
What blocks it
-
The native extension.
Init_herbnever callsrb_ext_ractor_safe(true).nm -ufinds norb_ext_ractor_safein any of the 0.11.0herb.bundles (3.2 to 4.0, arm64-darwin) or in a build ofmain. Because of that, Ruby refuses everyHerb::Backendcall from a non-main Ractor. -
Ruby-side state on the compile path:
Herb.configuration(lib/herb.rb:135) is@configuration ||= Configuration.load(project_path). A non-main Ractor can't set it. It can't read it either once the main Ractor has set it, because theConfigurationisn't shareable.Herb::Configuration::DEFAULTSandOPTIONSareYAML.safe_load_file(...).freeze, which freezes only the outer Hash. The same goes forVALID_FRAMEWORKSandVALID_TEMPLATE_ENGINES.Herb.default_erb_openings(lib/herb.rb:129) is@default_erb_openings ||=.Herb::Engine::Compiler::HORIZONTAL_SPACEandRAW_TEXT_ELEMENTSare frozen Arrays of unfrozen Strings, becausecompiler.rbis# frozen_string_literal: false.
A scan of the constants under
Herbalso flagsHerb::Fingerprint::BOM(.breturns an unfrozen copy) andHerb::Analysis::PartialResolution::EXTENSION_ALTERNATIVES/VIEW_ROOT. These aren't on this path. Add Audition CI checks #2286, also closed, fixed similar constants in other files and leftlib/herb/configuration.rb's class-level state in its Audition baseline.
With those out of the way
I added rb_ext_ractor_safe(true) to Init_herb (the #2261 change) and ran this on the main Ractor first. Herb::Engine then compiles in a Ractor, and its output is identical to the main Ractor's:
require "herb"
require "herb/engine"
source = %(<div class="<%= :a %>"><% if true %><%= "x" %><% end %><script>var a = 1;</script></div>\n).freeze
expected = Herb::Engine.new(source, escape: true).src
Ractor.make_shareable(Herb.configuration)
Herb.default_erb_openings
Ractor.make_shareable(Herb::Engine::Compiler::HORIZONTAL_SPACE)
Ractor.make_shareable(Herb::Engine::Compiler::RAW_TEXT_ELEMENTS)
actual = Ractor.new(source) { |s| Herb::Engine.new(s, escape: true).src }.value
puts "same output as the main Ractor: #{actual == expected}"same output as the main Ractor: true
Four Ractors calling Herb.parse at once also succeed. That doesn't show libherb is safe to run concurrently, though. The flag tells Ruby that the C code has no unsynchronized global or static state, and you're the ones who can audit that.
Questions
- Would you accept marking the extension Ractor-safe once libherb's global state has been audited? Ruby: Support Herb in Ractors #2261 mentions problems someone found with Herb and Ractors. Are they written up anywhere?
- For the Ruby side, would you take a PR that loads the configuration eagerly as a deeply frozen, shareable object, and deep-freezes the constants above?
Herb.configureandreset_configuration!would keep working from the main Ractor. I can send it, with a Ractor test that's skipped below Ruby 4.0.
Activity
marcoroth commentedon Oct 19, 2026
Thanks for opening this issue @mhenrixon. I'm more than happy to support Ractors 🙌🏼
I haven't put much thought into determining if Herb's core is Ractor safe, but if you have any tips on that I'm more than happy to hear them. Thanks!
mhenrixon commentedon Oct 19, 2026
Thanks @marcoroth! I read through
main(3660d8c) with that question in mind. In short, the C parser looks Ractor-safe already. What needs work is two lazy caches in the Ruby binding, plus some Ruby-side state.libherb (
src/)void_tags,boolean_attributes,optional_end_tags,p_closers, ...). Nothing writes to them, so marking themconstwould just document that.strtok,localtime,setlocale, ...).rb_ext_ractor_safe(true)itself.The extension (
ext/herb)The
static VALUEclass handles are set once, inInit_herbon the main Ractor. That's fine, because classes are shareable.Two caches are filled lazily, by whichever Ractor gets there first:
init_ast_value_ivar_ids()interns ten ivar IDs behind a plainbool;token_type_value_cachecreates an interned String per token type on first use and passes it torb_gc_register_mark_object.Both are small and fixed-size. Filling them in
Init_herbremoves the race.After that,
Init_herbcan callrb_ext_ractor_safe(true), guarded byhave_func("rb_ext_ractor_safe")inextconf.rb, as Ruby: Support Herb in Ractors #2261 did.Ruby side
The list in the issue still stands. Here's the full set, from an audit of 0.11.0 that lists every constant and module ivar a non-main Ractor can't read:
It reports 11:
Configuration::DEFAULTS,OPTIONS,VALID_FRAMEWORKSandVALID_TEMPLATE_ENGINES, which are frozen only at the top level;Engine::Compiler::HORIZONTAL_SPACEandRAW_TEXT_ELEMENTS;@declared_parser_optionsHashes onEngine::CompilerandVisitor;Fingerprint::BOM;Analysis::PartialResolution::EXTENSION_ALTERNATIVESandVIEW_ROOT.Calling
Ractor.make_shareableon each at load fixes them. Separately,Herb.configurationandHerb.default_erb_openingsmemoize on the module, so they need to be computed at load (or by Rails beforeractorize!) rather than on first call.A test that keeps it working
One test that compiles a few templates with
Herb::Enginein several Ractors at once and compares each result with the main Ractor's, skipped below Ruby 4.0. It fails again if any of the above comes back.I'm happy to open a PR with all of this, if that works for you: the extension change, the Ruby-side freezes and that test.
marcoroth commentedon Nov 6, 2026
Thanks @mhenrixon, I opened a preliminary PR here: #2890
Feel free to take this as the base for yours or, let me know what we should change to make this work for ractors.