ractor-rails-shim 0.2.4 → 0.2.6

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.
@@ -349,8 +349,16 @@ module RactorRailsShim
349
349
  # calls `#to_proc` and then requires the result to be a real Proc.
350
350
  # Return a frozen no-op lambda so the implicit conversion succeeds and
351
351
  # the (side-effect-free) call is a true no-op, matching `#call`.
352
+ #
353
+ # The lambda is a shareable constant (frozen at class load), NOT
354
+ # memoized on `@_to_proc`: NoOpProc instances are deep-frozen by
355
+ # `Ractor.make_shareable` during `make_app_shareable!`, so an
356
+ # `@_to_proc ||= ...` write would raise FrozenError on the frozen
357
+ # instance. A constant avoids the write entirely and is safe to share.
358
+ NO_OP_LAMBDA = ->(*) { nil }.freeze
359
+ Ractor.make_shareable(NO_OP_LAMBDA)
352
360
  def to_proc
353
- @_to_proc ||= ->(*) { nil }.freeze
361
+ NO_OP_LAMBDA
354
362
  end
355
363
  end
356
364
  class Callable
@@ -539,7 +547,18 @@ module RactorRailsShim
539
547
  # Doesn't dedup procs — must replace every occurrence.
540
548
  def _replace_unshareable_procs!(app)
541
549
  mw = (app.instance_variable_get(:@app) rescue nil)
542
- 3.times do
550
+ # Replace every Proc in the graph. The same Proc object can live in
551
+ # many containers (e.g. deprecation behaviors shared across
552
+ # deprecators), and replacing one occurrence doesn't replace the
553
+ # others — so we loop until a fixed point (no Procs left). A safety
554
+ # cap guards against a pathological graph where replacement keeps
555
+ # introducing new Procs (the replacements themselves are NoOpProc/
556
+ # Callable instances, not Procs, so this shouldn't happen, but the
557
+ # cap prevents an infinite loop if a future callable class leaks a
558
+ # Proc). 3 passes was the original magic number; observed real graphs
559
+ # converge in 2.
560
+ max_passes = 8
561
+ max_passes.times do
543
562
  procs = _collect_procs(app)
544
563
  break if procs.empty?
545
564
  procs.each { |proc_obj, _path, parent, ivar| _replace_one_proc(proc_obj, parent, ivar, mw) }
@@ -573,28 +592,76 @@ module RactorRailsShim
573
592
  next if seen[o.object_id]
574
593
  seen[o.object_id] = true
575
594
  next if o.is_a?(Mutex) || o.is_a?(Monitor)
576
- begin
577
- o.instance_variables.each do |iv|
578
- begin; v = o.instance_variable_get(iv); rescue; next; end
579
- stack << [v, "#{_path}.#{iv}", o, iv] if v
580
- end
581
- rescue => e
582
- # Some objects (BasicObject) don't support instance_variables
583
- end
584
- if o.is_a?(Array)
585
- o.each_with_index { |e, i| stack << [e, "#{_path}[#{i}]", o, nil] if e }
586
- elsif o.is_a?(Hash)
587
- o.each do |k, val|
588
- stack << [k, "#{_path}.key", o, nil] if k
589
- stack << [val, "#{_path}[#{k.inspect}]", o, nil] if val
595
+ _each_ivar_and_child(o, _path) do |child, child_path, child_ivar|
596
+ if child_ivar == :__default_proc__
597
+ procs << [child, child_path, o, :__default_proc__]
598
+ else
599
+ stack << [child, child_path, o, child_ivar] if child
590
600
  end
591
- dp = o.default_proc
592
- procs << [dp, "#{_path}.default_proc", o, :__default_proc__] if dp
593
601
  end
594
602
  end
595
603
  procs
596
604
  end
597
605
 
