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.
@@ -590,6 +590,18 @@ module RactorRailsShim
590
590
  # which writes `@table_name`/`@arel_table`/etc. From a worker that write is
591
591
  # Ractor::IsolationError. Route the value through IsolatedExecutionState
592
592
  # (keyed by model object_id); main keeps the original class-ivar behavior.
593
+ #
594
+ # NOTE on naming: there are TWO ModelSchema-related install methods, with
595
+ # deliberately different scopes:
596
+ # * `_install_active_record_model_schema_patch` (this method) — patches
597
+ # `table_name` / `table_name=` / `reset_table_name` only.
598
+ # * `_install_activerecord_model_schema_patch` (below) — patches the
599
+ # *column-derived* lazy caches `symbol_column_to_string`,
600
+ # `content_columns` (and previously `column_defaults`, now provided
601
+ # by the prepended `ActiveRecordModelSchemaPatch`).
602
+ # Both target `ActiveRecord::ModelSchema::ClassMethods` but patch
603
+ # disjoint method sets; do not collapse them without auditing every
604
+ # caller.
593
605
  def _install_active_record_model_schema_patch
594
606
  return if @ar_model_schema_patched
595
607
  @ar_model_schema_patched = true
@@ -776,15 +788,79 @@ module RactorRailsShim
776
788
  cols
777
789
  end
778
790
 
779
- def column_defaults
780
- key = :"ractor_rails_shim_column_defaults_\#{self.name}"
781
- v = ActiveSupport::IsolatedExecutionState[key]
782
- return v if v
783
- defaults = _default_attributes.deep_dup.to_hash.freeze
784
- ActiveSupport::IsolatedExecutionState[key] = defaults
785
- defaults
791
+ # NOTE: `column_defaults` is intentionally NOT redefined here. It is
792
+ # installed by the prepended `ActiveRecordModelSchemaPatch` (see
793
+ # `active_record_model_schema.rb`), which is prepended onto
794
+ # `ModelSchema::ClassMethods` by `_install_active_model_attribute_patch`
795
+ # and therefore sits in front of this module_eval heredoc in the
796
+ # method lookup chain. A second definition here would be dead code and
797
+ # a maintenance trap.
798
+
799
+ # `reload_schema_from_cache` (called by `load_schema`/`reload_schema!`)
800
+ # writes a batch of class ivars to `nil` to invalidate schema caches:
801
+ # @_returning_columns_for_insert, @arel_table, @column_names,
802
+ # @symbol_column_to_string_name_hash, @content_columns, @column_defaults
803
+ # (ModelSchema) plus @timestamp_attributes_for_create_in_model,
804
+ # @timestamp_attributes_for_update_in_model,
805
+ # @all_timestamp_attributes_in_model (Timestamp#reload_schema_from_cache
806
+ # via super). In a worker Ractor those writes raise
807
+ # Ractor::IsolationError ("can not set instance variables of
808
+ # classes/modules by non-main Ractors"). The shim already routes the
809
+ # *readers* for these caches through IES, so in a worker we only need
810
+ # to clear the IES slots — the next read rebuilds lazily. In main we
811
+ # keep the original class-ivar-clearing behavior.
812
+ def reload_schema_from_cache(recursive = true)
813
+ if Ractor.main?
814
+ super
815
+ else
816
+ # Clear this Ractor's IES slots for the IES-routed schema caches.
817
+ # Use `next` over an explicit list (not `IES.clear`) to avoid
818
+ # wiping unrelated slots. Keys mirror the readers above + the
819
+ # prepended ActiveRecordModelSchemaPatch (active_record_model_schema.rb)
820
+ # + ActiveModelAttributeRegistrationPatch (active_model_attribute.rb).
821
+ name_str = self.name
822
+ [
823
+ :"ractor_rails_shim_symbol_column_to_string_\#{name_str}",
824
+ :"ractor_rails_shim_content_columns_\#{name_str}",
825
+ :"rrs_column_defaults_\#{object_id}",
826
+ :"rrs_attributes_builder_\#{object_id}",
827
+ :"rrs_yaml_encoder_\#{object_id}",
828
+ :"rrs_returning_cols_\#{object_id}",
829
+ :"rrs_default_attributes_\#{object_id}",
830
+ :"rrs_attribute_types_\#{object_id}",
831
+ :"rrs_table_names",
832
+ :"rrs_arel_tables",
833
+ :"rrs_predicate_builders",
834
+ :"rrs_type_casters",
835
+ ].each do |k| ActiveSupport::IsolatedExecutionState.delete(k) end
836
+ # The Timestamp subclass override calls `super` (this method); it
837
+ # has already run by the time we get here via the super chain, so
838
+ # its ivar-clears were intercepted by the worker branch of THIS
839
+ # override (no-op). No further action needed.
840
+ end
786
841
  end
