Module: RactorRailsShim::LoggerIONeutralizer

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

Class Method Summary collapse

Methods included from RoleDefaults

default_funnel, default_reassign_shareable_const, default_safe_const_get

Class Method Details

.call(app) ⇒ Object

Neutralize the logger IO reachable from app so the subsequent Ractor.make_shareable(app) doesn't freeze the process's real $stdout/$stderr. Best-effort: failures on individual ivar swaps (e.g. frozen owners) are funneled through funnel so debug= surfaces them. Returns nil (the mutate is in-place).



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/ractor_rails_shim/roles/logger_io_neutralizer.rb', line 72

def self.call(app)
  # A frozen, shareable no-op BroadcastLogger (no broadcasts → no
  # IO) to swap in for the app-instance logger graph.
  noop_logger = ::ActiveSupport::BroadcastLogger.new
  noop_logger.freeze
  Ractor.make_shareable(noop_logger)

  # Replace the app-instance logger + any IO reachable from the app
  # graph. Walk ivars + Array/Hash children, tracking seen objects
  # by object_id to avoid cycles.
  seen = {}
  stack = [app]
  until stack.empty?
    o = stack.pop
    next if o.equal?(nil) || seen[o.object_id]
    seen[o.object_id] = true
    # Reuse ShareabilityTraversal.each_ivar for the ivar walk (Issue
    # #39, POODR §4b) — the rescue scaffolding has one home there.
    # The per-ivar branching (@logger -> BroadcastLogger, IO ->
    # NoOpLogDev, else -> recurse) stays inline here; it's role-
    # specific and each_ivar yields [iv, v] pairs cleanly.
    ShareabilityTraversal.each_ivar(o) do |iv, v|
      if iv == :@logger
        # Replace the app-instance / config logger with the no-op
        # (so the frozen app graph holds no live IO). Best-effort;
        # funnel through `funnel` so a frozen-owner failure is
        # traceable under debug=.
        funnel.call("neutralize logger ivar") do
          o.instance_variable_set(iv, noop_logger)
        end
      elsif v.is_a?(::IO) && (v == $stdout || v == $stderr || v == STDOUT || v == STDERR)
        # Any stray IO reference → a shareable no-op sink, built
        # by the injected `noop_log_dev_class` collaborator.
        sink = noop_log_dev_class.new
        sink.freeze
        Ractor.make_shareable(sink)
        funnel.call("neutralize logger IO ivar") do
          o.instance_variable_set(iv, sink)
        end
      elsif v
        stack << v
      end
    end
    # Use the shared CONTAINER_WALKERS dispatch table to push children
    # onto the stack (Issue #32 — reuse traversal from
    # ShareabilityTraversal instead of duplicating an is_a? chain).
    walker = ShareabilityTraversal::CONTAINER_WALKERS.find { |klass, _| o.is_a?(klass) }&.last
    if walker
      walker.call(o) { |c, _| stack << c if c }
    elsif ShareabilityTraversal.enumerable_but_not_basic?(o)
      o.each { |e| stack << e if e } rescue nil
    end
  end

  # Re-point the MAIN ractor's Rails.logger at a fresh live logger
  # (not reachable from the frozen app) so main keeps logging after
  # the app is made shareable. `$stderr` is each-Ractor-local and not
  # in the app graph, so it stays mutable. Use the same shape Rails
  # uses (BroadcastLogger broadcasting to a Logger writing $stderr).
  # Guard: the patched `Rails.logger=` (from install_rails_module)
  # calls `super`; a fake `::Rails` module in unit specs has no
  # superclass `logger=`, so rescue NoMethodError there. Against real
  # Rails the `super` succeeds and the rescue is never hit.
  if Ractor.main? && defined?(::Rails)
    begin
      live = ::ActiveSupport::BroadcastLogger.new(::Logger.new($stderr))
      ::Rails.logger = live
    rescue NoMethodError
      # Fake Rails in a unit spec — no superclass logger= to super.
    end
  end
  nil
end

.configure(funnel: nil, noop_log_dev_class: nil) ⇒ Object

Inject the collaborators. funnel is a callable responding to call(label) { block } that runs the block and rescues StandardError (matching _swallow). noop_log_dev_class is a class responding to .new returning a sink object the role will freeze + make shareable. Passing nil (or calling reset_configuration) restores the facade-lookup defaults.



44
45
46
47
# File 'lib/ractor_rails_shim/roles/logger_io_neutralizer.rb', line 44

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

.funnelObject

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



57
58
59
# File 'lib/ractor_rails_shim/roles/logger_io_neutralizer.rb', line 57

def self.funnel
  @funnel || default_funnel
end

.noop_log_dev_classObject

The active NoOpLogDev class: the injected one if configured, else the facade lookup (RactorRailsShim.singleton_class::NoOpLogDev).



63
64
65
# File 'lib/ractor_rails_shim/roles/logger_io_neutralizer.rb', line 63

def self.noop_log_dev_class
  @noop_log_dev_class || RactorRailsShim.singleton_class.const_get(:NoOpLogDev)
end

.reset_configurationObject

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



50
51
52
53
# File 'lib/ractor_rails_shim/roles/logger_io_neutralizer.rb', line 50

def self.reset_configuration
  @funnel = nil
  @noop_log_dev_class = nil
end