ractor-rails-shim 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +265 -0
  5. data/exe/ractor-rails-check +39 -0
  6. data/lib/ractor_rails_shim/check.rb +190 -0
  7. data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
  8. data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
  9. data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
  10. data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
  11. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
  12. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
  13. data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
  14. data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
  15. data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
  16. data/lib/ractor_rails_shim/patches/core.rb +806 -0
  17. data/lib/ractor_rails_shim/patches/devise.rb +172 -0
  18. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
  19. data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
  20. data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
  21. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
  22. data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
  23. data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
  24. data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
  25. data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
  26. data/lib/ractor_rails_shim/patches/rack.rb +160 -0
  27. data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
  28. data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
  29. data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
  30. data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
  31. data/lib/ractor_rails_shim/patches/warden.rb +195 -0
  32. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
  33. data/lib/ractor_rails_shim/patches.rb +67 -0
  34. data/lib/ractor_rails_shim/version.rb +5 -0
  35. data/lib/ractor_rails_shim/version_check.rb +88 -0
  36. data/lib/ractor_rails_shim.rb +33 -0
  37. metadata +104 -0
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch Zeitwerk::Registry to route its class ivars (@loaders, @mutex,
4
+ # @gem_loaders_by_root_file, @autoloads, @explicit_namespaces, @inceptions)
5
+ # through IsolatedExecutionState. Zeitwerk uses raw class ivars (not
6
+ # mattr_accessor), so the shim's macro rewrite doesn't catch them. Each
7
+ # Ractor that boots a Rails app gets its own registry state (its own
8
+ # loaders list, its own mutex, its own autoloads map), which is correct
9
+ # for per-Ractor boot: each worker's autoloaders are independent.
10
+ #
11
+ # The defaults are the same classes Zeitwerk instantiates at module-load
12
+ # time (Loaders, Hash, Autoloads, ExplicitNamespaces, Inceptions, Mutex).
13
+ # Workers lazily build their own on first read.
14
+
15
+ module RactorRailsShim
16
+ class << self
17
+ def install_zeitwerk_registry
18
+ return if @zeitwerk_patched
19
+ @zeitwerk_patched = true
20
+ _register_patch :zeitwerk_registry, "8.1"
21
+ if defined?(::Zeitwerk::Registry)
22
+ patch_zeitwerk_registry!
23
+ else
24
+ # Defer until Zeitwerk loads. A TracePoint(:class) fires when
25
+ # `module Registry` opens. One-shot.
26
+ @zw_tp = TracePoint.new(:class) do |trace|
27
+ if defined?(::Zeitwerk::Registry) && !@zeitwerk_registry_patched
28
+ @zw_tp.disable
29
+ patch_zeitwerk_registry!
30
+ end
31
+ end
32
+ @zw_tp.enable
33
+ end
34
+ end
35
+
36
+ def patch_zeitwerk_registry!
37
+ return if @zeitwerk_registry_patched
38
+ @zeitwerk_registry_patched = true
39
+
40
+ reg = ::Zeitwerk::Registry
41
+ # The ivars Zeitwerk sets at the bottom of registry.rb. Map each to an
42
+ # IES key and a default-builder string (eval'd in the reader when the
43
+ # Ractor's slot is empty). Builders reference Zeitwerk constants by
44
+ # full path so they're resolvable from any Ractor.
45
+ ivars = {
46
+ loaders: [:ractor_rails_shim_zw_loaders, "Zeitwerk::Registry::Loaders.new"],
47
+ gem_loaders_by_root_file: [:ractor_rails_shim_zw_gem, "{}"],
48
+ autoloads: [:ractor_rails_shim_zw_autoloads, "Zeitwerk::Registry::Autoloads.new"],
49
+ explicit_namespaces: [:ractor_rails_shim_zw_explicit, "Zeitwerk::Registry::ExplicitNamespaces.new"],
50
+ inceptions: [:ractor_rails_shim_zw_inceptions, "Zeitwerk::Registry::Inceptions.new"],
51
+ mutex: [:ractor_rails_shim_zw_mutex, "Mutex.new"],
52
+ }
53
+
54
+ # Redefine each reader (and the mutex, which is read directly as @mutex)
55
+ # to route through IES with lazy per-Ractor init. Use a PREPENDED module
56
+ # (not direct module_eval on the singleton class) because Zeitwerk's
57
+ # `attr_reader :loaders` etc. run LATER in the module body and would
58
+ # clobber a direct redefinition — same load-order issue as the Rails
59
+ # module accessors. A prepended module stays in front of the lookup chain.
60
+ #
61
+ # In the MAIN ractor, fall back to the existing ivar (set by Zeitwerk at
62
+ # the bottom of registry.rb) so main-ractor state is preserved. Worker
63
+ # ractors lazily build their own via the builder string.
64
+ reader_patch = Module.new
65
+ ivars.each do |ivar, (key, builder)|
66
+ key_str = key.inspect
67
+ ivar_sym = :"@#{ivar}"
68
+ ivar_str = ivar_sym.inspect
69
+ reader_patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1
70
+ def #{ivar}
71
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
72
+ return v unless v.nil?
73
+ if Ractor.main?
74
+ existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str})
75
+ if existing
76
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = existing
77
+ return existing
78
+ end
79
+ end
80
+ v = #{builder}
81
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = v
82
+ v
83
+ end
84
+ RUBY
85
+ end
86
+ reg.singleton_class.prepend(reader_patch)
87
+
88
+ # `conflicting_root_dir?` and `loader_for_gem` read @mutex / @gem_loaders
89
+ # directly via instance_variable_get-ish access (they use @mutex in the
90
+ # method body). Since we redefined the readers, the direct @mutex refs
91
+ # in those methods still hit the ivar. We need to rewrite those two
92
+ # methods to call the reader instead. Easiest: prepend a module that
93
+ # calls self.mutex / self.gem_loaders_by_root_file.
94
+ reg.singleton_class.prepend(Module.new {
95
+ def conflicting_root_dir?(loader, new_root_dir)
96
+ mutex.synchronize do
97
+ loaders.each do |existing_loader|
98
+ next if existing_loader == loader
99
+ existing_loader.__roots.each_key do |existing_root_dir|
100
+ next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
101
+ new_root_dir_slash = new_root_dir + '/'
102
+ existing_root_dir_slash = existing_root_dir + '/'
103
+ next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
104
+ next if loader.__ignores?(existing_root_dir)
105
+ break if existing_loader.__ignores?(new_root_dir)
106
+ return existing_loader
107
+ end
108
+ end
109
+ nil
110
+ end
111
+ end
112
+
113
+ def loader_for_gem(root_file, namespace:, warn_on_extra_files:)
114
+ h = gem_loaders_by_root_file
115
+ h[root_file] ||= Zeitwerk::GemLoader.__new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
116
+ end
117
+
118
+ def unregister_loader(loader)
119
+ gem_loaders_by_root_file.delete_if { |_, l| l == loader }
120
+ end
121
+ })
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The core shim: reroute Rails' class-level instance variable accessors
4
+ # through Ractor-safe storage.
5
+ #
6
+ # Background: Rails stores global state in class ivars:
7
+ #
8
+ # class Rails
9
+ # class << self
10
+ # attr_accessor :app_class, :cache, :logger
11
+ # def application; @application ||= ...; end
12
+ # end
13
+ # end
14
+ #
15
+ # From a non-main Ractor these reads/writes raise Ractor::IsolationError:
16
+ # "can not get unshareable values from instance variables of classes/modules
17
+ # from non-main Ractors"
18
+ #
19
+ # The fix: store the values in ActiveSupport::IsolatedExecutionState, which
20
+ # is already Ractor-safe. It's thread-local storage (Thread.current[:key]),
21
+ # and each Ractor has its own threads, so each Ractor gets its own slot.
22
+ # Verified on Ruby 4.0.5: a non-main Ractor reads nil for a key the main
23
+ # Ractor set, sets its own value without error, and main's value is intact.
24
+ #
25
+ # IMPORTANT: all method redefinitions use module_eval with STRING, not
26
+ # define_method with a block. A block captures the defining Ractor's
27
+ # binding, and calling it from another Ractor raises:
28
+ # "defined with an un-shareable Proc in a different Ractor"
29
+ # String eval produces methods with no captured binding, callable from any
30
+ # Ractor. Verified on Ruby 4.0.5.
31
+
32
+ begin
33
+ require "active_support/isolated_execution_state"
34
+ rescue LoadError
35
+ # ActiveSupport not installed — the fallback below provides the same API.
36
+ end
37
+ require_relative "fallback_ies"
38
+ require_relative "version_check"
39
+
40
+ # Per-concern patch files. Each reopens RactorRailsShim's singleton class
41
+ # to add its `_install_*` method(s). The order matters only for constants
42
+ # (core.rb defines the module skeleton + constants that others reference).
43
+ require_relative "patches/core"
44
+ require_relative "patches/make_shareable"
45
+ require_relative "patches/rails_module"
46
+ require_relative "patches/mattr_accessor"
47
+ require_relative "patches/class_attribute"
48
+ require_relative "patches/zeitwerk_registry"
49
+ require_relative "patches/route_helpers"
50
+ require_relative "patches/url_helpers"
51
+ require_relative "patches/execution_wrapper"
52
+ require_relative "patches/rack"
53
+ require_relative "patches/action_view"
54
+ require_relative "patches/action_controller"
55
+ require_relative "patches/action_dispatch"
56
+ require_relative "patches/polymorphic_routes"
57
+ require_relative "patches/active_support"
58
+ require_relative "patches/warden"
59
+ require_relative "patches/active_model_attribute"
60
+ require_relative "patches/active_record_model_schema"
61
+ require_relative "patches/activerecord"
62
+ require_relative "patches/kaminari"
63
+ require_relative "patches/propshaft"
64
+ require_relative "patches/devise"
65
+ require_relative "patches/orm_adapter"
66
+ require_relative "patches/openssl"
67
+ require_relative "patches/rubygems"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RactorRailsShim
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Version detection and gating for ractor-rails-shim.
4
+ #
5
+ # The shim's patches target specific Rails class layouts (class_attribute,
6
+ # Callbacks, PathRegistry, LookupContext, …) and Ruby Ractor semantics that
7
+ # change between releases. Applying an 8.1-targeted patch to a 7.1 app would
8
+ # silently miss blockers or redefine the wrong methods. This module makes the
9
+ # version checks robust (Gem::Version, not string prefix comparison) and gives
10
+ # each patch a tested-versions tag so the dispatcher can apply only patches
11
+ # that match the runtime.
12
+ #
13
+ # Three concerns live here:
14
+ #
15
+ # 1. Reading the runtime versions (Ruby + Rails) defensively — Rails may not
16
+ # be loaded yet at install time.
17
+ # 2. A policy switch for mismatches: :warn (default, backward compatible),
18
+ # :strict (raise), or :off (silent). Set via
19
+ # `RactorRailsShim.version_policy = :strict`.
20
+ # 3. A registry (`PATCH_VERSIONS`) mapping each install_* patch name to the
21
+ # Rails versions it was developed and tested against, plus reporters
22
+ # (`applicable_patches` / `skipped_patches`) so users (and CI) can see
23
+ # exactly what applied to their runtime.
24
+ module RactorRailsShim
25
+ module Version
26
+ # Ruby version the shim was developed against (major.minor). Ractor
27
+ # semantics are still settling; non-4.0 Rubies may behave differently.
28
+ SUPPORTED_RUBY = "4.0"
29
+ # Rails versions each patch was tested against. Patches are registered
30
+ # with one or more entries from this list; the dispatcher applies a patch
31
+ # only if the runtime Rails version matches one of its tags. To add 7.x
32
+ # support, write the version-specific patch variants and add the tag here.
33
+ TESTED_RAILS = ["8.1"].freeze
34
+
35
+ class << self
36
+ # Runtime Ruby version as a Gem::Version (e.g. "4.0.5"). Always
37
+ # available — Ruby is obviously loaded.
38
+ def ruby
39
+ Gem::Version.new(RUBY_VERSION)
40
+ end
41
+
42
+ # Runtime Rails version as a Gem::Version, or nil if Rails isn't
43
+ # loaded yet (the normal config/boot.rb case — install is called before
44
+ # `require "rails"`).
45
+ def rails
46
+ return nil unless defined?(::Rails) && defined?(::Rails::VERSION)
47
+ return nil if ::Rails::VERSION::STRING.nil? || ::Rails::VERSION::STRING.empty?
48
+ begin
49
+ Gem::Version.new(::Rails::VERSION::STRING)
50
+ rescue ArgumentError
51
+ nil
52
+ end
53
+ end
54
+
55
+ # Major.minor segment as a String ("8.1"), or nil if Rails isn't loaded.
56
+ def rails_segment
57
+ return nil unless (rv = rails)
58
+ "#{rv.segments[0]}.#{rv.segments[1]}"
59
+ end
60
+
61
+ # Major.minor segment of the running Ruby.
62
+ def ruby_segment
63
+ v = ruby
64
+ "#{v.segments[0]}.#{v.segments[1]}"
65
+ end
66
+
67
+ # True if the runtime Ruby's major.minor matches the supported segment.
68
+ def supported_ruby?
69
+ ruby_segment == SUPPORTED_RUBY
70
+ end
71
+
72
+ # True if the runtime Rails major.minor is in the tested set (or Rails
73
+ # isn't loaded yet — can't decide, so optimistic).
74
+ def supported_rails?
75
+ return true unless rails # Rails not loaded: defer decision.
76
+ TESTED_RAILS.include?(rails_segment)
77
+ end
78
+
79
+ # Compare a runtime segment ("8.1") against a Gem::Requirement string
80
+ # (e.g. ">= 7.0", "~> 8.1"). Returns true if the segment satisfies the
81
+ # requirement. Used by the patch registry to decide applicability.
82
+ def satisfies?(segment, requirement)
83
+ return false if segment.nil?
84
+ Gem::Requirement.new(requirement).satisfied_by?(Gem::Version.new(segment))
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ractor_rails_shim/version"
4
+ require_relative "ractor_rails_shim/patches"
5
+ require_relative "ractor_rails_shim/check"
6
+
7
+ # A monkey-patch shim that reroutes Rails' class-level instance variable
8
+ # accessors (Rails.application, Rails.cache, Rails.logger, mattr_accessor,
9
+ # etc.) through ActiveSupport::IsolatedExecutionState, which is
10
+ # Ractor-safe (thread-local; each Ractor has its own threads).
11
+ #
12
+ # This lets a Rails app run in Ractor mode without forking Rails itself.
13
+ # Each Ractor gets its own copy of the globals (same shape as forking one
14
+ # process per core, but without heap duplication). For shareable state
15
+ # (frozen config, route tables) use Ractor.make_shareable + a constant
16
+ # instead — that shares one copy by reference, zero copy.
17
+ #
18
+ # Stopgap: the goal is for Rails to do this upstream. When it does, this
19
+ # shim becomes a no-op and can be removed from your Gemfile.
20
+ module RactorRailsShim
21
+ class << self
22
+ # Require this gem and the patches auto-install IF Rails is loaded.
23
+ # If Rails isn't loaded yet, install is deferred to the first call
24
+ # of `install` (call it from config/boot.rb before Rails.application).
25
+ def autoload_install!
26
+ install if defined?(::Rails) && !installed?
27
+ end
28
+ end
29
+ end
30
+
31
+ # Auto-install if Rails is already loaded (e.g. via Bundler.require in a
32
+ # console). If not, the user calls RactorRailsShim.install from boot.
33
+ RactorRailsShim.autoload_install!
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ractor-rails-shim
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - dev
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: |
27
+ Rails stores global state (Rails.application, Rails.cache, Rails.logger,
28
+ config set via mattr_accessor) in class-level instance variables, which
29
+ are illegal to read or write from non-main Ractors. This shim reroutes
30
+ those accessors through ActiveSupport::IsolatedExecutionState (which is
31
+ already Ractor-safe, being thread-local with per-ractor threads) or
32
+ Ractor.store_if_absent, so a Rails app can run in Ractor mode.
33
+
34
+ This is a stopgap: the goal is for Rails to do this upstream, at which
35
+ point the shim becomes a no-op and can be removed.
36
+ email:
37
+ - kachur.daniil@gmail.com
38
+ executables:
39
+ - ractor-rails-check
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE.txt
45
+ - README.md
46
+ - exe/ractor-rails-check
47
+ - lib/ractor_rails_shim.rb
48
+ - lib/ractor_rails_shim/check.rb
49
+ - lib/ractor_rails_shim/fallback_ies.rb
50
+ - lib/ractor_rails_shim/patches.rb
51
+ - lib/ractor_rails_shim/patches/action_controller.rb
52
+ - lib/ractor_rails_shim/patches/action_dispatch.rb
53
+ - lib/ractor_rails_shim/patches/action_view.rb
54
+ - lib/ractor_rails_shim/patches/active_model_attribute.rb
55
+ - lib/ractor_rails_shim/patches/active_record_model_schema.rb
56
+ - lib/ractor_rails_shim/patches/active_support.rb
57
+ - lib/ractor_rails_shim/patches/activerecord.rb
58
+ - lib/ractor_rails_shim/patches/class_attribute.rb
59
+ - lib/ractor_rails_shim/patches/core.rb
60
+ - lib/ractor_rails_shim/patches/devise.rb
61
+ - lib/ractor_rails_shim/patches/execution_wrapper.rb
62
+ - lib/ractor_rails_shim/patches/kaminari.rb
63
+ - lib/ractor_rails_shim/patches/make_shareable.rb
64
+ - lib/ractor_rails_shim/patches/mattr_accessor.rb
65
+ - lib/ractor_rails_shim/patches/openssl.rb
66
+ - lib/ractor_rails_shim/patches/orm_adapter.rb
67
+ - lib/ractor_rails_shim/patches/polymorphic_routes.rb
68
+ - lib/ractor_rails_shim/patches/propshaft.rb
69
+ - lib/ractor_rails_shim/patches/rack.rb
70
+ - lib/ractor_rails_shim/patches/rails_module.rb
71
+ - lib/ractor_rails_shim/patches/route_helpers.rb
72
+ - lib/ractor_rails_shim/patches/rubygems.rb
73
+ - lib/ractor_rails_shim/patches/url_helpers.rb
74
+ - lib/ractor_rails_shim/patches/warden.rb
75
+ - lib/ractor_rails_shim/patches/zeitwerk_registry.rb
76
+ - lib/ractor_rails_shim/version.rb
77
+ - lib/ractor_rails_shim/version_check.rb
78
+ homepage: https://github.com/DDKatch/ractor-rails-shim
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ homepage_uri: https://github.com/DDKatch/ractor-rails-shim
83
+ source_code_uri: https://github.com/DDKatch/ractor-rails-shim
84
+ changelog_uri: https://github.com/DDKatch/ractor-rails-shim/blob/main/CHANGELOG.md
85
+ bug_tracker_uri: https://github.com/DDKatch/ractor-rails-shim/issues
86
+ rubygems_mfa_required: 'true'
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '4.0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 4.0.10
102
+ specification_version: 4
103
+ summary: Monkey-patch Rails class-level globals to be Ractor-safe
104
+ test_files: []