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.



139
140
141
142
143
144
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
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 139

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)
      if name == :process_action && filters.length >= 2 && filters[0].is_a?(Symbol)
        kind = filters[0]
        filter = filters[1]
        if filter.is_a?(Symbol) &&
           self.is_a?(::Class) &&
           self.ancestors.include?(::AbstractController::Base)
          # Rails converts `only:`/`except:` into an ActionFilter object
          # stored in the callback's `:if`/`:unless` options (NOT a bare
          # `:only` key). Read the constraint back from the ActionFilter's
          # @conditional_key (:only/:except) and @actions (a Set of action
          # name Strings) via the extracted, version-gated helper.
          opts = filters.find { |f| f.is_a?(Hash) }
          only = nil
          except = 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
          end
          ::RactorRailsShim::CallbackCapture.record_declared_callback(
            self.object_id, kind, filter, only, except)
        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.



203
204
205
206
207
208
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 203

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).



221
222
223
224
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 221

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, kind, filter, only, except) ⇒ Object

Record a single declared symbolic filter. Called from the set_callback interceptor during eager load (main Ractor only).



118
119
120
121
122
123
124
125
126
127
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 118

def self.record_declared_callback(klass_id, kind, filter, only, except)
  @declared_callbacks = {} unless defined?(@declared_callbacks)
  table = @declared_callbacks
  (table[klass_id] ||= []) << {
    kind: kind,
    filter: filter,
    only: (only.freeze if only),
    except: (except.freeze if except)
  }
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.



130
131
132
# File 'lib/ractor_rails_shim/roles/callback_capture.rb', line 130

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