787
842
  RUBY
843
+
844
+ # Patch ActiveRecord::Timestamp::ClassMethods#reload_schema_from_cache
845
+ # the same way: in main, keep the original ivar-clearing; in a worker,
846
+ # skip the class-ivar writes (the IES slots are cleared by the
847
+ # ModelSchema#reload_schema_from_cache override above via super).
848
+ if defined?(::ActiveRecord::Timestamp::ClassMethods)
849
+ ::ActiveRecord::Timestamp::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
850
+ def reload_schema_from_cache(recursive = true)
851
+ if Ractor.main?
852
+ @timestamp_attributes_for_create_in_model = nil
853
+ @timestamp_attributes_for_update_in_model = nil
854
+ @all_timestamp_attributes_in_model = nil
855
+ super
856
+ else
857
+ # Worker: skip the class-ivar writes (would raise
858
+ # IsolationError). The ModelSchema super clears the IES slots.
859
+ super
860
+ end
861
+ end
862
+ RUBY
863
+ end
788
864
  end
789
865
 
790
866
  # Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its
@@ -863,7 +939,7 @@ module RactorRailsShim
863
939
  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
864
940
  def configurations
865
941
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
866
- return v unless v.nil?
942
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
867
943
  if Ractor.main?
868
944
  ActiveRecord::Core.class_variable_get(:@@configurations)
869
945
  else
@@ -919,7 +995,7 @@ module RactorRailsShim
919
995
  ::ActiveRecord::DatabaseConfigurations.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
920
996
  def db_config_handlers
921
997
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
922
- return v unless v.nil?
998
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
923
999
  if Ractor.main?
924
1000
  @db_config_handlers
925
1001
  else
@@ -973,7 +1049,7 @@ module RactorRailsShim
973
1049
  ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
974
1050
  def query_transformers
975
1051
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
976
- return v unless v.nil?
1052
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
977
1053
  if Ractor.main?
978
1054
  @query_transformers
979
1055
  else
@@ -1026,7 +1102,7 @@ module RactorRailsShim
1026
1102
  ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1027
1103
  def #{method_name}
1028
1104
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
1029
- return v unless v.nil?
1105
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
1030
1106
  if Ractor.main?
1031
1107
  @#{method_name}
1032
1108
  else
@@ -1058,7 +1134,7 @@ module RactorRailsShim
1058
1134
  def registry
1059
1135
  key = :"ractor_rails_shim_dedup_registry_\#{name || object_id}"
1060
1136
  v = ActiveSupport::IsolatedExecutionState[key]
1061
- return v unless v.nil?
1137
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1062
1138
  h = {}
1063
1139
  ActiveSupport::IsolatedExecutionState[key] = h
1064
1140
  h
@@ -1081,7 +1157,7 @@ module RactorRailsShim
1081
1157
  def query_constraints_list
1082
1158
  key = :"ractor_rails_shim_qcl_\#{name || object_id}"
1083
1159
  v = ActiveSupport::IsolatedExecutionState[key]
1084
- return v unless v.nil?
1160
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1085
1161
  result = if base_class? || primary_key != base_class.primary_key
