feat(core): concurrency conformance via conformance-kit v0.4.0
Merged
MH
1mo ago+781+781+012345678901234567890123456789-62-62-01234567890123456789
Summary
phlex-forms passes conformance-kit v0.4.0's gates: conformance / Conformance (Ruby 4.0) is green, and so are Lint and the Specs on 3.4 and 4.0. This PR adds the kit's caller workflow and makes the gem conform. That conformance work is tracked in #42. Ships as 0.5.0 (## [0.5.0] in the CHANGELOG).
- Tier 1 (RuboCop):
.rubocop.ymlinheritsconformance-kit: config/rubocop.yml, and the Gemfile addsrubocop-thread_safety >= 0.8. All 22ThreadSafety/ClassInstanceVariableoffences inlib/are fixed, and nothing is baselined. Three inline disables remain, each with its reason:lib/phlex_forms.rb: onedisable-nexton the module'sclass << self(configuration accessors).@configurationis built at load and replaced only on the main Ractor, at boot or between tests. It is shareable once finalized.lib/forms/base.rb: one disable/enable pair aroundform_optionsand its two readers.form_optionsis a class macro that runs while the class body loads, and it stores shareable copies.lib/forms/live.rb: one pair around thelive/live_permit/live_deny/live_tagsmacros and their readers, for the same reason.
- Tiers 1–2 (contention):
spec/phlex_forms/contention_spec.rbruns the kit's thread and Async examples overTheme.resolve, withconsistent: :identical. - Tier 3 (finalize at boot, automatic):
- Under Rails, the engine calls
PhlexForms.finalize!inconfig.after_initialize, after every initializer has run, with no app code. - Without Rails (no
Rails::Enginewhen the gem loads),PhlexForms.configurefinalizes when its block returns. A host that never callsconfigurecallsPhlexForms.finalize!at boot (documented). finalize!stores Ractor-shareable copies of every setting and then freezes the configuration. The caller's own arrays and hashes are copied, never frozen.- Finalizing never raises over a value it cannot share. That value is kept as given and works on the main Ractor only, for example a top-level initializer lambda (its
selfismain) or one that closes over aLogger. - New this round: an icon renderer that is not a Proc (an object with
#call, aMethod) is never copied or frozen. Before,make_shareable(copy: true)replaced a renderer that keeps a cache with a frozen copy, and that copy raisedFrozenErroron its first render. configureon a finalized configuration edits a copy and swaps it in. A direct write after boot raisesFrozenError. A direct write in a Rails initializer, before boot, still works.Theme::DAISY/Theme::PLAINare built once, whentheme.rbloads, and everyThemeis frozen.form_optionsand thelivemacros store shareable copies.
- Under Rails, the engine calls
- Ractor canary (Ruby 4.0):
spec/phlex_forms/ractor_canary_spec.rb(:ractor) runs these inside a non-main Ractor: field option resolution and inference on a Struct model, theme resolution, the icon renderer, and theform_options/livesettings. Rendering is apendingexample with the Phlex reason. - Audit:
audit.require: [./spec/conformance_eager_load.rb]eager-loads both Zeitwerk roots and callsfinalize!. The audit runs withnamespace: [PhlexForms, Forms]andstrict_autoloads: true. There is nomain_onlyand noskip_autoloads. - Bugs fixed along the way:
- Without phlex-reactive, the first form raised
NameError: Forms::TagField. phlex-reactive is meant to be a soft dependency; in practice it was a hard one. - Without Rails but with phlex-reactive bundled, the first form raised
NoMethodError: Hash#to_json. Because finalizing builds the themes,configurenow raised it too. The cause is phlex-reactive'sSortable, which does not require json. phlex-forms now requires json before phlex-reactive (spec/phlex_forms/plain_boot_spec.rb). - Review fixes:
configurereturns its block's value again.field_variants = nilis stored as[], so a later<<is no longer lost.
- Without phlex-reactive, the first form raised
bundle exec rakepasses from the root. It used to abort onmain. The root RuboCop now excludesdocs/, a separate Rails app with its own bundle and.rubocop.yml. The Gemfile's oneBundler/OrderedGemsoffence is fixed.
Refs #42
Test plan
-
bundle exec conformance-kit check, Ruby 4.0.6, local, kit 0.4.0. It exits 0:- doctor:
ok (conformance-kit 0.4.0, .conformance.yml) bundle exec rspec --tag ractor:6 examples, 0 failures, 1 pending(the render canary)conformance-audit: 0 findings in PhlexForms, Forms (0 allowlisted main-only; 70 modules audited, 0 unloaded autoloads skipped)conformance-kit check passed
- doctor:
-
conformance-audit --strictbefore the bump (0.3.0) and after it (0.4.0) gives the same result: 0 allowlisted main-only names, 0ancestor:rules. The allowlisted set gained no name. -
bundle exec rspec, Ruby 4.0.6:241 examples, 0 failures, 2 pending(the render canary and the existingTimeZoneSelectskip). -
bundle exec rspec, Ruby 3.4.11:241 examples, 0 failures, 7 pending. The 6 Ractor examples are skipped with the kit's reason. -
bundle exec rubocop lib spec(what CI lints) on 4.0.6 and 3.4.11: 99 files, no offences.bundle exec rakeexits 0 on both Rubies. - New this round, each seen failing first:
spec/phlex_forms/rails_boot_spec.rbboots a minimal Rails app in a child process. It checks three things: the default configuration is frozen and shareable after boot with no app code; initializer writes (configureand a direct write) are kept; and a top-level lambda renderer that closes over aLoggerboots and still renders. With the engine'safter_initializeline removed, it failed 3 of 3 (frozen: false).finalize_spec"keeps a renderer object that holds its own state". It failed before the fix: the renderer had been replaced by a frozen copy.plain_boot_specboots without Rails in a child process with json not preloaded. Before the fix it failed withNoMethodError: Hash#to_jsonfromconfigure.finalize_spec"returns the block's value" and "keeps field_variants appendable after it is set to nil": 2 failures before the fixes.
- Round 1's checks were also seen failing first: the RuboCop offences,
finalize_spec, the canary, the audit, the soft-dependency spec and the contention spec. - CI on
e17c21f: Lint, Specs (3.4), Specs (4.0), Conformance settings, Conformance (Ruby 4.0) (handshakeconformance-kit 0.4.0 satisfies the workflow's v0.4), Compiler probes and cubic all pass. 0 unresolved review threads.
Remaining from #42
- Rendering inside a non-main Ractor. Phlex reads its non-shareable
Phlex::ATTRIBUTE_CACHEconstant on every element that has attributes. A per-Ractor cache is pending upstream in yippee-fun/phlex#1055. The canary's render example ispendingwith that reason, so RSpec reports it once Phlex lands. Under the daisy theme, rendering also hits daisyui's class-level modifier maps (DaisyUI::Input.@modifiers). That part belongs to daisyui's own rollout. - Required status checks.
conformance / Conformance settingsandconformance / Conformance (Ruby 4.0)still need adding to the branch ruleset. That is for the maintainer: this PR changes no repo settings.
Deviations & judgment calls
Round 1:
- No
main_onlyforForms::Base. Its class-body ivars are made shareable when written, with a commented inline disable. The audit never sees host subclasses, so an entry would be stale. - Themes are built when
theme.rbloads, not insidefinalize!.finalize!resolves the default theme, so they still load at boot.Theme::DAISYandTheme::PLAINare new public constants. - The configuration stays open until it is finalized, per the issue's Decision. A direct write after boot raises
FrozenError;configurekeeps working. - One default icon renderer for every Ruby: a capture-free lambda made shareable in the class body. On 3.4,
make_shareablerejects aMethod. Forms::Live.@_dependenciesis frozen, not listed inmain_only. ActiveSupport::Concern only reads it after the module body has loaded.register_element→tag()inRichTextarea. Phlex 2.0.0, the gemspec floor, already hastag.- The contention spec checks
Theme.resolve. The issue namesTheme.component_for, which doesn't exist. - The canary covers more than field inference. It uses a Struct model, as the issue says.
- New development-only piece: the
asyncgem. Round 1 also added arequire "json"to the eager-load file; round 2 moved it into the gem. rubocop:disable-nextneeds RuboCop 1.92 or later. No lockfile is tracked, so CI resolves the latest RuboCop.- No
ConformanceKit::Storeor Mutex pattern was needed: no lazy state is left. - The docs-site Configuration page is not updated. Only the README and CHANGELOG are, because a docs-page change needs screenshots.
Round 2:
- Auto-finalize was already wired in round 1 (the engine's
after_initialize, plus the end ofconfigurewithout Rails). This round adds the real-boot proof (rails_boot_spec) instead of only thestub_constspec. It runs in a child process, so the engine never loads into the suite. Without the engine,configurefinalizes by itself. railties ~> 8.0added to the development group. The boot spec requires it. It was already in the bundle through phlex-reactive, so the resolution is unchanged.- Non-Proc icon renderers are kept as given (
PhlexForms::Shareable.callable). The other choice was to share them in place withmake_shareable. That would freeze the host's own object and break it the same way. - The "outside Rails" boot point is the end of
configure, notrequire. Finalizing atrequirewould load every component at require time (round 1 hit phlex-reactive's missingrequire "json"doing that). It would also freeze a configuration the host has not configured yet. A host that never callsconfigure, or loads Rails without booting an app, callsPhlexForms.finalize!(README). - No
main_onlyentries to convert toancestor:rules. There were none before (0 allowlisted), and there are none after. The audit's scope is unchanged: booting the engine into it would have added Rails::Engine class state to allowlist. - Dependabot is unchanged. No Gemfile besides the root pins conformance-kit (
docs/Gemfiledoesn't), and the rootGemfile.lockis not tracked, so there was no lockfile to update. - CHANGELOG: this repo never had version headings, and released 0.4.x entries still sat under
[Unreleased]. This PR's entries move to a new## [0.5.0]section, which opens with an "Upgrading to 0.5" list. The released pile below it is headed## [0.4.1] and earlier; otherwise it is untouched. bundle exec rake:docs/is excluded from the root RuboCop (comment in.rubocop.yml). The Gemfile is re-sorted: phlex-reactive's block moved belowgem-release.- Cubic findings:
- The one-sided Dependabot group PR is intended behaviour. Dependabot cannot wait for both ecosystems; the version handshake fails a one-sided PR, as conformance-kit's README documents.
- "
patternsis invalid at the update-entry level" is a false positive. GitHub's multi-ecosystem docs require it there. - Three findings are fixed: the CHANGELOG/README wording on Ractor shareability,
configure's return value, and nilfield_variants. For the last one, the nextconfiguredid not actually fail (nil.dupis nil), but a<<after nil was lost.
- The json require lives in the gem. The eager-load file's workaround was dropped. phlex-reactive's own fix can't be relied on: it is unreleased.