Skip to content

Ruby: Herb::Engine can't be used inside a Ractor #2805

Open

Description

@mhenrixon

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

  1. The native extension. Init_herb never calls rb_ext_ractor_safe(true). nm -u finds no rb_ext_ractor_safe in any of the 0.11.0 herb.bundles (3.2 to 4.0, arm64-darwin) or in a build of main. Because of that, Ruby refuses every Herb::Backend call from a non-main Ractor.

  2. 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 the Configuration isn't shareable.
    • Herb::Configuration::DEFAULTS and OPTIONS are YAML.safe_load_file(...).freeze, which freezes only the outer Hash. The same goes for VALID_FRAMEWORKS and VALID_TEMPLATE_ENGINES.
    • Herb.default_erb_openings (lib/herb.rb:129) is @default_erb_openings ||=.
    • Herb::Engine::Compiler::HORIZONTAL_SPACE and RAW_TEXT_ELEMENTS are frozen Arrays of unfrozen Strings, because compiler.rb is # frozen_string_literal: false.

    A scan of the constants under Herb also flags Herb::Fingerprint::BOM (.b returns an unfrozen copy) and Herb::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 left lib/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

  1. 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?
  2. 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.configure and reset_configuration! would keep working from the main Ractor. I can send it, with a Ractor test that's skipped below Ruby 4.0.

Activity

  1. moved this to Waiting on maintainers in Ractor-safe Railson Oct 18, 2026
  2. added
    featureNew feature or request
    rubyRuby source for the gem and its libraries
    on Oct 19, 2026
  3. marcoroth commented on Oct 19, 2026

    @marcoroth
    Owner

    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!

  4. mhenrixon commented on Oct 19, 2026

    @mhenrixon
    Author

    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/)

    • I found no global or static mutable state. The parser, lexer, allocator, arena and string code keep their state in per-call structs.
    • The only file-scope data are the static string tables (void_tags, boolean_attributes, optional_end_tags, p_closers, ...). Nothing writes to them, so marking them const would just document that.
    • There are no calls to non-reentrant libc functions (strtok, localtime, setlocale, ...).
    • The vendored Prism is fine: the prism gem declares rb_ext_ractor_safe(true) itself.

    The extension (ext/herb)

    • The static VALUE class handles are set once, in Init_herb on 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 plain bool;
      • token_type_value_cache creates an interned String per token type on first use and passes it to rb_gc_register_mark_object.

      Both are small and fixed-size. Filling them in Init_herb removes the race.

    • After that, Init_herb can call rb_ext_ractor_safe(true), guarded by have_func("rb_ext_ractor_safe") in extconf.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:

    gem install conformance-kit
    conformance-audit --require herb --require herb/engine --require herb/configuration --namespace Herb

    It reports 11:

    • Configuration::DEFAULTS, OPTIONS, VALID_FRAMEWORKS and VALID_TEMPLATE_ENGINES, which are frozen only at the top level;
    • Engine::Compiler::HORIZONTAL_SPACE and RAW_TEXT_ELEMENTS;
    • the @declared_parser_options Hashes on Engine::Compiler and Visitor;
    • Fingerprint::BOM;
    • Analysis::PartialResolution::EXTENSION_ALTERNATIVES and VIEW_ROOT.

    Calling Ractor.make_shareable on each at load fixes them. Separately, Herb.configuration and Herb.default_erb_openings memoize on the module, so they need to be computed at load (or by Rails before ractorize!) rather than on first call.

    A test that keeps it working

    One test that compiles a few templates with Herb::Engine in 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.

  5. marcoroth commented on Nov 6, 2026

    @marcoroth
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or requestrubyRuby source for the gem and its libraries

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions