Module: RactorRailsShim::ShareabilityTraversal

Extended by:
RoleDefaults
Defined in:
lib/ractor_rails_shim/roles/shareability_traversal.rb

Defined Under Namespace

Classes: Locs

Constant Summary collapse

CONTAINER_WALKERS =

Container dispatch table: maps container class to a walker lambda. Replaces the is_a? chain in each_ivar_and_child (Issue #30, POODR §4a Duck Typing). Each lambda takes an object and a block, yielding [child, ivar_or_nil] pairs.

{
  Hash => ->(o, &b) {
    o.each { |k, v| b.call(k, nil); b.call(v, nil) }
    dp = o.default_proc
    b.call(dp, :__default_proc__) if dp
  },
  Array => ->(o, &b) { o.each { |e| b.call(e, nil) } },
  Set => ->(o, &b) { o.each { |e| b.call(e, nil) } },
  Struct => ->(o, &b) { o.each_pair { |_n, v| b.call(v, nil) } },
}.freeze
BASIC_TYPES =

The container types with dedicated walkers in CONTAINER_WALKERS, plus String (which is Enumerable over chars but not a container of references we want to walk). Derived from CONTAINER_WALKERS.keys so a new container added to the dispatch table is automatically excluded here — the two lists can't drift (Issue #39, POODR §4a).

(CONTAINER_WALKERS.keys + [::String]).freeze
PROC_REPLACEMENTS =

Proc-replacement dispatch table (Issue #39, POODR §4c Duck Typing). Replaces the 6-way if/elsif on (source_location_suffix, ivar) in replace_one_proc. Each entry is keyed by [tag, ivar] where tag is a Symbol identifying the LOC string (resolved at dispatch time via src.end_with?, so injected LOCs work) and ivar is the ivar symbol or nil for an ivar-wildcard (matches any ivar). The [nil, nil] entry is the default for unknown Procs. Each value is a lambda (traversal, proc_obj, parent, mw) -> replacement. Adding a new proc-replacement rule is a one-line table entry instead of a new elsif.

{
  [:ssl, :@exclude] => ->(t, _p, parent, _mw) {
    t.callable_const_class.new(!parent.instance_variable_get(:@redirect))
  },
  [:files, :@app] => ->(t, proc_obj, parent, mw) {
    # The lambda is `Rack::Files#initialize`'s `lambda { |env| get env }`,
    # stored as `Rack::Head#@app`. Its `self` (binding receiver) is the
    # `Rack::Files` instance that defines `get` — NOT the `Rack::Head`
    # that holds it. Use the binding receiver as the callable target so
    # the worker calls `Rack::Files#get(env)` (the original behavior).
    # Fall back to the middleware-chain search if the receiver can't be
    # resolved (e.g. frozen/unavailable binding).
    receiver = proc_obj.binding.receiver rescue nil
    files_server = receiver if receiver && receiver.respond_to?(:get)
    files_server ||= t.find_files_server.call(mw)
    files_server ||= parent
    t.callable_class.new(files_server, :get)
  },
  [:cookie, nil] => ->(t, _p, _parent, _mw) {
    t.request_callable_class.new(:cookies_same_site_protection)
  },
  [:devise_scope, nil] => ->(t, proc_obj, parent, _mw) {
    t.devise_mapping_replacement.call(proc_obj, parent)
  },
  [:mapper, :@strategy] => ->(t, proc_obj, _parent, _mw) {
    # Identify SERVE vs CALL by OBJECT IDENTITY against the actual
    # ActionDispatch constants, NOT by source_location line number.
    # See strategy_replacement_for for the full rationale.
    t.strategy_replacement_for.call(proc_obj)
  },
  [nil, nil] => ->(t, _p, _parent, _mw) {
    t.noop_proc_class.new
  },
}.freeze
LOC_TAGS =

The ordered list of (tag, locs-attr) pairs used to resolve a source_location suffix to a tag. Kept in dispatch order (the first matching suffix wins, mirroring the original elsif chain). The [nil, nil] default in PROC_REPLACEMENTS is the fallback.

[
  [:ssl,          :ssl],
  [:files,        :files],
  [:cookie,       :cookie],
  [:devise_scope, :devise_scope],
  [:mapper,       :mapper],
].freeze

Class Method Summary collapse

Methods included from RoleDefaults

default_funnel, default_reassign_shareable_const, default_safe_const_get

Class Method Details

.callable_classObject



129
130
131
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 129

def self.callable_class
  @callable_class || RactorRailsShim.singleton_class.const_get(:Callable)
end

.callable_const_classObject



133
134
135
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 133

def self.callable_const_class
  @callable_const_class || RactorRailsShim.singleton_class.const_get(:CallableConst)
end

.collect_procs(app) ⇒ Object



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 238

def self.collect_procs(app)
  seen = {}
  procs = []
  stack = [[app, nil, nil]]
  until stack.empty?
    o, parent, ivar = stack.pop
    next if o.equal?(nil)
    # Skip BasicObject subclasses that don't respond to is_a?/object_id
    # (e.g. ActiveSupport::Callbacks::CallTemplate internals). Must guard
    # BEFORE calling is_a? — BasicObject doesn't define it.
    next unless introspectable?(o)
    if o.is_a?(Proc)
      procs << [o, parent, ivar]
      next
    end
    next if seen[o.object_id]
    seen[o.object_id] = true
    next if o.is_a?(Mutex) || o.is_a?(Monitor)
    each_ivar_and_child(o) do |child, child_ivar|
      if child_ivar == :__default_proc__
        procs << [child, o, :__default_proc__]
      else
        stack << [child, o, child_ivar] if child
      end
    end
  end
  procs
end

.configure(funnel: nil, find_files_server: nil, devise_mapping_replacement: nil, strategy_replacement_for: nil, noop_lock_class: nil, noop_proc_class: nil, callable_class: nil, callable_const_class: nil, request_callable_class: nil, locs: nil) ⇒ Object

Inject the collaborators. Callables: funnel (= _swallow), find_files_server, devise_mapping_replacement, strategy_replacement_for. Callable classes: noop_lock_class, noop_proc_class, callable_class, callable_const_class, request_callable_class. LOC strings: locs: (a Locs value object). Passing nil for any (or calling reset_configuration) restores the facade-lookup default for that collaborator.



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 73

def self.configure(funnel: nil, find_files_server: nil,
                   devise_mapping_replacement: nil, strategy_replacement_for: nil,
                   noop_lock_class: nil, noop_proc_class: nil,
                   callable_class: nil, callable_const_class: nil,
                   request_callable_class: nil,
                   locs: nil)
  @funnel = funnel
  @find_files_server = find_files_server
  @devise_mapping_replacement = devise_mapping_replacement
  @strategy_replacement_for = strategy_replacement_for
  @noop_lock_class = noop_lock_class
  @noop_proc_class = noop_proc_class
  @callable_class = callable_class
  @callable_const_class = callable_const_class
  @request_callable_class = request_callable_class
  @locs = locs
end


149
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 149

def self.cookie_loc; locs.cookie; end

.devise_mapping_replacementObject



113
114
115
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 113

def self.devise_mapping_replacement
  @devise_mapping_replacement || RactorRailsShim.method(:_devise_mapping_replacement)
end

.devise_scope_locObject



150
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 150

def self.devise_scope_loc; locs.devise_scope; end

.each_ivar(o) ⇒ Object

Enumerate the instance variables of o, yielding [ivar, value] pairs. The instance_variable_get is guarded so an ivar that raises is skipped (not fatal). The whole instance_variables call is guarded so a BasicObject (or frozen object) that doesn't support it is a no-op. This is the ivar-only half of each_ivar_and_child — the half callers that don't need the container walk (e.g. LoggerIONeutralizer, which keeps its per-ivar branching inline) use this directly (Issue #39, POODR §4b Duck Typing).



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 203

def self.each_ivar(o)
  return to_enum(:each_ivar, o) unless block_given?
  begin
    o.instance_variables.each do |iv|
      v =
        begin
          o.instance_variable_get(iv)
        rescue StandardError
          next
        end
      yield iv, v
    end
  rescue StandardError
    # BasicObject or frozen objects don't support instance_variables.
  end
end

.each_ivar_and_child(o) ⇒ Object

Enumerate every child reference of o for the graph traversals:

- instance variables (yields [value, iv])
- container entries via CONTAINER_WALKERS (Array, Hash, Set, Struct)
- Enumerable fallback for other Enumerable types

Centralized so collect_procs and replace_locks_and_concurrent_maps! share the same container coverage. The ivar half delegates to each_ivar (Issue #39) so the rescue scaffolding has one home.



228
229
230
231
232
233
234
235
236
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 228

def self.each_ivar_and_child(o)
  each_ivar(o) { |iv, v| yield v, iv }
  walker = CONTAINER_WALKERS.find { |klass, _| o.is_a?(klass) }&.last
  if walker
    walker.call(o) { |c, iv| yield c, iv }
  elsif enumerable_but_not_basic?(o)
    o.each { |e| yield e, nil } rescue nil
  end
end

.enumerable_but_not_basic?(o) ⇒ Boolean

True if o is Enumerable but NOT one of the container types with a dedicated branch in each_ivar_and_child. Used to gate the generic Enumerable fallback so we don't double-walk Array/Hash/etc.

Returns:

  • (Boolean)


187
188
189
190
191
192
193
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 187

def self.enumerable_but_not_basic?(o)
  return false unless o.is_a?(::Enumerable)
  BASIC_TYPES.none? { |t| o.is_a?(t) }
rescue NoMethodError
  # BasicObject without Kernel — not Enumerable.
  false
end

.files_locObject



148
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 148

def self.files_loc; locs.files; end

.find_files_serverObject



109
110
111
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 109

def self.find_files_server
  @find_files_server || RactorRailsShim.method(:_find_files_server)
end

.funnelObject



105
106
107
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 105

def self.funnel
  @funnel || default_funnel
end

.generate_ar_attribute_methods!Object

Force AR attribute-method generation for every loaded model in the MAIN Ractor. See the call site in make_app_shareable! for why; without this a worker Ractor dies with Ractor::IsolationError on the first model instantiation (GeneratedAttributeMethods::LOCK is a non-shareable Monitor).



458
459
460
461
462
463
464
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 458

def self.generate_ar_attribute_methods!
  return unless defined?(::ActiveRecord::Base)
  ARModelWalker.each_model do |klass|
    next unless klass.respond_to?(:define_attribute_methods)
    klass.define_attribute_methods
  end
end

.introspectable?(o) ⇒ Boolean

BasicObject (and its subclasses) don't define respond_to?, so calling o.respond_to? on one raises NoMethodError. Use this to safely test whether an object can be introspected (is_a?, instance_variables, ...).

Returns:

  • (Boolean)


171
172
173
174
175
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 171

def self.introspectable?(o)
  o.respond_to?(:is_a?)
rescue NoMethodError
  false
end

.loc_tag_for(src) ⇒ Object

Resolve a source_location suffix to a LOC tag, or nil if no LOC matches. Drives the PROC_REPLACEMENTS lookup.



350
351
352
353
354
355
356
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 350

def self.loc_tag_for(src)
  current_locs = locs
  LOC_TAGS.each do |tag, attr|
    return tag if src.end_with?(current_locs.public_send(attr))
  end
  nil
end

.locsObject

The active Locs value object. Delegates the 5 individual LOC readers to the Locs attributes for backward compat.



143
144
145
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 143

def self.locs
  @locs || Locs.default
end

.mapper_locObject



151
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 151

def self.mapper_loc; locs.mapper; end

.noop_lock_classObject



121
122
123
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 121

def self.noop_lock_class
  @noop_lock_class || RactorRailsShim.singleton_class.const_get(:NoOpLock)
end

.noop_proc_classObject



125
126
127
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 125

def self.noop_proc_class
  @noop_proc_class || RactorRailsShim.singleton_class.const_get(:NoOpProc)
end

.precompute_lazy_ivars(app) ⇒ Object

Force lazy app ivars to populate in the MAIN Ractor before the graph is frozen. Each call rescues nil (the app may not define the method, or it may raise during boot).



445
446
447
448
449
450
451
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 445

def self.precompute_lazy_ivars(app)
  app.env_config
  app.app_env_config rescue nil
  app.routes.url_helpers rescue nil
  app.routes.named_routes rescue nil
  app.routes.helpers rescue nil
end

.replace_locks_and_concurrent_maps!(app) ⇒ Object

Identify which ActionDispatch strategy a Proc is and return its shareable replacement. The Proc stored in ActionDispatch::Routing::Mapper::Constraints#@strategy is the constant SERVE or CALL (assigned by reference), so equal? is the robust identifier — independent of the source line number, which any Rails patch release can shift. Returns NoOpProc for an unknown Proc (defensive; never mis-route).

Kept on the facade (not moved here) because it constructs StrategyServe/StrategyCall (action_dispatch.rb concern) — it's a cross-concern dispatcher reached via RactorRailsShim._strategy_replacement_for. The extracted traversal calls it through the facade.



410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 410

def self.replace_locks_and_concurrent_maps!(app)
  seen = {}
  stack = [[app, nil, nil]]
  until stack.empty?
    o, _parent, _ivar = stack.pop
    next if o.equal?(nil)
    next unless introspectable?(o)
    next if seen[o.object_id]
    seen[o.object_id] = true
    next if o.is_a?(Mutex) || o.is_a?(Monitor)
    each_ivar_and_child(o) do |child, child_ivar|
      next if child_ivar == :__default_proc__
      if child.is_a?(Mutex) || child.is_a?(Monitor)
        funnel.call("replace lock ivar") do
          o.instance_variable_set(child_ivar, noop_lock_class.new) if child_ivar
        end
        # If the lock is in an Array/Set/Hash (no ivar), we can't swap
        # it in place here — leave it; make_shareable will handle the
        # frozen container. The ivar case is the load-bearing one.
      elsif defined?(::Concurrent::Map) && child.is_a?(::Concurrent::Map) && child_ivar
        hash_copy = {}
        child.each_pair { |k, val| hash_copy[k] = val }
        funnel.call("replace concurrent map ivar") do
          o.instance_variable_set(child_ivar, hash_copy)
        end
      elsif child
        stack << [child, o, child_ivar]
      end
    end
  end
end

.replace_one_proc(proc_obj, parent, ivar, mw) ⇒ Object



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 358

def self.replace_one_proc(proc_obj, parent, ivar, mw)
  src = proc_obj.source_location&.first || ""
  tag = loc_tag_for(src)
  key = PROC_REPLACEMENTS.key?([tag, ivar]) ? [tag, ivar] : [tag, nil]
  builder = PROC_REPLACEMENTS.fetch(key) { PROC_REPLACEMENTS[[nil, nil]] }
  replacement = builder.call(self, proc_obj, parent, mw)

  if ivar == :__default_proc__
    # The parent Hash may already be frozen (e.g. by an earlier
    # shareability pass on AR internals). A frozen Hash can't have its
    # default cleared, but a frozen Hash with a default_proc is still
    # unshareable — Ractor.make_shareable(parent) later will replace it
    # wholesale if needed. Just skip here when frozen.
    begin
      parent.default = nil
    rescue FrozenError, RuntimeError
      # frozen Hash — leave the default_proc; make_shareable handles it.
    end
  elsif ivar
    funnel.call("replace proc ivar") do
      parent.instance_variable_set(ivar, replacement)
    end
  elsif parent.is_a?(Array)
    idx = parent.index(proc_obj)
    if idx then parent[idx] = replacement
    else parent.each_with_index { |e, i| parent[i] = replacement if e.equal?(proc_obj) }
    end
  elsif parent.is_a?(Hash)
    funnel.call("replace proc hash entry") do
      key = parent.key(proc_obj)
      parent[key] = replacement if key
    end
  end
end

.replace_unshareable_procs!(app) ⇒ Object

Replace every Proc in the app graph with a callable/no-op object. Multiple passes because the same Proc object can live in many containers (e.g. deprecation behaviors shared across deprecators). Doesn't dedup procs — must replace every occurrence.



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 271

def self.replace_unshareable_procs!(app)
  mw = (app.instance_variable_get(:@app) rescue nil)
  # Replace every Proc in the graph. The same Proc object can live in
  # many containers (e.g. deprecation behaviors shared across
  # deprecators), and replacing one occurrence doesn't replace the
  # others — so we loop until a fixed point (no Procs left). A safety
  # cap guards against a pathological graph where replacement keeps
  # introducing new Procs (the replacements themselves are NoOpProc/
  # Callable instances, not Procs, so this shouldn't happen, but the
  # cap prevents an infinite loop if a future callable class leaks a
  # Proc). 3 passes was the original magic number; observed real graphs
  # converge in 2.
  max_passes = 8
  max_passes.times do
    procs = collect_procs(app)
    break if procs.empty?
    procs.each { |proc_obj, parent, ivar| replace_one_proc(proc_obj, parent, ivar, mw) }
  end
end

.request_callable_classObject



137
138
139
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 137

def self.request_callable_class
  @request_callable_class || RactorRailsShim.singleton_class.const_get(:RequestCallable)
end

.reset_configurationObject

Restore the default (facade-lookup) collaborators. Test seam.



92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 92

def self.reset_configuration
  @funnel = nil
  @find_files_server = nil
  @devise_mapping_replacement = nil
  @strategy_replacement_for = nil
  @noop_lock_class = nil
  @noop_proc_class = nil
  @callable_class = nil
  @callable_const_class = nil
  @request_callable_class = nil
  @locs = nil
end

.ssl_locObject



147
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 147

def self.ssl_loc; locs.ssl; end

.strategy_replacement_forObject



117
118
119
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 117

def self.strategy_replacement_for
  @strategy_replacement_for || RactorRailsShim::ActionDispatchStrategy.method(:replacement_for)
end

.warm_attribute_method_patterns!Object

Build + freeze ActiveModel's per-class attribute_method_patterns_cache (and attribute_method_matchers) in the MAIN Ractor for every loaded model. These are lazy class ivars populated on the first respond_to? call; they hold an Array of [Regexp, Symbol] pairs (shareable elements) but the Array itself is mutable and therefore NOT Ractor-shareable. A worker Ractor reading the ivar raises Ractor::IsolationError. Building it in MAIN (where it is reachable) and freezing the Array makes it shareable; the cache is never mutated after build, so freezing is safe.



475
476
477
478
479
480
481
482
483
484
485
486
# File 'lib/ractor_rails_shim/roles/shareability_traversal.rb', line 475

def self.warm_attribute_method_patterns!
  return unless defined?(::ActiveRecord::Base)
  ARModelWalker.each_model do |klass|
    next unless klass.respond_to?(:attribute_method_patterns_cache, true)
    cache = klass.send(:attribute_method_patterns_cache)
    cache.freeze if cache
    if klass.respond_to?(:attribute_method_matchers, true)
      matchers = klass.send(:attribute_method_matchers)
      matchers.freeze if matchers
    end
  end
end