606
+ # Enumerate every child reference of `o` for the graph traversals:
607
+ # - instance variables (yields [value, "path.iv", iv])
608
+ # - Array / Set / Enumerable members (yields [member, "path[i]", nil])
609
+ # - Hash pairs (yields [key, "path.key", nil] and [val, "path[k]", nil])
610
+ # - Hash#default_proc (yields [proc, "path.default_proc", :__default_proc__])
611
+ # - Struct members (yields [value, "path.member", nil] via #each_pair)
612
+ #
613
+ # Pre-fix the traversals only handled Array and Hash, so a Mutex or Proc
614
+ # nested inside a Set (Rails uses Set in several caches, e.g.
615
+ # ActionDispatch::Journey::Routes) or a Struct was missed and left
616
+ # unshareable. Centralized here so _collect_procs and
617
+ # _replace_locks_and_concurrent_maps! share the same container coverage.
618
+ def _each_ivar_and_child(o, path)
619
+ begin
620
+ o.instance_variables.each do |iv|
621
+ begin; v = o.instance_variable_get(iv); rescue; next; end
622
+ yield v, "#{path}.#{iv}", iv
623
+ end
624
+ rescue => e
625
+ # BasicObject or frozen objects don't support instance_variables
626
+ end
627
+ if o.is_a?(Hash)
628
+ o.each do |k, val|
629
+ yield k, "#{path}.key", nil
630
+ yield val, "#{path}[#{k.inspect}]", nil
631
+ end
632
+ dp = o.default_proc
633
+ yield dp, "#{path}.default_proc", :__default_proc__ if dp
634
+ elsif o.is_a?(Array)
635
+ o.each_with_index { |e, i| yield e, "#{path}[#{i}]", nil }
636
+ elsif o.is_a?(::Set)
637
+ o.each_with_index { |e, i| yield e, "#{path}.set[#{i}]", nil }
638
+ elsif o.is_a?(::Struct)
639
+ o.each_pair { |name, val| yield val, "#{path}.#{name}", nil }
640
+ elsif _enumerable_but_not_basic?(o)
641
+ # Generic Enumerable fallback (Range, Enumerator, custom Enumerable
642
+ # mixes). Skip String/Hash/Array/Set/Struct — already handled. Best
643
+ # effort; rescue per-element in case #each raises for some members.
644
+ idx = -1
645
+ o.each do |e|
646
+ idx += 1
647
+ yield e, "#{path}.each[#{idx}]", nil
648
+ end rescue nil
649
+ end
650
+ end
651
+
652
+ # True if `o` is Enumerable but NOT one of the container types with a
653
+ # dedicated branch in _each_ivar_and_child. Used to gate the generic
654
+ # Enumerable fallback so we don't double-walk Array/Hash/etc.
655
+ def _enumerable_but_not_basic?(o)
656
+ return false unless o.is_a?(::Enumerable)
657
+ return false if o.is_a?(::Array) || o.is_a?(::Hash) || o.is_a?(::Set) || o.is_a?(::Struct)
658
+ return false if o.is_a?(::String) # String is Enumerable (chars) but not a container of refs we want
659
+ true
660
+ rescue NoMethodError
661
+ # BasicObject without Kernel — not Enumerable.
662
+ false
663
+ end
664
+
598
665
  def _replace_one_proc(proc_obj, parent, ivar, mw)
599
666
  src = proc_obj.source_location&.first || ""
600
667
  replacement =
@@ -619,8 +686,18 @@ module RactorRailsShim
619
686
  elsif src.end_with?(DEVISE_SCOPE_LOC)
620
687
  _devise_mapping_replacement(proc_obj, parent)
621
688
  elsif src.end_with?(MAPPER_LOC) && ivar == :@strategy
622
- line = proc_obj.source_location[1]
623
- line == 32 ? StrategyServe.new : StrategyCall.new
689
+ # Identify SERVE vs CALL by OBJECT IDENTITY against the actual
690
+ # ActionDispatch constants, NOT by source_location line number.
691
+ # The value stored in @strategy IS the constant object (Rails
692
+ # assigns it via `@strategy = strategy` where `strategy` is passed
693
+ # as Constraints::SERVE or Constraints::CALL), so `equal?` is the
694
+ # robust identifier — it survives any Rails patch release that
695
+ # shifts the constant definitions by a line or two, where the old
696
+ # `line == 32` check would silently swap the two strategies and
697
+ # break routing. Falls through to NoOpProc if the Proc isn't
698
+ # either constant (defensive: shouldn't happen, but never
699
+ # mis-route).
700
+ _strategy_replacement_for(proc_obj)
624
701
  else
625
702
  NoOpProc.new
626
703
  end
@@ -653,6 +730,22 @@ module RactorRailsShim
653
730
  # DeviseMappingCallable) now lives in warden.rb; `_find_files_server`
654
731
  # (Rack::Files target for the asset stack) now lives in rack.rb.