1086
1162
  primary_key if primary_key.is_a?(::Array)
1087
1163
  else
@@ -1093,7 +1169,7 @@ module RactorRailsShim
1093
1169
  def has_query_constraints?
1094
1170
  key = :"ractor_rails_shim_qcl_\#{name || object_id}"
1095
1171
  v = ActiveSupport::IsolatedExecutionState[key]
1096
- return !v.nil? unless v.nil?
1172
+ return !v.nil? if ActiveSupport::IsolatedExecutionState.key?(key)
1097
1173
  result = query_constraints_list
1098
1174
  !result.nil?
1099
1175
  end
@@ -1336,7 +1412,7 @@ module RactorRailsShim
1336
1412
  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1337
1413
  def default_connection_handler
1338
1414
  v = ActiveSupport::IsolatedExecutionState[#{dch_key_str}]
1339
- return v unless v.nil?
1415
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{dch_key_str})
1340
1416
  if Ractor.main?
1341
1417
  cv = RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__]
1342
1418
  return cv if cv
@@ -1351,6 +1427,12 @@ module RactorRailsShim
1351
1427
  # worker's other threads; Ractor.current is per-Ractor and shared by all
1352
1428
  # threads of the worker. Falls back to IES (legacy) then
1353
1429
  # default_connection_handler (main Ractor only).
1430
+ # NOTE: uses nil-sentinel (`unless v.nil?`) rather than key?-based
1431
+ # detection because Ractor#[] has no `key?` method (Ractor local
1432
+ # storage returns nil for both unset and set-to-nil). Safe here
1433
+ # because `connection_handler=` is only ever called with a real
1434
+ # ConnectionHandler instance, never nil — so nil unambiguously
1435
+ # means "unset, fall through to default_connection_handler".
1354
1436
  def connection_handler
1355
1437
  v = Ractor.current[:active_record_connection_handler]
1356
1438
  return v unless v.nil?
@@ -1428,7 +1510,7 @@ module RactorRailsShim
1428
1510
  def serialize_cast_value_compatible?
1429
1511
  key = :"ractor_rails_shim_scv_\#{name || object_id}"
1430
1512
  v = ActiveSupport::IsolatedExecutionState[key]
1431
- return v unless v.nil?
1513
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1432
1514
  result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner)
1433
1515
  ActiveSupport::IsolatedExecutionState[key] = result
1434
1516
  result
@@ -1461,7 +1543,7 @@ module RactorRailsShim
1461
1543
  def uncacheable_methods
1462
1544
  key = :ractor_rails_shim_ar_uncacheable_methods
1463
1545
  v = ActiveSupport::IsolatedExecutionState[key]
1464
- return v unless v.nil?
1546
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1465
1547
  result = (
1466
1548
  delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods
1467
1549
  ).to_set.freeze
@@ -1497,7 +1579,7 @@ module RactorRailsShim
1497
1579
  def primary_key
1498
1580
  key = :"ractor_rails_shim_pk_\#{name || object_id}"
1499
1581
  v = ActiveSupport::IsolatedExecutionState[key]
1500
- return v unless v.nil?
1582
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1501
1583
  if Ractor.main?
1502
1584
  reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
1503
1585
  v = @primary_key
@@ -1510,7 +1592,7 @@ module RactorRailsShim
1510
1592
  def composite_primary_key?
1511
1593
  key = :"ractor_rails_shim_pk_\#{name || object_id}"
1512
1594
  v = ActiveSupport::IsolatedExecutionState[key]
1513
- return v.is_a?(::Array) unless v.nil?
1595
+ return v.is_a?(::Array) if ActiveSupport::IsolatedExecutionState.key?(key)
1514
1596
  if Ractor.main?
1515
1597
  reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
1516
1598
  @primary_key.is_a?(::Array)
@@ -1700,7 +1782,7 @@ module RactorRailsShim
1700
1782
  mig.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1701
