Module: RactorRailsShim::CallbackCapture

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

Class Method Summary collapse

Methods included from RoleDefaults

default_funnel, default_reassign_shareable_const, default_safe_const_get

Class Method Details

.configure(funnel: nil, reassign_shareable_const: nil, register_patch: nil) ⇒ Object

Inject the callable collaborators. funnel responds to call(label) { block } (runs the block, rescues StandardError — matches _swallow). reassign_shareable_const responds to call(sym, value) (reassigns the shareable constant). register_ patch responds to call(name, version) (records the patch tag). Passing nil for any (or calling reset_configuration) restores the facade-lookup default for that collaborator.



59
60
61
62
63
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 59

def self.configure(funnel: nil, reassign_shareable_const: nil, register_patch: nil)
  @funnel = funnel
  @reassign_shareable_const = reassign_shareable_const
  @register_patch = register_patch
end

.freeze_declared_callbacks!Object

Freeze (make Ractor-shareable) the captured declared-callbacks table so worker Ractors can read it via the SHAREABLE_DECLARED_CALLBACKS constant. Deep-freeze (make shareable) so workers can read the constant. Entries are Hashes of Symbols/booleans/nil/Arrays — all natively shareable. A non-frozen constant raises Ractor::IsolationError when a worker reads it.



108
109
110
111
112
113
114
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 108

def self.freeze_declared_callbacks!
  table = (@declared_callbacks || {})
  funnel.call("freeze declared callbacks") do
    Ractor.make_shareable(table)
    reassign_shareable_const.call(:SHAREABLE_DECLARED_CALLBACKS, table)
  end
end

.funnelObject

The active funnel: the injected one if configured, else the facade lookup (RactorRailsShim::Funnel.method(:swallow)).



86
87
88
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 86

def self.funnel
  @funnel || default_funnel
end

.install_callback_declaration_capture!Object

Install an interceptor on ActiveSupport::Callbacks.set_callback that records, per declaring class, every symbolic :process_action filter it declares. This must run BEFORE eager load (so declarations are captured as they happen) — install wires it via the ActiveSupport.on_load(:active_support) hook in install.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 145

def self.install_callback_declaration_capture!
  return if @installed
  register_patch.call(:action_filter_introspection, "8.1")
  @installed = true
  # ActiveSupport::Callbacks may not be loaded yet at
  # on_load(:active_support) time (it's required lazily). Require it so
  # the ClassMethods module with set_callback exists before we alias it.
  require "active_support/callbacks" rescue nil
  mod = (defined?(::ActiveSupport::Callbacks) &&
         ::ActiveSupport::Callbacks.const_defined?(:ClassMethods)) ?
        ::ActiveSupport::Callbacks::ClassMethods : nil
  return unless mod && mod.method_defined?(:set_callback)
  # Alias the original `set_callback` exactly once. The @callback_
  # capture_installed guard above short-circuits a second install, but
  # specs clear that flag to test the install path in isolation; without
  # this `unless`, the second alias overwrites `_rrs_orig_set_callback`
  # with the *interceptor* (which is now `set_callback`), so any later
  # `set_callback` call recurses infinitely through the interceptor.
  mod.alias_method(:_rrs_orig_set_callback, :set_callback) unless mod.method_defined?(:_rrs_orig_set_callback)
  mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    def set_callback(name, *filters, &block)
      # Capture any SYMBOLIC filter (filters[1] is a Symbol) on an app class
      # — controller (AbstractController::Base) OR ActiveRecord model — for
      # ANY callback chain kind (:process_action, :save, :create, :destroy,
      # …). Symbolic filters are shareable; we re-invoke the named method in
      # worker Ractors. Lambda/block filters are unshareable and are left in
      # the (empty in workers) chain — they need a dedicated transport (e.g.
      # the dependent-association transport). Capturing ALL kinds (not just
      # :process_action) is what generalizes the transport to any callback.
      if filters.length >= 2 && filters[0].is_a?(Symbol) &&
         self.is_a?(::Class) &&
         (
           (self.ancestors.include?(::AbstractController::Base) rescue false) ||
           (defined?(::ActiveRecord::Base) && (self < ::ActiveRecord::Base))
         )
        kind = filters[0]
        filter = filters[1]
        if filter.is_a?(Symbol)
          opts = filters.find { |f| f.is_a?(::Hash) }
          only = nil
          except = nil
          if_cond = nil
          unless_cond = nil
          if opts
            [opts[:if], opts[:unless]].each do |arr|
              next unless arr.is_a?(::Array)
              arr.each do |af|
                ck, acts = ::RactorRailsShim::CallbackCapture.read_action_filter_constraints(af)
                next unless ck && acts
                only = acts if ck == :only
                except = acts if ck == :except
              end
            end
            # Capture Symbol if:/unless: conditions (e.g. Devise's
            # `after_update :send_email_changed_notification,
            # if: :send_email_changed_notification?`). These are
            # method-name Symbols that the transport can call on the
            # context to gate the callback — without this, the callback
            # fires unconditionally in worker Ractors.
            raw_if = opts[:if]
            raw_unless = opts[:unless]
            if_cond = raw_if if raw_if.is_a?(::Symbol)
            unless_cond = raw_unless if raw_unless.is_a?(::Symbol)
          end
          ::RactorRailsShim::CallbackCapture.record_declared_callback(
            self.object_id, name, kind, filter, only, except, if_cond, unless_cond)
        end
      end
      _rrs_orig_set_callback(name, *filters, &block)
    end
  RUBY
  @installed = true