655
732
 
733
+ # Identify which ActionDispatch strategy a Proc is and return its
734
+ # shareable replacement. The Proc stored in
735
+ # `ActionDispatch::Routing::Mapper::Constraints#@strategy` is the
736
+ # constant `SERVE` or `CALL` (assigned by reference), so `equal?` is
737
+ # the robust identifier — independent of the source line number, which
738
+ # any Rails patch release can shift. Returns NoOpProc for an unknown
739
+ # Proc (defensive; never mis-route).
740
+ def _strategy_replacement_for(proc_obj)
741
+ constraints = defined?(::ActionDispatch::Routing::Mapper::Constraints) ?
742
+ ::ActionDispatch::Routing::Mapper::Constraints : nil
743
+ return NoOpProc.new unless constraints
744
+ return StrategyServe.new if proc_obj.equal?(constraints::SERVE)
745
+ return StrategyCall.new if proc_obj.equal?(constraints::CALL)
746
+ NoOpProc.new
747
+ end
748
+
656
749
  def _replace_locks_and_concurrent_maps!(app)
657
750
  seen = {}
658
751
  stack = [[app, "app", nil, nil]]
@@ -663,24 +756,20 @@ module RactorRailsShim
663
756
  next if seen[o.object_id]
664
757
  seen[o.object_id] = true
665
758
  next if o.is_a?(Mutex) || o.is_a?(Monitor)
666
- begin
667
- o.instance_variables.each do |iv|
668
- begin; v = o.instance_variable_get(iv); rescue; next; end
669
- if v.is_a?(Mutex) || v.is_a?(Monitor)
670
- o.instance_variable_set(iv, NoOpLock.new) rescue nil
671
- elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map)
672
- hash_copy = {}
673
- v.each_pair { |k, val| hash_copy[k] = val }
674
- o.instance_variable_set(iv, hash_copy) rescue nil
675
- elsif v
676
- stack << [v, "#{_p}.#{iv}", o, iv]
677
- end
759
+ _each_ivar_and_child(o, _p) do |child, child_path, child_ivar|
760
+ next if child_ivar == :__default_proc__
761
+ if child.is_a?(Mutex) || child.is_a?(Monitor)
762
+ o.instance_variable_set(child_ivar, NoOpLock.new) rescue nil if child_ivar
763
+ # If the lock is in an Array/Set/Hash (no ivar), we can't swap
764
+ # it in place here — leave it; make_shareable will handle the
765
+ # frozen container. The ivar case is the load-bearing one.
766
+ elsif defined?(::Concurrent::Map) && child.is_a?(::Concurrent::Map) && child_ivar
767
+ hash_copy = {}
768
+ child.each_pair { |k, val| hash_copy[k] = val }
769
+ o.instance_variable_set(child_ivar, hash_copy) rescue nil
770
+ elsif child
771
+ stack << [child, child_path, o, child_ivar]
678
772
  end
679
- rescue => e
680
- # BasicObject or frozen objects don't support instance_variables
681
- end
682
- if o.is_a?(Array); o.each_with_index { |e, i| stack << [e, "#{_p}[#{i}]", o, nil] if e }
683
- elsif o.is_a?(Hash); o.each { |k, val| stack << [k, "#{_p}.key", o, nil] if k; stack << [val, "#{_p}[#{k.inspect}]", o, nil] if val }
684
773
  end
685
774
  end
686
775
  end
@@ -81,7 +81,7 @@ module RactorRailsShim
81
81
  singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
82
82
  def #{sym}
83
83
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
84
- return v unless v.nil?
84
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
85
85
 
86
86
  if #{!!shareable}