1783
  def migrations_paths
1702
1784
  v = ActiveSupport::IsolatedExecutionState[#{mp_key_str}]
1703
- return v unless v.nil?
1785
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{mp_key_str})
1704
1786
  if Ractor.main? && instance_variable_defined?(:@migrations_paths)
1705
1787
  v = @migrations_paths
1706
1788
  ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = v
@@ -102,11 +102,24 @@ module RactorRailsShim
102
102
  # for copy-on-write fallback. This restores per-subclass isolation
103
103
  # (lost by the IES-routed variant) without thread-local IES, which
104
104
  # is empty on Puma's request threads.
105
+ #
106
+ # Key format MUST stay `:"ractor_rails_shim_class_attr_<oid>_<name>"`
107
+ # to match the writer below and the Ractor-mode key in
108
+ # `redefine`'s own `key` local. The original walked ancestors and
109
+ # interpolated the *whole* Symbol per ancestor on every read —
110
+ # allocating a fresh Array (ancestors) AND a fresh Symbol per
111
+ # ancestor, the dominant allocation source for GET requests. We
112
+ # interpolate only the per-ancestor `object_id` tail inside the
113
+ # lookup, which Ruby optimizes to a single Symbol allocation per
114
+ # ancestor (vs. the previous full-string interpolation). The
115
+ # Ractor-mode branch below avoids the walk entirely (literal key),
116
+ # but thread mode must walk because subclass COW fallback is
117
+ # load-bearing here.
105
118
  target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
106
119
  def #{namespaced_name}
107
120
  self.ancestors.each do |anc|
108
121
  v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"]
109
- return v unless v.nil?
122
+ return v if RactorRailsShim::CLASS_ATTR_VALUES.key?(:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}")
110
123
  end
111
124
  #{namespaced_name.inspect} == :__callbacks ? {} : nil
112
125
  end
@@ -124,7 +137,7 @@ module RactorRailsShim
124
137
  def #{name}
125
138
  self.ancestors.each do |anc|
126
139
  v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"]
127
- return v unless v.nil?
140
+ return v if RactorRailsShim::CLASS_ATTR_VALUES.key?(:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}")
128
141
  end
129
142
  #{namespaced_name.inspect} == :__callbacks ? {} : nil
130
143
  end
@@ -148,7 +161,7 @@ module RactorRailsShim
148
161
  target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
149
162
  def #{namespaced_name}
150
163
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
151
- return v unless v.nil?
164
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
152
165
  fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
153
166
  return fb unless fb.nil?
154
167
  RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
@@ -168,7 +181,7 @@ module RactorRailsShim
168
181
  owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1
169
182
  def #{name}
170
183
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
171
- return v unless v.nil?
184
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
172
185
  fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
173
186
  return fb unless fb.nil?
174
187
  RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
@@ -120,6 +120,29 @@ module RactorRailsShim
120
120
  end
121
121
  attr_writer :version_policy
122
122
 
123
+ # When true, swallowed exceptions in freeze/shareability paths are
124
+ # reported to $stderr so a worker Ractor that later crashes on an
125
+ # unshareable value has a traceable cause. Default false (silent, the
126
+ # historical behaviour). Enable for diagnosis:
127
+ # RactorRailsShim.debug = true
128
+ def debug?
129
+ defined?(@debug) ? @debug : false
130
+ end
131
+ attr_writer :debug
132
+
133
+ # Swallow an exception raised by the block, optionally logging it when
134
+ # `debug?` is on. Used by freeze/shareability paths where individual
135
+ # failures are expected (some ivars hold intrinsically unshareable
136
+ # values like Procs) but a worker crash on the same value later has no
137
+ # visible cause. `label` identifies the call site (e.g. "freeze AR ivar
138
+ # Post@column_defaults"). Keep the label short — it's only for grepping.
139
+ def _swallow(label)
140
+ yield
141
+ rescue => e
142
+ warn "[ractor_rails_shim] #{label}: #{e.class}: #{e.message[0, 120]}" if debug?
143
+ nil
144
+ end
145
+
123
146
  SUPPORTED_RUBY = RactorRailsShim::Version::SUPPORTED_RUBY