end

.installed?Boolean

Has install_callback_declaration_capture! run? Lives on CallbackCapture (Issue #24 — own your own state), NOT on the facade singleton.

Returns:

  • (Boolean)


75
76
77
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 75

def self.installed?
  @installed
end

.read_action_filter_constraints(af) ⇒ Object

Read @conditional_key and @actions off an ActionFilter instance (Rails internal ivars). Returns [conditional_key, actions_as_symbols]. On a Rails version where the ivars are renamed/absent, returns [nil, nil]. A missing ivar means callbacks run for actions they shouldn't (security-relevant). instance_variable_get returns nil for a missing ivar without raising, so we check instance_variable_defined? and emit a labeled warning via _swallow when debug=true so a silent Rails rename is visible during diagnosis.



227
228
229
230
231
232
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 227

def self.read_action_filter_constraints(af)
  ck = read_ivar_or_warn(af, :@conditional_key, "action filter constraints")
  acts = read_ivar_or_warn(af, :@actions, "action filter constraints")
  acts = acts.to_a.map(&:to_sym) if acts && acts.respond_to?(:to_a)
  [ck, acts]
end

.read_ivar_or_warn(obj, ivar, label) ⇒ Object

Read an ivar; if it's undefined, behavior is gated by the VersionPolicy::Strategy (Issue #37 — the case policy branch is replaced by a strategy-module message):

Strict — raise UnsupportedVersionError (a missing ivar means
       callbacks run for actions they shouldn't; failing loud
       pins the security-relevant failure mode instead of
       silently mis-routing callbacks)
Warn   — emit a labeled warning via funnel when debug? so a silent
       Rails internal rename surfaces during diagnosis
Off    — silent nil

Returns the ivar value or nil (under Warn/Off).



245
246
247
248
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 245

def self.read_ivar_or_warn(obj, ivar, label)
  return obj.instance_variable_get(ivar) if obj.instance_variable_defined?(ivar)
  RactorRailsShim::VersionPolicy.strategy.missing_ivar(obj, ivar, label, funnel: funnel)
end

.reassign_shareable_constObject

The active reassign callable: the injected one if configured, else the facade lookup (RactorRailsShim.method(:_reassign_shareable_const)).



92
93
94
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 92

def self.reassign_shareable_const
  @reassign_shareable_const || default_reassign_shareable_const
end

.record_declared_callback(klass_id, chain_kind, phase, filter, only, except, if_cond = nil, unless_cond = nil) ⇒ Object

Record a single declared symbolic filter. Called from the set_callback interceptor during eager load (main Ractor only). chain_kind is the ActiveSupport::Callbacks chain name (:process_action, :save, :create, :destroy, …); phase is :before / :after. Storing chain_kind is what generalizes replay beyond controllers to model lifecycle callbacks.



121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 121

def self.record_declared_callback(klass_id, chain_kind, phase, filter, only, except, if_cond = nil, unless_cond = nil)
  @declared_callbacks = {} unless defined?(@declared_callbacks)
  table = @declared_callbacks
  (table[klass_id] ||= []) << {
    chain_kind: chain_kind,
    phase: phase,
    filter: filter,
    only: (only.freeze if only),
    except: (except.freeze if except),
    if_cond: (if_cond.freeze if if_cond),
    unless_cond: (unless_cond.freeze if unless_cond)
  }
end

.register_patchObject

The active register_patch callable: the injected one if configured, else the facade lookup (RactorRailsShim.method(:_register_patch)).



98
99
100
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 98

def self.register_patch
  @register_patch || RactorRailsShim.method(:_register_patch)
end

.reset_configurationObject

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



66
67
68
69
70
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 66

def self.reset_configuration
  @funnel = nil
  @reassign_shareable_const = nil
  @register_patch = nil
end

.reset_declared_callbacks!Object

Clear the declared-callbacks table. Test seam.



136
137
138
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 136

def self.reset_declared_callbacks!
  remove_instance_variable(:@declared_callbacks) if instance_variable_defined?(:@declared_callbacks)
end

.reset_installed!Object

Clear the installed flag. Test seam + reinstall seam.



80
81
82
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 80

def self.reset_installed!
  @installed = false
end