87
87
  if class_variable_defined?(#{cv_str})
@@ -128,7 +128,7 @@ module RactorRailsShim
128
128
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
129
129
  def #{sym}
130
130
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
131
- return v unless v.nil?
131
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
132
132
  if Ractor.main?
133
133
  self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil
134
134
  else
@@ -21,7 +21,7 @@ module RactorRailsShim
21
21
  def to_adapter
22
22
  key = :"ractor_rails_shim_orm_adapter_\#{object_id}"
23
23
  v = ActiveSupport::IsolatedExecutionState[key]
24
- return v unless v.nil?
24
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
25
25
  adapter = self::OrmAdapter.new(self)
26
26
  ActiveSupport::IsolatedExecutionState[key] = adapter
27
27
  adapter
@@ -56,7 +56,7 @@ module RactorRailsShim
56
56
  req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
57
57
  def forwarded_priority
58
58
  v = ActiveSupport::IsolatedExecutionState[#{fp_key_str}]
59
- return v unless v.nil?
59
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{fp_key_str})
60
60
  if Ractor.main? && instance_variable_defined?(:@forwarded_priority)
61
61
  @forwarded_priority
62
62
  else
@@ -70,7 +70,7 @@ module RactorRailsShim
70
70
  end
71
71
  def x_forwarded_proto_priority
72
72
  v = ActiveSupport::IsolatedExecutionState[#{xp_key_str}]
73
- return v unless v.nil?
73
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{xp_key_str})
74
74
  if Ractor.main? && instance_variable_defined?(:@x_forwarded_proto_priority)
75
75
  @x_forwarded_proto_priority
76
76
  else
@@ -105,7 +105,7 @@ module RactorRailsShim
105
105
  u.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
106
106
  def default_query_parser
107
107
  v = ActiveSupport::IsolatedExecutionState[#{dqp_key_str}]
108
- return v unless v.nil?
108
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{dqp_key_str})
109
109
  if Ractor.main? && instance_variable_defined?(:@default_query_parser)
110
110
  v = @default_query_parser
111
111
  ActiveSupport::IsolatedExecutionState[#{dqp_key_str}] = v
@@ -116,7 +116,7 @@ module RactorRailsShim
116
116
  end
117
117
  def multipart_total_part_limit
118
118
  v = ActiveSupport::IsolatedExecutionState[#{mtp_key_str}]
119
- return v unless v.nil?
119
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{mtp_key_str})
120
120
  if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit)
121
121
  v = @multipart_total_part_limit
122
122
  ActiveSupport::IsolatedExecutionState[#{mtp_key_str}] = v
@@ -127,7 +127,7 @@ module RactorRailsShim
127
127
  end
128
128
  def multipart_file_limit
129
129
  v = ActiveSupport::IsolatedExecutionState[#{mfl_key_str}]
130
- return v unless v.nil?
130
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{mfl_key_str})
131
131
  if Ractor.main? && instance_variable_defined?(:@multipart_file_limit)
132
132
  v = @multipart_file_limit
133
133
  ActiveSupport::IsolatedExecutionState[#{mfl_key_str}] = v
@@ -36,6 +36,13 @@ module RactorRailsShim
36
36
  # :class fires when `rails.rb` opens `module Rails` (module bodies fire
37
37
  # as :class); once the constant is assigned, we patch once and disable
38
38
  # the hook.
39
+ #
40
+ # Two flags are intentional and guard *different* things:
41
+ # @rails_load_hook_installed — the TracePoint one-shot is armed (so we
42
+ # don't stack multiple TracePoints on repeated `install` calls).
43
+ # @rails_module_patched — the patch itself has been applied (checked
44
+ # again inside the TracePoint block and in `patch_rails_module!`
45
+ # because the immediate-path caller also goes through that method).
39
46
  def install_rails_load_hook
40
47
  return if @rails_load_hook_installed
41
48
  @rails_load_hook_installed = true
@@ -89,7 +96,7 @@ module RactorRailsShim
89
96
  # slot is empty until they boot their own app).
90
97
  def application
91
98
  v = ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}]
92
- return v unless v.nil?
99
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:application].inspect})
93
100
  if Ractor.main?
94
101
  super
95
102
  else
@@ -108,7 +115,7 @@ module RactorRailsShim
108
115
  # value at make_app_shareable! time) when their own IES slot is empty.
109
116
  def app_class
110
117
  v = ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}]
111
- return v unless v.nil?
118
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:app_class].inspect})
112
119
  return super if Ractor.main?
113
120
  RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}]
114
121
  end
@@ -120,7 +127,7 @@ module RactorRailsShim
120
127
 
121
128
  def cache
122
129
  v = ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}]
123
- return v unless v.nil?
130
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:cache].inspect})
124
131
  return super if Ractor.main?
125
132
  RactorRailsShim::SHAREABLE_FALLBACK[#{k[:cache].inspect}]
126
133
  end
@@ -132,7 +139,7 @@ module RactorRailsShim
132
139
 
133
140
  def logger
134
141
  v = ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}]