124
147
  SUPPORTED_RAILS = "8.1"
125
148
 
@@ -283,11 +306,7 @@ module RactorRailsShim
283
306
  # with `equal?`, and a frozen Symbol is always shareable.
284
307
  Ractor.make_shareable(:"__shim_unshareable_sentinel__")
285
308
  else
286
- begin
287
- Ractor.make_shareable(val)
288
- rescue => e
289
- nil
290
- end
309
+ _swallow("make_value_shareable #{val.class}") { Ractor.make_shareable(val) }
291
310
  end
292
311
  end
293
312
 
@@ -301,21 +320,6 @@ module RactorRailsShim
301
320
  [parent, parts.last.to_sym]
302
321
  end
303
322
 
304
- # Public API: run after Rails.application.initialize! and BEFORE spawning
305
- # worker Ractors. Makes every registered constant shareable (deep-freeze).
306
- # Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
307
- # after `module Rails` opens) get fixed here. Idempotent; safe to call
308
- # multiple times. Must run in the main Ractor.
309
- #
310
- # NOTE: this does NOT build the framework-config shareable fallback. That
311
- # step is folded into `make_app_shareable!` because some class_attribute /
312
- # mattr_accessor values reference the app graph, and making them shareable
313
- # must happen AFTER the app itself is already frozen (otherwise the app
314
- # gets frozen prematurely and precompute/proc-replacement can't mutate
315
- # it). If you call prepare_for_ractors! standalone (without
316
- # make_app_shareable!), worker Ractors will see nil for framework config
317
- # values that couldn't be shared without freezing the app — set them
318
- # explicitly per worker, or use make_app_shareable!.
319
323
  # Shared list of every framework patch install method. Both
320
324
  # `prepare_for_ractors!` (pre-worker boot) and `make_app_shareable!`
321
325
  # (post-boot, pre-freeze) call this, so the two no longer duplicate the
@@ -403,6 +407,21 @@ module RactorRailsShim
403
407
  _install_hash_compute_if_absent_patch
404
408
  end
405
409
 
410
+ # Public API: run after Rails.application.initialize! and BEFORE spawning
411
+ # worker Ractors. Makes every registered constant shareable (deep-freeze).
412
+ # Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
413
+ # after `module Rails` opens) get fixed here. Idempotent; safe to call
414
+ # multiple times. Must run in the main Ractor.
415
+ #
416
+ # NOTE: this does NOT build the framework-config shareable fallback. That
417
+ # step is folded into `make_app_shareable!` because some class_attribute /
418
+ # mattr_accessor values reference the app graph, and making them shareable
419
+ # must happen AFTER the app itself is already frozen (otherwise the app
420
+ # gets frozen prematurely and precompute/proc-replacement can't mutate
421
+ # it). If you call prepare_for_ractors! standalone (without
422
+ # make_app_shareable!), worker Ractors will see nil for framework config
423
+ # values that couldn't be shared without freezing the app — set them
424
+ # explicitly per worker, or use make_app_shareable!.
406
425
  def prepare_for_ractors!
407
426
  do_install_shareable_constants
408
427
  RactorRailsShim._freeze_shareable_class_ivars! if RactorRailsShim.respond_to?(:_freeze_shareable_class_ivars!)
@@ -416,9 +435,9 @@ module RactorRailsShim
416
435
  _freeze_global_class_ivars!
417
436
  _freeze_global_constants!
418
437
  _freeze_messages_constants!
419
- end
438
+ end
420
439
 
421
- # Verify the runtime matches the versions the shim was developed against.
440
+ # Verify the runtime matches the versions the shim was developed against.
422
441
  # The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0
423
442
  # Ractor semantics. On other versions, the patches may silently miss or
