Repository navigation
Conversation
…main Ractors Rendering inside a non-main Ractor raised `can not access non-shareable objects in constant Phlex::ATTRIBUTE_CACHE by non-main Ractor`: the cache is a FIFO (a Hash behind a Monitor), which a non-main Ractor can never read through a constant. `Phlex.attribute_cache` now 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), sized like the main one including the expansion for every view_template seen so far. It is resolved once per render into Phlex::SGML::State, so element methods pay an ivar read instead of a constant lookup and nothing per element touches Ractor storage. Not covered yet: `tag(:name, ...)` reads the element registries, which are mutable Hashes in module instance variables and so still unreadable from a non-main Ractor. Making them copy-on-write costs one transient Hash per registration at boot, which is enough to shift the 160-byte size pool across a page boundary before the first GC and park bench.rb's uncompiled run in a `nofree` major-GC loop (2x slower, 2 vs ~380 majors); that wants an explicit freeze step instead, in a follow-up. Text escaping goes through ERB::Escape, whose html_escape is only marked Ractor-safe since erb 5.0.2 (Ruby 4.0 ships one); the render test is gated on that. Before Ruby 4.0 a non-main Ractor cannot autoload, so on 3.2/3.3 eager-load Phlex first.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 82c1665b5a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
lib/phlex.rb called Ractor.respond_to? at load time, so on TruffleRuby, which has no Ractor constant, requiring Phlex raised NameError. Without Ractors there is only ever one cache, so attribute_cache returns ATTRIBUTE_CACHE; the Ractor tests are skipped there.
|
Pushed a fix for the TruffleRuby lanes: The |
__expand_attribute_cache__ only grows ATTRIBUTE_CACHE and the expansion counter, so a Ractor whose cache already existed kept the size it was created with: in a lazily loaded app whose later templates pass the 1 MiB floor, worker Ractors would evict and regenerate attributes the main one keeps. A Ractor-local cache now catches up when it is next fetched, which is once per render; the main Ractor's path is unchanged.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e803f75e28
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
With Phlex::Compiler.enable!, every render went through compile_on_first_render, which takes Compiler::MUTEX before checking whether the component is already compiled. A non-main Ractor can't read a Mutex through a constant, so compiler-enabled apps couldn't render there at all. A compiled component now skips the lock (the flag is only set once its compilation has finished), and only the main Ractor compiles: workers render whatever is defined, compiled or not.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4289d5908a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
SGML#cache (through Phlex.__source_line__) and the exception mapping in compiled code read COMPILED_SOURCE_MAPS, a mutable Hash in a constant that a non-main Ractor can't touch: a cached fragment couldn't render there, and an error raised by a compiled component came out as Ractor::IsolationError. The compiler, which only runs on the main Ractor under its lock, now also publishes a frozen, shareable copy after each write, and both readers use it. A Ractor-local attribute cache was topped up with compare-then-expand, so threads in one worker racing past a stale size could each add the shortfall. FIFO#grow_to raises the limit to a target under the FIFO's lock instead.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 63ea2e0ee8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
The two |
…stries tag looks names up in the built-in modules' element registries, mutable Hashes in module instance variables that a non-main Ractor can't read. Each built-in module now ends its body with __freeze_registries__, which makes both registries shareable in place: no copy, and nothing changes for the registrations before it. A registration on a registry that is already frozen swaps in a frozen copy instead of mutating it, and stores a frozen copy of the tag so the caller's String is never frozen. Component classes' own registries are never frozen.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3749a622b4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
4289d59 let any compiled component skip Compiler::MUTEX, on the main Ractor too. A recompilation (an element or helper override) holds that lock but leaves the compiled flag set, so a render starting on another main-Ractor thread meanwhile ran the old inlined method instead of waiting. Only worker Ractors, which can't take the lock or compile, skip it now; the main Ractor's path is the one it had before this PR.
|
I think I'm done now @joeldrapper, would love to see these tweaks in Phlex so that I can make my stack ractor-friendly. |
conformance-kit's "runs inside a non-main Ractor" (tagged :ractor, Ruby 4.0; skipped with the kit's reason below) runs, with the configuration finalized: Forms::Field option resolution and PhlexForms::Inference on a literal Struct model, theme resolution and the icon renderer, and the form_options / live class settings of constant form classes. Before the two previous fixes the class-settings examples raised Ractor::IsolationError on @form_modifiers and @live_scope. Rendering is a pending example with its reason: Phlex reads its non-shareable Phlex::ATTRIBUTE_CACHE on every element with attributes (yippee-fun/phlex#1055). It fails today; once upstream lands the pending example passes and RSpec reports it, so it cannot go stale silently. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
audit.require points at spec/conformance_eager_load.rb, which eager-loads both Zeitwerk roots (PhlexForms, Forms) and finalizes the configuration as a host's boot does; audit.strict_autoloads makes the strict audit fail on any autoload left unloaded. No main_only and no skip_autoloads entries: nothing is left that needs one. The Ractor gate runs the :ractor examples; probes.gem_name enables the report-only compiler probes. `bundle exec conformance-kit check` on Ruby 4.0.6: doctor ok, 6 :ractor examples (0 failures, 1 pending: rendering, yippee-fun/phlex#1055), and conformance-audit: 0 findings, 70 modules audited, 0 unloaded autoloads. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Only the main Ractor can eager-load (Zeitwerk's registry is main-only), so DaisyGroups skips it elsewhere: finalize! already loaded everything. The :ractor canary merges inside Ractor.new on Ruby 4.0. Rendering stays pending on Phlex (Phlex::ATTRIBUTE_CACHE, yippee-fun/phlex#1055); DaisyUI's own render-time code gets that far. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
…ctor conformance-kit's "runs inside a non-main Ractor" canary (Ruby 4.0; skipped with a reason below) now builds a SearchIndex from literal pages, assembles llms.txt and llms-full.txt from a Configuration built inside the Ractor, and builds a literal registry's NavItem tree. Two blockers were docs-kit's own: Configuration::DEFAULT_NAV was an unshareable Proc (every Configuration.new read it) and a registry's page list was an unfrozen Array in a class ivar. The page DSL now makes every value shareable on write, DEFAULT_NAV is a shareable lambda, and DocsUI::Callout::LEVELS is frozen all the way down. Rendering cannot run in a Ractor yet: the Phlex, commonmarker and Nokogiri canaries are pending with the upstream reason (yippee-fun/phlex#1055, commonmarker's Proc-defined options, nokogiri's C extension), and turn red the day one of them passes. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
conformance-kit's "runs inside a non-main Ractor" example, tagged :ractor and skipped below Ruby 4.0 with the kit's reason, over what a non-main Ractor can run once the configuration is finalized: param coercion through a compiled ParamSchema, Phlex::Reactive::Stream wrapping, Streamable#dom_id, reading the finalized configuration, and token-payload upgrades. ParamSchema::BOOLEAN_TYPE is now Ractor-shareable: the canary found the :boolean caster unreadable from a non-main Ractor. Two canaries are pending, each with its upstream reason, and fail loudly the day they pass: signing/verifying (an ActiveSupport::MessageVerifier cannot be built or used outside the main Ractor) and rendering (Phlex 2.4.1 reads its unshareable ATTRIBUTE_CACHE on every element with attributes, yippee-fun/phlex#1055). Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
A ractor_readiness check, the same shape as the fiber isolation one: eager loading on, the configuration finalized, and every setting a non-main Ractor can read (Doctor.unshareable_settings names each one that holds an unshareable value, with its class). Advisory only, never a failure: no app has to run Ractors, and the ok line names what still blocks Ractor mode upstream (Rails, ActiveSupport's MessageVerifier, Phlex rendering, yippee-fun/phlex#1055). It never loads a server. Phlex::Reactive::CONFIG_READERS is the one list of configuration readers, shared by the doctor and the finalize spec. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
The tiers phlex-reactive is held to and their gates, why request state is fiber-local (and what the per-fiber view-context rebuild costs a Falcon request, measured), the configuration finalized at boot and what it changes for an app, what Ractor mode needs (eager loading) and what waits upstream (Phlex rendering, yippee-fun/phlex#1055; ActiveSupport's MessageVerifier; Rails), and the doctor's Ractor readiness line. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
* chore(ci): call conformance-kit's conformance workflow at v0.3.0 Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot keep both pins current. The conformance check is not required yet: it turns green, and becomes required, with #42. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): group the two conformance-kit pins into one Dependabot update A tag bump without the gem (or the reverse) fails the conformance version check, so both now arrive in a single multi-ecosystem pull request. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): inherit conformance-kit's RuboCop config Adds rubocop-thread_safety (>= 0.8, what conformance-kit doctor checks) and inherits conformance-kit's config/rubocop.yml, so the Tier 1 ThreadSafety cops run in the Lint job. The offences it reports in lib/ are fixed in the commits that follow, not baselined. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(theme): build the themes without phlex-reactive Theme.daisy / Theme.plain named Forms::TagField and its rootless twin as arguments to reactive_roles, so the classes were resolved before the defined?(Phlex::Reactive) guard ran. Without phlex-reactive those files are ignored by the loader, and the first theme lookup raised NameError: the soft dependency was in fact hard. reactive_roles now takes the namespace and names the classes only behind the guard. The spec boots the gem in a child process with phlex-reactive off the load path. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(core): finalize the configuration and themes at boot The configuration and both built-in themes lived in lazy class ivars (`@configuration ||=`, `@daisy ||=`, `@plain ||=`, plus lazy defaults in Configuration's readers): check-then-set races, and unreadable from a non-main Ractor. - Theme.daisy / Theme.plain are the DAISY / PLAIN constants, built once when theme.rb loads; a Theme is frozen on construction, so a theme of component classes is Ractor-shareable. - Configuration sets every default in initialize; no reader memoizes. The default icon renderer is a capture-free lambda made shareable in the class body, and glyphs_renderer returns a shareable one too. - PhlexForms.finalize! stores Ractor-shareable copies of every setting (PhlexForms::Shareable.copy, which never freezes the caller's objects) and freezes the configuration. configure calls it when the Rails engine is absent; the engine calls it after_initialize. configure on a finalized configuration edits a copy and swaps it in whole. - A renderer that cannot be shared (a top-level lambda in an initializer) is kept as given and still works on the main Ractor. - The configuration is built when the gem loads, never lazily; one inline ThreadSafety/ClassInstanceVariable disable covers the module's configuration accessors, with the reason beside it. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(theme): theme resolution under thread and fiber contention conformance-kit's "thread-safe under contention" and "fiber-safe under an Async reactor" examples over Theme.resolve, the lookup every field makes, require one Theme object across 8 threads/fibers x 50 calls. On the lazy `@daisy ||=` themes the thread example saw 8 distinct Theme objects in 2 of 3 runs; on the load-time constants it cannot race. Adds async (development only, require: false) so the fiber example runs instead of skipping. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(builder): form_options stores Ractor-shareable copies form_options kept its arguments as-is in the form class's ivars, so a non-main Ractor reading form_modifiers / form_defaults raised Ractor::IsolationError. It now stores PhlexForms::Shareable.copy of both (deep-frozen copies; the caller's objects stay unfrozen, and a value that cannot be shared, such as a lambda with an unshareable self, is kept as given for the main Ractor). The readers already build new values per call, so nothing else changes. The ThreadSafety/ClassInstanceVariable disable around the macro and its two readers carries the reason: written while the class body loads, at boot on the main Ractor, and shareable once written. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(validation): the live macros store Ractor-shareable settings live (setup_live), live_permit, live_deny and live_tags stored mutable strings, arrays and a hash in the form class's ivars, unreadable from a non-main Ractor. They now store PhlexForms::Shareable.copy of each; the model class and the debounce were shareable already. conformance-audit also flagged Forms::Live.@_dependencies, the Array ActiveSupport::Concern keeps of the concerns Forms::Live includes. It is filled while the module body loads and only read after (when a form class includes Forms::Live), so it is frozen at the end of the body. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * refactor(components): render <lexxy-editor> through tag() register_element :lexxy_editor left Forms::RichTextarea with a mutable per-class element registry (@__registered_elements__, Phlex's own), which conformance-audit reports as unshareable class state. Phlex's tag(:lexxy_editor, ...) (Phlex >= 2.0.0, the gemspec floor) renders the same custom element, attributes and block, with no registry on the class. The rich-textarea specs are unchanged and green. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): Ractor canary for field inference and form settings conformance-kit's "runs inside a non-main Ractor" (tagged :ractor, Ruby 4.0; skipped with the kit's reason below) runs, with the configuration finalized: Forms::Field option resolution and PhlexForms::Inference on a literal Struct model, theme resolution and the icon renderer, and the form_options / live class settings of constant form classes. Before the two previous fixes the class-settings examples raised Ractor::IsolationError on @form_modifiers and @live_scope. Rendering is a pending example with its reason: Phlex reads its non-shareable Phlex::ATTRIBUTE_CACHE on every element with attributes (yippee-fun/phlex#1055). It fails today; once upstream lands the pending example passes and RSpec reports it, so it cannot go stale silently. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): .conformance.yml with a strict, eager-loading audit audit.require points at spec/conformance_eager_load.rb, which eager-loads both Zeitwerk roots (PhlexForms, Forms) and finalizes the configuration as a host's boot does; audit.strict_autoloads makes the strict audit fail on any autoload left unloaded. No main_only and no skip_autoloads entries: nothing is left that needs one. The Ractor gate runs the :ractor examples; probes.gem_name enables the report-only compiler probes. `bundle exec conformance-kit check` on Ruby 4.0.6: doctor ok, 6 :ractor examples (0 failures, 1 pending: rendering, yippee-fun/phlex#1055), and conformance-audit: 0 findings, 70 modules audited, 0 unloaded autoloads. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: finalize-at-boot configuration in the README and CHANGELOG README: a "Configuration is finalized at boot" section (when it finalizes, writing through configure afterwards, renderers that cannot be shared). CHANGELOG [Unreleased]: PhlexForms.finalize! and the shareable themes and class macros (Added), the frozen configuration and the renderer rule (Changed), and the theme fix without phlex-reactive (Fixed). Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): call conformance-kit's conformance workflow at v0.4.0 Moves both pins together: the caller's tag and the Gemfile's ~> 0.4, so the version handshake matches. No tracked lockfile pins the kit (the root Gemfile.lock is ignored, docs/ does not bundle it). Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): the engine finalizes the configuration at Rails boot Boots a minimal Rails app in a child process (so the engine never loads into the suite) and checks the configuration is frozen and shareable after after_initialize with no app code, that initializer writes (configure and a direct write) are kept, and that an icon renderer which cannot be shared (a top-level lambda closing over a Logger) boots and still renders. Seen failing with the engine's after_initialize removed. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): finalizing keeps a non-Proc icon renderer as given Shareable.copy deep-copies and freezes any object it can, so an icon renderer object with state of its own (a cache) was swapped for a frozen copy that raised FrozenError on its first render. A renderer that is not a Proc is now kept as given: shared only if it already is. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore: bundle exec rake passes from the root The root RuboCop walked docs/, whose .rubocop.yml requires docs_kit/rubocop from the docs app's own bundle, so `rake` (spec + rubocop) aborted on main. docs/ is excluded from the root lint, and the one remaining offence (Bundler/OrderedGems in the Gemfile) is fixed by moving phlex-reactive into order. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: the 0.5.0 changelog and how finalizing hooks in Moves this PR's entries out of the [Unreleased] pile into a [0.5.0] section with an "Upgrading to 0.5" list (auto-finalize, FrozenError on a direct write after boot, unshareable values kept as given), and heads the released pile "[0.4.1] and earlier". The README says when finalizing happens with and without Rails, and that non-Proc renderers are never copied or frozen. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): configure finalizes outside Rails with phlex-reactive bundled Finalizing builds the themes, which load the tag-field leaves and with them phlex-reactive's Sortable, which calls Hash#to_json while it loads without requiring json. A Rails host has json loaded; a plain Ruby host got NoMethodError from PhlexForms.configure (before this PR, from its first form). phlex-forms now requires json before phlex-reactive, and the eager-load file's workaround goes. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): configure returns its block's value again Before finalizing, configure was `yield(configuration)` and returned what the block returned; the finalizing versions returned the configuration or nil. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): field_variants set to nil stays an appendable list The reader stopped memoizing `@field_variants ||= []`, so after `c.field_variants = nil` a later `c.field_variants << :sm` appended to a throwaway array. The writer now stores nil as an empty list. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: Ractors read the configuration when every setting is shareable The changelog and README promised a Ractor-shareable configuration even when an unshareable value (a top-level renderer lambda) is kept as given. Also adds the json fix to the 0.5.0 Fixed list. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): label conformance-kit Dependabot PRs dependencies + devops Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
…-finalize, Ractor canary, strict audit) (#103) * chore(ci): call conformance-kit's conformance workflow at v0.3.0 Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot keep both pins current. The conformance check is not required yet: it turns green, and becomes required, with #91. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): group the two conformance-kit pins into one Dependabot update A tag bump without the gem (or the reverse) fails the conformance version check, so both now arrive in a single multi-ecosystem pull request. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(registry): build a cold snapshot once, per Ractor, under a lock DocsKit::Snapshot.for kept its cache in an unguarded class ivar (`@cache ||= {}`): threads that missed a cold key together each read and parsed the manifest and raced to store it (32 threads parsed it up to 26 times). The cache now lives in DocsKit::Store, a copy of conformance-kit's per-Ractor store (the kit is a development dependency only), and its check-then-set runs under that Ractor's lock. A Snapshot builds its registries in initialize, so a cached instance carries no lazy memo. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(engine): finalize the configuration at boot; lock the OpenAPI memo Every DocsUI::Code render resolved its Rouge theme with Object.const_get, every nav render constantized each page's view class, and the brand logo, SEO, landing and optional-mcp memos were filled by whichever request got there first. DocsKit.finalize! resolves the themes and every registered page's view class once into frozen (Ractor-shareable) maps and fills those memos; the engine runs it in after_initialize, after the host's to_prepare configure, unless code reloading is on. DocsKit.configure re-opens it, and a theme assigned directly afterwards still wins. Configuration#openapi_document kept its mtime memo in unguarded ivars: threads that missed together each parsed the spec. The memo now lives in a per-Ractor DocsKit::Store cache whose check-then-set runs under a lock. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(registry): hold Scope.with and the snapshot sidebar to threads and fibers Puma serves docs-kit from threads and Falcon from fibers in one reactor. conformance-kit's "thread-safe under contention" and "fiber-safe under an Async reactor" examples now run DocsKit::Scope.with (each request keeps its own version and leaves none behind) and an archived-version sidebar (DocsKit::Snapshot.for + Configuration#nav_groups: one snapshot, one sidebar), each call yielding mid-request so the others run inside it. The thread example fails on the old unguarded snapshot cache. async joins the test group so the fiber example runs rather than skips. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(registry): search, llms.txt and the sidebar tree run inside a Ractor conformance-kit's "runs inside a non-main Ractor" canary (Ruby 4.0; skipped with a reason below) now builds a SearchIndex from literal pages, assembles llms.txt and llms-full.txt from a Configuration built inside the Ractor, and builds a literal registry's NavItem tree. Two blockers were docs-kit's own: Configuration::DEFAULT_NAV was an unshareable Proc (every Configuration.new read it) and a registry's page list was an unfrozen Array in a class ivar. The page DSL now makes every value shareable on write, DEFAULT_NAV is a shareable lambda, and DocsUI::Callout::LEVELS is frozen all the way down. Rendering cannot run in a Ractor yet: the Phlex, commonmarker and Nokogiri canaries are pending with the upstream reason (yippee-fun/phlex#1055, commonmarker's Proc-defined options, nokogiri's C extension), and turn red the day one of them passes. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix: make docs-kit's remaining constants Ractor-shareable conformance-audit (Ruby 4.0) found constants a non-main Ractor could not read: ApiClient::DEFAULTS held Method objects (never shareable; now capture-free lambdas), DocsUI::Code::FORMATTER and Engine::JAVASCRIPT_PATH were unfrozen, and the install generator's MIGRATIONS held an unshareable block, PACKAGE_FLOORS unfrozen hashes and MIN_RUBY a Gem::Version. Gem::Version.new hands out process-wide cached instances, so MIN_RUBY and Migration#to keep a String and build the Gem::Version where they compare. MigrationRegistry.default is built at load (DEFAULT) instead of an unguarded `@default ||=`. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix: pass conformance-kit's thread-safety cops, no baseline .rubocop.yml inherits conformance-kit's config/rubocop.yml (the rubocop-thread_safety cops, now in the bundle at >= 0.8) and every offence is fixed rather than baselined: - DocsKit.configuration is built at load instead of a lazy `||=`, so no first request can race to build a second one; the remaining reset is boot/spec-only and says so inline. - DocsUI::Page's class macros (title, eyebrow, description, on_page) make each value shareable on write; they run in the class body at load. - The docs_kit:og task's one-shot screenshot server thread, the store's main-only fallback and the release-kit spec's block-form Dir.chdir are disabled inline with the reason; the snapshot contention spec, which starts threads on purpose, is excluded from NewThread with its reason. - The scoping spec's stand-in host records around_actions in a closure, not a class ivar. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): hold docs-kit to conformance-kit's strict audit .conformance.yml sets what `conformance-kit check` (and the conformance workflow on Ruby 4.0) holds docs-kit to: audit.require boots a minimal Rails app and eager-loads the gem's zeitwerk tree, the engine's controllers, the generators and the og task's generator; namespace DocsKit + DocsUI with strict_autoloads (0 unloaded autoloads); the `rspec --tag ractor` canary; probes for docs-kit. The strict audit has no unallowlisted finding. main_only carries only state that truly is main-only, each with its reason: Rails::Engine's, ActionController's and Thor's own class state, the copied store's below-3.4 fallback, and DocsKit.@configuration, which holds host per-request callables by public API. SIMPLECOV_MINIMUM lets the canary stage (ractor.env) lift the suite's 80% floor, which a handful of examples cannot meet. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * ci: run the suite and lint on Ruby 4.0 too The conformance workflow runs the Ractor canary and the strict audit on Ruby 4.0, so the whole suite and RuboCop run there as well, next to 3.3 and 3.4. AGENTS.md lists the lane and `conformance-kit check`. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs(changelog): the snapshot/OpenAPI locks, finalize! and frozen boot values Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): move to conformance-kit 0.4 The caller's tag and the Gemfile pin move together (the conformance job's version handshake checks one against the other). No tracked lockfile pins the kit: the root Gemfile.lock is gitignored, and docs/ doesn't bundle it. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(audit): allowlist framework class state by ancestor, not per ivar conformance-kit 0.4's ancestor: rules replace the 25 name: entries for the state Rails::Engine, ActionController::Base and Rails::Generators::Base (Thor) keep on docs-kit's engine, controllers and generators. Each rule is narrowed to those frameworks' own ivars, so state docs-kit kept on the same classes would still be audited. The audit allowlists the same 28 names as before, with no stale entries; docs-kit's own state (DocsKit.@configuration, the Store fallback) keeps its name: entries. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(engine): finalize in development too, and again after every reload The engine skipped DocsKit.finalize! while code reloading was on, so a development app resolved theme and page view classes per render. It now finalizes after boot in every environment. With reloading on it also adds a to_prepare after boot, which runs after the host's to_prepare configure (that re-opens the configuration) on every reload, so the resolved view classes are the reloaded ones. The host's per-request callables are left as they are: they close over app state, so the configuration stays main-Ractor-only, and finalizing never tries to make it shareable. A child-process boot spec covers both environments, a reload, and callables that close over mutable app state. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: DocsKit.finalize! after boot, the host's callables and Ractors README's Configure section says when the engine finalizes (after boot in every environment, again after a development reload), what is frozen, and that the host's callables stay as given, so the configuration is main-Ractor-only and a Ractor passes its own. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs(changelog): file the conformance work under 1.4.0 The [Unreleased] entries move under a 1.4.0 heading (no date: the release sets it), with an Upgrading from 1.3 note, the development finalize and the conformance-kit 0.4 move. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(registry): a new view_namespace drops the views resolved under the old one Registry#finalize! caches each page's view class by view name. Setting view_namespace afterwards left that cache in place, so a page kept rendering the class from the old namespace. The setter now clears it, and the entries resolve under the new namespace. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(config): keep the OpenAPI memo on its configuration Configuration#openapi_document cached each Document in a per-Ractor map keyed by configuration, which kept every configuration that had read a spec (and its parsed Document) alive for the Ractor's lifetime: one per reset_configuration! in a host's suite. The memo is back on the configuration, so a replaced one takes its Document with it. The check-then-set still runs under a per-Ractor lock, so threads that miss together parse once and share one Document. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(store): racing threads in a Ractor-local fallback agree on one value Below 3.4 a non-main Ractor keeps Store values in Ractor-local storage, which has no lock. The initializer ran inside the ||=, so threads that missed together each stored and returned their own value, and a cache's callers could hold different maps and mutexes. The value is now built first, and the check-then-store runs no Ruby code between the read and the write: every thread gets the first value stored. The branch had no spec. It is now covered on every Ruby by stubbing both capability checks, with a Thread.pass in the initializer to force the race. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(code): build DocsUI::Code::FORMATTER with DocsKit::Shareable.make The formatter was only shallow-frozen. Rouge's HTML formatter keeps no ivars today, so that was already shareable, but a Rouge in the allowed range that does keep state would leave the constant unreadable from a non-main Ractor. A deep freeze holds for every version. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test: tighten four specs the review found loose - finalize_spec checks that the two theme names are not looked up again, not that no Object.const_get happens at all. - shareable_spec skips its Ractor examples on an engine without Ractor, so the plain-freeze fallback example can run there. - page_spec checks that each macro stored the value it was given before calling it shareable (nil and symbols are always shareable). - An empty SIMPLECOV_MINIMUM now means the default floor instead of an ArgumentError at load. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: only resolved page views skip the lookup at request time A page whose view class didn't exist at boot is still looked up when it is read, so it turns up once written. The README promised no lookup at all. Refs #91 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * ci(dependabot): label conformance-kit updates dependencies + devops Without labels, Dependabot uses its ecosystem labels (ruby, github_actions), which aren't in the taxonomy, so its PRs would carry no type or area label. The labels go on the multi-ecosystem group. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
…or class merge) (#61) * chore(ci): call conformance-kit's conformance workflow at v0.3.0 Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot keep both pins current. The conformance check is not required yet: it turns green, and becomes required, with #58. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore: re-trigger CI Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): group the two conformance-kit pins into one Dependabot update A tag bump without the gem (or the reverse) fails the conformance version check, so both now arrive in a single multi-ecosystem pull request. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(deps): add async and rubocop-thread_safety for the conformance gates async runs conformance-kit's fiber example (locked on Ruby 3.2 so the 3.2 lane still installs); rubocop-thread_safety >= 0.8 is what the kit's config/rubocop.yml and `conformance-kit doctor` need. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(class-merge): keep the merger and Tailwind class groups per Ractor `@tailwind_utils ||=` was an unguarded check-then-set (8 threads built 8 class-group tries), and the merger, its Monitor and the class groups were module ivars no non-main Ractor could read. DaisyUI::Store (adapted from conformance-kit's Store, which the gem cannot depend on at runtime, plus delete) gives each Ractor its own reentrant table; reset! drops the current Ractor's merger. Warm merges: 0.60 -> 0.64 us on 4.0.6, 0.64 -> 0.71 us on 3.2.8 (100k, best of 5). Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(class-merge): make the Tailwind config constants Ractor-shareable The validator, scale and theme lambdas (tailwind_config.rb:48 included) and DEFAULTS were frozen but not shareable, so a non-main Ractor could not read them. They are all capture-free (THEME_* reads only its frozen key) with the module as self, so Ractor.make_shareable takes them as they are; no conversion to methods needed. DaisyUI::Shareable keeps the old deep freeze on engines without Ractor. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(config): DaisyUI.finalize! makes the configuration and registries Ractor-shareable A non-main Ractor can only read a class/module ivar whose value is shareable. finalize! eager-loads the components, derives each component_class and freezes their registries, then deep-freezes the configuration (class_merge and modifiers included) and warms the Tailwind class groups, all on the main Ractor. Writers keep working: register_modifiers now assigns new registries instead of mutating them, and configure after finalize! yields a copy, swaps it in and finalizes it again. Only a direct write to the frozen configuration raises. The unknown-modifier report log moved per Ractor (DaisyUI::Store), since its Mutex made the configuration unshareable; a callable unknown_modifiers is made shareable when it is capture-free, and kept as it is otherwise. DaisyUI.@configuration is created at load instead of by a lazy ||=. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(class-merge): merge classes inside a non-main Ractor Only the main Ractor can eager-load (Zeitwerk's registry is main-only), so DaisyGroups skips it elsewhere: finalize! already loaded everything. The :ractor canary merges inside Ractor.new on Ruby 4.0. Rendering stays pending on Phlex (Phlex::ATTRIBUTE_CACHE, yippee-fun/phlex#1055); DaisyUI's own render-time code gets that far. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(lint): inherit conformance-kit's RuboCop config Turns on the rubocop-thread_safety cops. lib needs no exclusion beyond the justified inline disables on the component registries and the Store's below-3.4 table; the three specs that start threads on purpose say so on the line. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * refactor(mcp): make McpServer::TOOLS Ractor-shareable Only the outer Array was frozen; the audit flagged the Hashes inside. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): add .conformance.yml with a strict, eager-loaded audit audit.require loads the gem and runs DaisyUI.finalize!, as the engine does at boot, which eager-loads every DaisyUI constant (strict_autoloads: 0 skipped). The one main_only entry is Phlex's own register_element registry on Calendar, which Phlex never reads back. `conformance-kit check` passes on Ruby 4.0.6. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(engine): finalize DaisyUI once a Rails app has booted config.after_initialize runs DaisyUI.finalize! after the app's initializers have configured it. Checked against the docs app (Rails 8, Ruby 4.0.6): the configuration is shareable after boot, a non-main Ractor merges classes, and the docs suite passes with and without CI eager loading. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: DaisyUI.finalize! and class merging in Ractors README and CHANGELOG cover finalize!, configure after it, the FrozenError on direct writes, and what a callable unknown_modifiers needs to stay shareable. AGENTS.md lists `conformance-kit check`. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore: re-trigger CI now that Actions is enabled Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(deps): move to conformance-kit 0.4.0 The caller workflow tag and the Gemfile pin move together, so the conformance version check keeps passing. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): allowlist Phlex's element registry by ancestor, not by name conformance-kit 0.4 rules cover the ivar Phlex::SGML::Elements writes on every component, so a component that registers an element later needs no new entry. The allowlisted names are unchanged (DaisyUI::Calendar.@__registered_elements__). Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(config): finalizing keeps a value it cannot make shareable DaisyUI.finalize! raised at boot for a raw class_merge validator that closes over app state (Ractor::IsolationError from make_shareable), and Ractor.make_shareable freezes as it walks, so an object holding a logger would have been left frozen before it failed. Shareable.share freezes Hashes, Arrays and Strings, makes a capture-free Proc shareable, and keeps anything else as it is: the configuration is then readable on the main Ractor only, and finalizing never raises. The gem's own constants keep the strict Shareable.make. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(config): DaisyUI.configure finalizes and publishes the configuration A Rails-less app no longer has to call DaisyUI.finalize! itself: configure is its boot point. It yields the configuration (a writable copy once one is finalized), finalizes it and only then publishes it, so calling configure again keeps working and no reader, another thread or a non-main Ractor, meets a writable configuration. The Rails engine still finalizes in after_initialize, for an app that never calls configure and for the components loaded during boot. The suite's configuration is now frozen, as an app's is: specs change settings through DaisyUI.configure, or run on a writable copy (:finalizes). Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(docs): the engine finalizes DaisyUI when the app boots A real Rails boot (the docs app) with no DaisyUI code of its own: the configuration and the component registries are shareable, and a non-main Ractor merges classes. Fails without the engine's after_initialize hook. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs(changelog): this release's changes under 2.1.0 Auto-finalize at boot, the frozen configuration (FrozenError on a direct write) and values kept as they are when they cannot be made shareable. The notes that sat under [Unreleased] shipped in 2.0.x. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(config): configure always yields a copy, so a raising block changes nothing On the first call configure yielded the live configuration, so other threads could see a half-changed configuration and a block that raised left its writes behind. It now always works on a copy and publishes it only once finalized. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(class-merge): rebuild a Ractor's merger when configure publishes ClassMerge.reset! drops only the calling Ractor's merger, so a Ractor that had already merged kept the old settings after DaisyUI.configure. Each merger now remembers the configuration it was built from and is rebuilt once another one is published. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(base): freeze component registries as they are written A component that loaded after boot (lazy loading in development), or registered modifiers after it, kept mutable registries until the next finalize!, so a non-main Ractor building its merger raised Ractor::IsolationError. Every registry writer now shares the value it writes, and component_class is remembered on the main Ractor only. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test: make the shareable, contention and Store specs able to fail - Store's contention example built nil (Thread.pass && Object.new), so every thread got the same nil whatever fetch did. - The tailwind_utils contention setup is now proven to reach the build. - The constants spec walks every module under ClassMerge instead of a hand-kept list. - The non-Ractor contexts say they are simulated. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: qualify the any-Ractor guarantee; merger rebuild and frozen registries Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(config): serialize configure, so concurrent calls keep each other's settings Since configure works on a copy, two concurrent calls copied the same configuration and the last to publish dropped the other's changes. A per-Ractor Monitor (DaisyUI::Store) now serializes the copy, yield and publish, so each call starts from what the previous one published. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(class-merge): build a merger from one configuration snapshot build_merger recorded the configuration it captured, but DaisyGroups.build read the prefix and modifiers of whatever configuration was published at that moment. The configuration is now passed through merger_config to DaisyGroups.build. Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs(changelog): registry writers replace values only as of this release Refs #58 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): label the grouped conformance-kit Dependabot PRs Without labels Dependabot tags its PRs ruby/github_actions, which are not in the label taxonomy; dependencies + devops are. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
) * chore(ci): call conformance-kit's conformance workflow at v0.3.0 Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot keep both pins current. The conformance check is not required yet: it turns green, and becomes required, with #364. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): group the two conformance-kit pins into one Dependabot update A tag bump without the gem (or the reverse) fails the conformance version check, so both now arrive in a single multi-ecosystem pull request. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(core): per-Ractor Store instead of Concurrent::Map constants Fragment::VIEWER_WARNED and FragmentsController::CSRF_TOKEN_PATTERNS were Concurrent::Map constants: unreadable from a non-main Ractor, and concurrent-ruby is not a declared dependency (it only arrives through ActiveSupport). The defer/pending once-per-process warnings wrote a module ivar on the request path, which a non-main Ractor can never do. Phlex::Reactive::Store is per-Ractor lazy state (Ractor.store_if_absent, with one Mutex-guarded table on an engine without Ractors), the pattern of conformance-kit's ConformanceKit::Store copied in because the kit is a development dependency only. Store::Table is the Mutex-guarded Hash one Ractor's threads share. lib/ and app/ no longer reference Concurrent::. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(core): finalize the configuration at boot (Phlex::Reactive.finalize!) A non-main Ractor can read a module ivar only when its value is Ractor-shareable and can never write one, so after boot every reader must return a shareable value and no reader may write on first use. - Every setting starts at its default when the gem loads (SETTING_DEFAULTS), so the readers are plain attr_readers. The lazy ones left are the verifier (it needs Rails.application) and the param-type registry (ParamSchema loads ActiveModel), both built at boot by finalize!. - Settings are read and written through two private helpers, the one place with an inline ThreadSafety/ClassInstanceVariable disable and the reason. - finalize! builds the lazy defaults, freezes the param-type and schema registries, and stores each setting as a deep-frozen shareable copy (Phlex::Reactive::Shareable.share), never freezing the app's own objects. The verifier (an ActiveSupport::MessageVerifier) and Procs whose self is not shareable stay as they are, main-Ractor only. - Writers keep working after it and what they write is shared too; around_action / on_action_error / register_token_upgrader replace a frozen list instead of appending. finalized? and a test-only reset_finalization!. - The engine finalizes from an after_initialize block registered after the app's config/initializers, so an app's own after_initialize block that appends to authorization_errors still finds the list open. The lib/phlex/reactive.rb, defer.rb and pending.rb ThreadSafety excludes go: those files no longer need them. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(controller): instrument verify_authorized at boot, lock the lazy path Authorization.instrument! ran on the request path: a Module#prepend and an instance_variable_set on the component class per first request, with a check-then-set race (two threads could prepend two interceptors). With eager loading the engine now instruments every registered component class at boot (Authorization.instrument_all!, from finalize_boot!), so the endpoint's call is a bare "already instrumented" read. The lazy path stays for development and for a class defined after boot, and holds a per-Ractor lock (Phlex::Reactive::Store) across the check and the prepend. The spec forces the race with a Queue handshake: two interceptors before, one now. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * refactor(component): warm every component's registries at boot Component::Registry built each class's resolution cache, and Identity and the DSL their per-class token and root-attrs memos, on the first request that rendered the class: a write on the component class from the request path, which a non-main Ractor cannot do and request threads race on. Registry.warm!(classes) resolves every registry kind (Registry::READERS, one reader per OWN_IVARS kind, checked by the spec) and builds those memos, then freezes the class's resolution cache; a frozen cache answers a kind it lacks without memoizing it. The engine runs it from finalize_boot! when the app eager-loads. A declaration after it (a development reload) bumps the generation and rebuilds a fresh cache, as before. Identity.per_class_gid_app? was a lazy module-ivar memo read on the token path; it is now a constant computed when the file loads. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * refactor(lint): inherit conformance-kit's RuboCop config, no ThreadSafety excludes .rubocop.yml inherits conformance-kit: config/rubocop.yml (every rubocop-thread_safety cop on, MutableClassInstanceVariable with literals, LazySynchronizationPrimitive) and loses the file and directory excludes of ClassInstanceVariable and ClassAndModuleAttributes. Fixed rather than excluded: - Streamable.register starts a class's view-context generation at 0 when the class is defined, so the render path's reader is a plain attr_reader. - The APM adapter memo is per-Ractor state (Store), not a module ivar. - Registry reads its generation through its reader. - Every ActiveSupport::Concern module seals the bookkeeping Concern keeps on it (@_dependencies frozen, the included block made shareable) at the end of its body (Shareable.seal_concern). Inline disables, each with the reason it is safe on the line(s) above: the per-class render memos (warmed at boot by Registry.warm!), the view-context generation bump (to_prepare / specs, main Ractor), the APM subscription (installed at boot), and the dummy's test-fixture dials. NewThread is excluded only for the five spec files that start threads on purpose. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * refactor(core): per-Ractor locks and weak maps, none in constants or ivars A Mutex, a WeakMap and a WeakKeyMap are never Ractor-shareable, so held in a constant or a module ivar no other Ractor can even read them. The three the gem keeps are now per-Ractor state (Phlex::Reactive::Store): - Component::Registry's generation-bump lock (was the WRITE_MUTEX constant). - Streamable's class registry: a ClassRegistry (WeakMap + Mutex) per Ractor. Classes are defined on the main Ractor (boot, a development reload), so in practice it is the one registry it was. - Pending::Markup's variant memo: a Variants holder (WeakKeyMap + Mutex). Behaviour is unchanged on the main Ractor; the audit no longer has to allowlist any of them. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): Ractor canary for the pure operations, on Ruby 4.0 conformance-kit's "runs inside a non-main Ractor" example, tagged :ractor and skipped below Ruby 4.0 with the kit's reason, over what a non-main Ractor can run once the configuration is finalized: param coercion through a compiled ParamSchema, Phlex::Reactive::Stream wrapping, Streamable#dom_id, reading the finalized configuration, and token-payload upgrades. ParamSchema::BOOLEAN_TYPE is now Ractor-shareable: the canary found the :boolean caster unreadable from a non-main Ractor. Two canaries are pending, each with its upstream reason, and fail loudly the day they pass: signing/verifying (an ActiveSupport::MessageVerifier cannot be built or used outside the main Ractor) and rendering (Phlex 2.4.1 reads its unshareable ATTRIBUTE_CACHE on every element with attributes, yippee-fun/phlex#1055). Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): .conformance.yml, eager-loading audit, CI Postgres for the run - audit.require boots the dummy app (which finalizes the configuration) and loads all of the gem: the engine's app/, lib/ through the gem's own loader, the two ActiveJob classes left out of eager loading, the MCP tools and the browser test helpers. strict_autoloads is on, nothing skipped. - main_only lists only state that is not the gem's to fix, each with its reason: Rails::Engine's and ActionController's own class state on the gem's engine and controllers, the ActiveSupport::MessageVerifier, and the mcp gem's tool DSL state. - The Ractor examples and the audit run with pgbus present against Postgres (ractor.env TRANSPORT=pgbus). bin/conformance-db is ractor.setup: in GitHub Actions it gives the runner's user a role on the workflow's postgres:18 service through its postgres/postgres superuser, so the user-less DATABASE_URL works there and locally (your own user, your own phlex_reactive_pgbus_test), then loads the schema. - probes: gem_name and roundhouse_app (spec/dummy). Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(docs): resolve conformance-kit in the docs app's bundle RuboCop in docs/ reads the gem's .rubocop.yml as an ancestor config, and that one now inherits conformance-kit's config (inherit_gem), so the docs app's `rake lint` failed with "Unable to find gem conformance-kit". The docs Gemfile already mirrors the root's RuboCop plugins for the same reason; it now lists conformance-kit too (development group, require: false). Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(devtools): phlex_reactive:doctor reports Ractor readiness A ractor_readiness check, the same shape as the fiber isolation one: eager loading on, the configuration finalized, and every setting a non-main Ractor can read (Doctor.unshareable_settings names each one that holds an unshareable value, with its class). Advisory only, never a failure: no app has to run Ractors, and the ok line names what still blocks Ractor mode upstream (Rails, ActiveSupport's MessageVerifier, Phlex rendering, yippee-fun/phlex#1055). It never loads a server. Phlex::Reactive::CONFIG_READERS is the one list of configuration readers, shared by the doctor and the finalize spec. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(core): renderer by name follows a code reload A renderer given as a Class is that class object for good: once Zeitwerk reloads it in development, re-renders and broadcasts keep running the old, unloaded class (reproduced by the new spec). Phlex::Reactive.renderer now also takes a name, like base_controller_name: it resolves on every read, so a reloaded class is picked up and the per-thread view contexts rebuild for it (they are keyed on the renderer's identity). finalize! keeps the name. A Class keeps working as before. The README and the install template show the name. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): thread and Async contention examples for the hot paths conformance-kit's "thread-safe under contention" and "fiber-safe under an Async reactor" examples over rendering a component, building a replace stream, signing/verifying a token, coercing params, a component class's first registry resolution, and broadcasting. Tagged :concurrency, and the conformance run's test_command now runs them too (--tag ractor --tag concurrency), with pgbus present there: the broadcast examples go over pgbus in that run and over Action Cable in the regular suite. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): name the markup comparison of the contention examples The autocorrected lambda nested one implicit `it` inside another; a named method reads unambiguously. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * perf(core): seed the param-type registry when the gem loads ParamSchema#coerce reads Phlex::Reactive.param_types once per scalar param, and the finalize-at-boot change had made that reader go through the settings helper (a lazy "build on first use" read). Seeding the registry below the loader makes it a plain attr_reader again. ParamSchema loads ActiveModel, which the engine's boot already required (it freezes the registry in after_initialize). coerce_params bench, same machine, 3 runs each (i/s): before the branch 35.2k / 39.8k / 38.3k, after 39.4k / 40.1k / 39.8k; 200 objects per call both ways. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(bench): what the per-fiber view-context rebuild costs a Falcon request benchmark/micro/view_context.rb measures building the off-request view context, then a full action round trip through the Rack stack twice: in one fiber (Puma: the Thread.current caches stay warm) and each request in a new fiber (Falcon: they rebuild). The difference is what a shared pool of view contexts could save a Falcon request. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(core): RBS signatures for the public surface, validated in Lint sig/ declares the configuration and token surface of Phlex::Reactive, a component's class-level DSL (Component::DSL::ClassMethods), Streamable's class methods, Response and ParamSchema, plus Store and Shareable. Rails, Turbo and Phlex objects are `untyped`: those gems ship no RBS. The gem now ships sig/. - Lint runs `bundle exec rbs -I sig validate` (rbs added to the development/test group; it was already in the lockfile through rdoc). - spec/phlex/rbs_spec.rb is a reflection diff: every public method the gem defines on those surfaces must have a signature, and a missing one is named. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * ci: report-only kino smoke of the dummy app in Ractor mode The allowed-to-fail kino-smoke job of #364, in this repo's own main.yml: the dummy app served by kino (-m ractor) through ractor-rails-shim on Ruby 4.0.6, GET /up, then one reactive action (CounterComponent#increment). scripts/kino_smoke.sh writes `kino --check`, both statuses and the first blocker to the job summary and an artifact; continue-on-error, so it never fails the build. Today, locally: kino --check passes (the shim makes the app shareable), and both requests 500 in the worker Ractors (a worker crash, then Rails' ActionDispatch::ParamBuilder.default is nil there). kino and the shim live in gemfiles/kino.gemfile only (eval_gemfile of the gem's Gemfile), never in the gem's bundle; that gemfile keeps no lockfile, so the job installs the bundle fresh. The dummy gains Rails' /up route. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: a Concurrency & Ractors guide page The tiers phlex-reactive is held to and their gates, why request state is fiber-local (and what the per-fiber view-context rebuild costs a Falcon request, measured), the configuration finalized at boot and what it changes for an app, what Ractor mode needs (eager loading) and what waits upstream (Phlex rendering, yippee-fun/phlex#1055; ActiveSupport's MessageVerifier; Rails), and the doctor's Ractor readiness line. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: CHANGELOG entries for the concurrency conformance work Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(component): Sortable requires json, which it calls while it loads Component::Sortable builds SORTABLE_MESSAGES_JSON with Hash#to_json in its module body, so eager-loading the gem in a process that had not loaded json yet raised NoMethodError (phlex-forms' conformance audit hit it and worked around it with its own require). The eager-load spec now boots a fresh process without json and eager-loads the namespace. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): conformance-kit 0.4.0, workflow caller at v0.4.0 The caller, both Gemfile pins (root and docs) and their lockfiles move to 0.4.0 together. docs/Gemfile pins the kit too, so Dependabot's bundler entry now watches "/" and "/docs". Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * chore(ci): main_only ancestor: rules for framework class state conformance-kit 0.4.0 covers the class state a framework keeps on the gem's own subclasses with one ancestor: rule per framework. Rails::Engine (4 ivars), ActionController::Base (3 ivars on the two controllers) and MCP::Tool (the tools' DSL state) replace 16 name: entries; each rule is narrowed with ivars: to exactly the framework's ivars, so state the gem keeps on the same classes is still audited. The verifier stays a name: entry: it is the gem's own setting. conformance-audit --strict allowlists the same 17 names before and after, with no stale entries. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): finalizing at boot never raises, even without a secret_key_base finalize! builds the verifier at boot so a request never writes it. An app booting without a secret_key_base (a production rake task or build step) made Rails raise ArgumentError there, where the verifier used to be built on first use. It now stays lazy in that case: boot finalizes, and the first token raises as before. The boot spec also covers what the brief asks for: the engine's hook finalizes with an initializer's closures over app state, a Logger and an APM object configured, keeping each one working and unfrozen; and every registry an app registers into is frozen at boot with no call of its own. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: the configuration is finalized at boot, in the README too The README's configuration section says what finalize! changes: lists frozen after boot, unshareable values kept on the main Ractor (finalizing never raises for them), and finalize! as the call for a boot without the engine. The Concurrency & Ractors page says the same. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: CHANGELOG section for 0.16.0, with the behaviour changes up front This PR's entries move under ## [0.16.0], led by "Upgrading from 0.15.x": the configuration finalized automatically at boot, its lists (and every setting) frozen afterwards, and nothing an app configures making boot raise. The entries left under [Unreleased] had all shipped (tags 0.14.2 to 0.15.5 cut no sections). They now sit under ## [0.15.5] with a consolidation note, as 0.9.0 did for 0.2.7 to 0.4.8, so none of them reads as new in 0.16.0. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): the registry example drives the boot hook from a reset state It read the registries as the dummy booted them, but other examples reset the param-schema registry without refreezing it, so under some seeds it saw an open registry. It now reopens every registry, runs the engine's boot hook and asserts each one is frozen, like the examples beside it. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): Shareable copies Procs and keeps a Hash's default From the PR review (cubic): - seal_concern called Ractor.make_shareable with no Ractor check, so on TruffleRuby or JRuby every concern in the gem raised NameError as it loaded. It is a no-op without Ractor now. - share made a capture-free Proc shareable in place, freezing the app's own Proc; it shares a copy, as it does for plain data. - A Hash lost its default value, a Hash with a default proc was only shallow-copied (the app's nested values stayed shared with the setting), and compare_by_identity was dropped. Every Hash is now a plain copy with shared entries that keeps all three. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * feat(core): boot warms client-only components and shares their memos From the PR review (cubic): - A ClientBindings-only view never registers with Streamable, so the boot hook never warmed it, and its first render built the root-attrs memos on the class. Every class with the DSL is now tracked (Component::Registry.track, from ClientBindings' included and inherited hooks), and the boot hook warms all of them. verify_authorized's interceptor still goes only on the token-bearing classes. - warm! built the render path's memos but left them unshareable (an Array of pairs, an attribute Hash). They are the gem's own plain data, so warm! now stores shared copies a non-main Ractor can read. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * test(core): tighter conformance specs, and no state leaks between them From the PR review (cubic): - The contention examples also require every render to carry a signed token (they compared the markup with the token stripped). - The viewer-warning race starts all eight threads from a barrier. - The Ractor canary puts the module's settings back after finalizing them. - The APM memo example exercises the memo (a stubbed available SDK). - The renderer-reload teardown restores the stored setting, so a name is not turned into a class for the rest of the suite. - finalize_spec asserts every value it set; the boot-hook example registers into every registry before asserting it is frozen. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(rbs): signatures match what the methods do, and cover more of the surface From the PR review (cubic): - reactive_dirty's only: takes a single name; reactive_lazy's tag: a String; reactive_compute is a setter that needs inputs: or outputs: (two overloads); reactive_collection?, reactive_compute? and reactive_compute_def always raise (bot); Response.dismiss_attr returns a String; the removed Response.with is signed like the other removed verbs (ActiveSupport's Object#with hid it from the reflection diff). - around_wrapper is a proc type taking the continuation block (an RBS method block cannot declare one, so around_action's comment says so). - Store::NATIVE is a boolean on an engine without Ractor too, as signed. - The reflection diff covers Streamable's instance methods (#id, dom_id, to_stream_*), Store and Shareable; Streamable's are signed. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * docs: review fixes to the doctor's advice, the CHANGELOG and the bench note From the PR review (cubic): - The doctor's Ractor-readiness fix covered only callables; it now says how to share any other value, and that one which cannot be shared stays main-Ractor only. - CHANGELOG: the doctor entry says which settings it reports, the conformance entry names NewThread's five spec-file excludes, the RBS entry lists the wider surface, and boot warming covers client-only views. - The view-context bench describes its delta as an upper bound: it includes creating the fiber. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * ci: bounded kino-smoke requests, its server log kept, Dependabot labels - Every curl in the kino smoke has a --max-time, so a worker Ractor that accepts and never answers is recorded instead of hanging the job; the artifact keeps kino's full server.log too (cubic review). - The conformance-kit Dependabot group labels its PRs dependencies + devops, from the zoolutions taxonomy, instead of Dependabot's default ruby / github_actions labels. Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU * fix(core): Shareable shares a Hash's default proc too The copy kept the caller's default proc as is, so a Hash whose default proc could be shared still came back unshareable (cubic review). Refs #364 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Summary
Rendering a component inside a non-main Ractor fails on every Phlex release:
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.
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.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.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.ATTRIBUTE_CACHE.Verified on Ruby 4.0.6: standard, void, custom (
register_element), SVG andtag(…)elements,cache, and compiled components all render on worker Ractors byte-identical to the main Ractor.Environment notes
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.Phlex::Loader.eager_loadfirst. On 4.0 a worker-triggered autoload runs on the main Ractor (verified forPhlex::SVG).headlanes need refract with Prism'sErrorRecoveryNodesupport (fix: convert Prism::ErrorRecoveryNode, which replaced MissingNode refract#4, merged); they pass on this branch since mergingmain.Benchmarks (
bench.rbverbatim, Ruby 3.4.2 +YJIT, same machine)Against
main: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 calltag.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 slowbench.rb~2×, which is why the registries are frozen in place once instead. Custom harnesses thatGC.startbefore measuring flip into anofreemajor-GC loop on unrelated changes, so compare withbench.rband 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 offensesquickdraw/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 shareablequickdraw/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 mutablequickdraw/fifo.test.rb:grow_tosemantics and racing threadstag+ 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"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.)The
Examplecomponent 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 withRactor.store_if_absent, and the render tests only whereERB::Escapeis Ractor-safe.