135
- return v unless v.nil?
142
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:logger].inspect})
136
143
  return super if Ractor.main?
137
144
  # Loggers are intrinsically mutable (formatters hold tag stacks,
138
145
  # logdevs hold IO + Mutex) and can't be shared read-only. Build a
@@ -152,7 +159,7 @@ module RactorRailsShim
152
159
 
153
160
  def backtrace_cleaner
154
161
  v = ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}]
155
- return v unless v.nil?
162
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:backtrace_cleaner].inspect})
156
163
  return super if Ractor.main?
157
164
  RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}]
158
165
  end
@@ -167,7 +174,7 @@ module RactorRailsShim
167
174
  # lazily builds and caches in @_env.
168
175
  def env
169
176
  v = ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}]
170
- return v unless v.nil?
177
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:env].inspect})
171
178
  if Ractor.main?
172
179
  super
173
180
  else
@@ -23,6 +23,13 @@ module RactorRailsShim
23
23
  else
24
24
  # Defer until Zeitwerk loads. A TracePoint(:class) fires when
25
25
  # `module Registry` opens. One-shot.
26
+ #
27
+ # Two flags guard different things (mirrors `rails_module.rb`):
28
+ # @zeitwerk_patched — `install_zeitwerk_registry` ran
29
+ # (so we don't arm a second TracePoint).
30
+ # @zeitwerk_registry_patched — the actual patch was applied (checked
31
+ # again inside the TracePoint block and
32
+ # in `patch_zeitwerk_registry!`).
26
33
  @zw_tp = TracePoint.new(:class) do |trace|
27
34
  if defined?(::Zeitwerk::Registry) && !@zeitwerk_registry_patched
28
35
  @zw_tp.disable
@@ -69,7 +76,7 @@ module RactorRailsShim
69
76
  reader_patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1
70
77
  def #{ivar}
71
78
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
72
- return v unless v.nil?
79
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
73
80
  if Ractor.main?
74
81
  existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str})
75
82
  if existing
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RactorRailsShim
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.6"
5
5
  end
@@ -23,9 +23,11 @@
23
23
  # exactly what applied to their runtime.
24
24
  module RactorRailsShim
25
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"
26
+ # Ruby version the shim was developed against (major.minor.patch). Ractor
27
+ # semantics are still settling; the frozen-iseq call-cache fix (#22075) and
28
+ # the cross-ractor env-string fix that the shim relies on both shipped in
29
+ # 4.0.6, so earlier 4.0.x patch releases are NOT supported.
30
+ SUPPORTED_RUBY = "4.0.6"
29
31
  # Rails versions each patch was tested against. Patches are registered
30
32
  # with one or more entries from this list; the dispatcher applies a patch
31
33
  # only if the runtime Rails version matches one of its tags. To add 7.x
@@ -33,7 +35,7 @@ module RactorRailsShim
33
35
  TESTED_RAILS = ["8.1"].freeze
34
36
 
35
37
  class << self
36
- # Runtime Ruby version as a Gem::Version (e.g. "4.0.5"). Always
38
+ # Runtime Ruby version as a Gem::Version (e.g. "4.0.6"). Always
37
39
  # available — Ruby is obviously loaded.
38
40
  def ruby
39
41
  Gem::Version.new(RUBY_VERSION)
@@ -64,9 +66,11 @@ module RactorRailsShim
64
66
  "#{v.segments[0]}.#{v.segments[1]}"
65
67
  end
66
68
 
67
- # True if the runtime Ruby's major.minor matches the supported segment.
69
+ # True if the runtime Ruby is >= the supported version (4.0.6). Uses a
70
+ # full Gem::Version compare (not a segment match) so the minimum patch
71
+ # release is enforced — the shim relies on fixes that shipped in 4.0.6.
68
72
  def supported_ruby?
69
- ruby_segment == SUPPORTED_RUBY
73
+ ruby >= Gem::Version.new(SUPPORTED_RUBY)
70
74
  end
71
75
 
72
76
  # True if the runtime Rails major.minor is in the tested set (or Rails
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractor-rails-shim
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
- - dev
7
+ - Daniil Kachur
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -99,7 +99,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
- version: '4.0'
102
+ version: 4.0.6
103
103
  required_rubygems_version: !ruby/object:Gem::Requirement
104
104
  requirements:
105
105
  - - ">="