424
443
  # break things. Behavior on mismatch is governed by `version_policy`:
@@ -434,9 +453,10 @@ module RactorRailsShim
434
453
  def _check_version_support
435
454
  @version_policy ||= :warn
436
455
  unless RactorRailsShim::Version.supported_ruby?
437
- msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim developed " \
438
- "against Ruby #{SUPPORTED_RUBY}. Ractor semantics may differ; " \
439
- "the shim may break. Proceeding anyway."
456
+ msg = "ractor-rails-shim: Ruby #{RUBY_VERSION} — shim requires " \
457
+ "Ruby >= #{SUPPORTED_RUBY} (frozen-iseq call-cache fix #22075 " \
458
+ "and cross-ractor env-string fix both shipped in 4.0.6). " \
459
+ "Proceeding anyway."
440
460
  _version_mismatch(msg)
441
461
  end
442
462
  if RactorRailsShim::Version.rails &&
@@ -508,9 +528,27 @@ module RactorRailsShim
508
528
  # `Zeitwerk::Loader.new` raises IsolationError off the main Ractor).
509
529
  def capture_app_constants
510
530
  map = {}
511
- return map unless defined?(::Rails) && Rails.respond_to?(:autoloaders)
512
- [Rails.autoloaders.main, Rails.autoloaders.once].each do |loader|
513
- next unless loader.respond_to?(:all_expected_cpaths)
531
+ unless defined?(::Rails) && Rails.respond_to?(:autoloaders)
532
+ return map.freeze
533
+ end
534
+ autoloaders = Rails.autoloaders
535
+ # Guard against non-Zeitwerk configurations: Rails.autoloaders may be
536
+ # present (the method exists) but not expose `main`/`once` (e.g. classic
537
+ # loader mode, or `config.autoloaders = false` returning a null object).
538
+ # `main`/`once` may also individually be nil when only one loader is
539
+ # configured. Filter to the loaders that actually expose
540
+ # `all_expected_cpaths` (the Zeitwerk introspection API the capture
541
+ # relies on).
542
+ loaders =
543
+ if autoloaders.respond_to?(:main) && autoloaders.respond_to?(:once)
544
+ [autoloaders.main, autoloaders.once]
545
+ elsif autoloaders.respond_to?(:each)
546
+ autoloaders.to_a
547
+ else
548
+ []
549
+ end
550
+ loaders.each do |loader|
551
+ next unless loader && loader.respond_to?(:all_expected_cpaths)
514
552
  begin
515
553
  loader.all_expected_cpaths.values.each do |cpath|
516
554
  obj = Object.const_get(cpath) rescue next
@@ -532,10 +570,16 @@ module RactorRailsShim
532
570
  # constants in the main Ractor and wraps the frozen, shareable app in a
533
571
  # WorkerApp that rebinds those constants (and initializes the worker's
534
572
  # ActiveRecord connection) on the first request served by each worker
535
- # Ractor. Returns a shareable WorkerApp instance.
573
+ # Ractor. Returns a shareable WorkerApp instance — frozen and
574
+ # `Ractor.make_shareable`'d — so it can be passed directly to
575
+ # `Ractor.new(worker_app) { |a| a.call(env) }` without the caller having
576
+ # to know the shareability contract.
536
577
  def worker_app(frozen_app)
537
578
  bindings = capture_app_constants
538
- WorkerApp.new(frozen_app, bindings)
579
+ wa = WorkerApp.new(frozen_app, bindings)
580
+ wa.freeze
581
+ Ractor.make_shareable(wa)
582
+ wa
539
583
  end
540
584
 
541
585
  # See patches/active_model_attribute.rb. When the frozen `:ractor` graph is
@@ -575,11 +619,25 @@ module RactorRailsShim
575
619
  # them. But Rails code calls Concurrent::Map#compute_if_absent on these
576
620
  # caches (e.g. ActiveModel::AttributeMethods' attribute_method_patterns_
577
621
  # cache). Plain Hash lacks that method, so we add a compatible definition.
622
+ #
623
+ # Semantics MUST match Concurrent::Map#compute_if_absent exactly:
624
+ # - if the key is present (even with a nil value), return the stored
625
+ # value WITHOUT invoking the block;
626
+ # - otherwise invoke the block, STORE the result (including nil), and
627
+ # return it.
628
+ # The nil-storing behaviour is load-bearing: Concurrent::Map stores nil
629
+ # results so the block doesn't re-run; the original shim implementation
630
+ # used `IES[key] ||= yield` in the frozen branch, which SKIPS nil — a
631
+ # divergent semantics that re-invokes the block on every lookup for any
632
+ # cache slot whose computed value is nil.
633
+ #
578
634
  # For a MUTABLE cache the shim freezes the replaced Hash (so it is shareable
579
635
  # across workers); mutating it would raise FrozenError. So when the receiver
580
636
  # is frozen we route the store to per-Ractor IES keyed by the Hash identity
581
637
  # and the key — giving each worker its own cache entry without mutating the
582
- # shared object. Semantics otherwise match Concurrent::Map.
638
+ # shared object. The frozen branch mirrors the mutable one: check presence
639
+ # first (via IES key?), invoke the block only on miss, store the result
640
+ # (including nil) via IES[]=.
583
641
  def _install_hash_compute_if_absent_patch
584
642
  return @hash_compute_if_absent_patched if defined?(@hash_compute_if_absent_patched) && @hash_compute_if_absent_patched
585
643
  @hash_compute_if_absent_patched = true
@@ -587,8 +645,17 @@ module RactorRailsShim
587
645
  ::Hash.prepend(Module.new do
588
646
  def compute_if_absent(key)
589
647
  if frozen?
590
- ies_key = :"rrs_cia_#{object_id}_#{key}"
591
- ActiveSupport::IsolatedExecutionState[ies_key] ||= yield(key)
648
+ # Check the receiver first — a present key (even nil-valued)
649
+ # returns without invoking the block, matching Concurrent::Map.
650
+ return self[key] if key?(key)
651
+ # Per-Ractor IES slot, namespaced by this Hash's object_id so two
652
+ # frozen hashes with the same key don't collide. Use a two-level
653
+ # structure (Hash per receiver) so the presence check (`key?`)
654
+ # and the store share the same slot.
655
+ slot = ActiveSupport::IsolatedExecutionState[:rrs_cia] ||= {}
656
+ bucket = slot[object_id] ||= {}
657
+ return bucket[key] if bucket.key?(key)
658
+ bucket[key] = yield(key)
592
659
  elsif key?(key)
593
660
  self[key]
594
661
  else
@@ -617,11 +684,9 @@ module RactorRailsShim
617
684
  # be shareable.
618
685
  klass.instance_variables.each do |ivar|
619
686
  val = klass.instance_variable_get(ivar)
620
- next if val.nil? || Ractor.shareable?(val)
621
- begin
687
+ next if Ractor.shareable?(val)
688
+ _swallow("freeze AR ivar #{klass.name}#{ivar}") do
622
689
  Ractor.make_shareable(val)
623
- rescue
624
- nil
625
690
  end
626
691
  end
627
692
  end
@@ -638,11 +703,9 @@ module RactorRailsShim
638
703
  classes.each do |klass|
639
704
  klass.instance_variables.each do |ivar|
640
705
  val = klass.instance_variable_get(ivar)
641
- next if val.nil? || Ractor.shareable?(val)
642
- begin
706
+ next if Ractor.shareable?(val)
707
+ _swallow("freeze global ivar #{klass}#{ivar}") do
643
708
  Ractor.make_shareable(val)
644
- rescue
645
- nil
646
709
  end
647
710
  end
648
711
  end
@@ -699,11 +762,14 @@ module RactorRailsShim
699
762
  # non-frozen arrays; load hooks never fire again in workers.
700
763
  # Gem::LoadError is a ScriptError (not StandardError), so a bare
701
764
  # `rescue nil` on the require would NOT catch a missing msgpack gem.
702
- begin
703
- require "active_support/message_pack"
704
- rescue LoadError
705
- return
706
- end
765
+ # Pre-check for the msgpack gem: active_support/message_pack loads
766
+ # without it but prints a "requires the msgpack gem" warning to $stderr
767
+ # before raising LoadError. Checking first avoids the warning entirely
768
+ # (no stderr suppression needed) and skips the freeze step cleanly when
769
+ # the C extension isn't installed (e.g. in the gem's no-Rails unit specs).
770
+ return unless Gem::Specification.find_all_by_name("msgpack").any?
771
+
772
+ require "active_support/message_pack"
707
773
 
708
774
  mod = (Object.const_get(:ActiveSupport) rescue nil)&.const_get(:Messages, false) rescue nil
709
775
  mod = mod&.const_get(:Metadata, false) rescue nil
@@ -766,8 +832,9 @@ module RactorRailsShim
766
832
  # 2. ensures the worker's ActiveRecord connection handler is initialized.
767
833
  #
768
834
  # The wrapper holds only shareable state (@app, @bindings), so the instance
769
- # is Ractor.make_shareable. `Ractor.current` provides per-worker storage for
770
- # the one-time guard, avoiding any top-level constant reference.
835
+ # is `Ractor.make_shareable`'d by `worker_app` before being handed to worker
836
+ # Ractors. `Ractor.current` provides per-worker storage for the one-time
837
+ # guard, avoiding any top-level constant reference.
771
838
  class WorkerApp
772
839
  def initialize(app, bindings)
773
840
  @app = app
@@ -784,9 +851,12 @@ module RactorRailsShim
784
851
  def setup_once!
785
852
  # All threads inside a worker Ractor share Ractor.current, so a single
786
853
  # per-Ractor mutex serializes the one-time init across the worker's
787
- # threads. (The mutex is created under ||= which is racy under extreme
788
- # contention, but both init steps below are themselves idempotent, so a
789
- # rare double-init is harmless.)
854
+ # threads. `Ractor.current[:key] ||= Thread::Mutex.new` is atomic in
855
+ # Ruby 4.0.6 (verified: 100 concurrent threads produce exactly one
856
+ # mutex object), so the TOCTOU race that would let two threads each
857
+ # create their own mutex does not occur. The `:rrs_worker_ready` guard
858
+ # inside `synchronize` then ensures `rebind_constants` runs exactly
859
+ # once per worker; later threads return early.
790
860
  m = Ractor.current[:rrs_worker_mutex] ||= Thread::Mutex.new
791
861
  m.synchronize do
792
862
  return if Ractor.current[:rrs_worker_ready]
@@ -91,7 +91,7 @@ module RactorRailsShim
91
91
  def devise_parameter_filter
92
92
  key = :"ractor_rails_shim_devise_param_filter_\#{object_id}"
93
93
  v = ActiveSupport::IsolatedExecutionState[key]
94
- return v unless v.nil?
94
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
95
95
  f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys)
96
96
  ActiveSupport::IsolatedExecutionState[key] = f
97
97
  f
@@ -42,7 +42,7 @@ module RactorRailsShim
42
42
  ew.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
43
43
  def active_key
44
44
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
45
- return v unless v.nil?
45
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
46
46
  sym = :"active_execution_wrapper_\#{object_id}"
47
47
  ActiveSupport::IsolatedExecutionState[#{key_str}] = sym
48
48
  sym
@@ -62,7 +62,7 @@ module RactorRailsShim
62
62
  ::Kaminari.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
63
63
  def config
64
64
  v = ActiveSupport::IsolatedExecutionState[#{k_key_str}]
65
- return v unless v.nil?
65
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k_key_str})
66
66
  if Ractor.main? && instance_variable_defined?(:@_config)
67
67
  @_config
68
68
  else