Class: RactorRailsShim::Check

Inherits:
Object
  • Object
show all
Defined in:
lib/ractor_rails_shim/roles/check.rb

Overview

Audit an app for Ractor blockers. Inspects loaded classes/modules for class-level instance variables and mattr_accessor usage that would raise Ractor::IsolationError from a non-main Ractor.

Modeled on Kino's kino --check — tell the user exactly what blocks their app, instead of leaving them to decode IsolationError at runtime.

Defined Under Namespace

Classes: Finding

Class Method Summary collapse

Class Method Details

.report(print: true) ⇒ Object

Human-readable report. Returns a string; also prints to $stderr if print: is true (default).



136
137
138
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
# File 'lib/ractor_rails_shim/roles/check.rb', line 136

def report(print: true)
  findings = scan
  rails_findings = findings.select { |f| rails_namespace?(f.owner) }
  app_findings = findings - rails_findings

  lines = []
  cvar_count = findings.count { |f| f.kind == :cvar }
  ivar_count = findings.count { |f| f.kind == :ivar }
  lines << "ractor-rails-shim check: #{findings.size} blocker(s) found" \
    " (#{ivar_count} class-ivar, #{cvar_count} class-var)"
  lines << "  (unshareable values in @ivar and @@cvar; reads/writes from a non-main Ractor"
  lines << "   would raise Ractor::IsolationError)"
  lines << ""

  %i[rails app].each do |group|
    grp = group == :rails ? rails_findings : app_findings
    next if grp.empty?

    label = group == :rails ? "Rails framework" : "app + gems"
    lines << "=== #{label} (#{grp.size}) ==="
    grp.first(50).each do |f|
      lines << "  #{f.owner}#{f.ivar} = #{f.value_class}#{f.tag}"
      lines << "    #{f.source}" if f.source
    end
    if grp.size > 50
      lines << "  ... and #{grp.size - 50} more (use Check.scan to see all)"
    end
    lines << ""
  end

  if findings.empty?
    lines << "no blockers found — app may be Ractor-compatible"
  else
    lines << "hints:"
    lines << "  - require \"ractor_rails_shim\" and call RactorRailsShim.install before"
    lines << "    Rails.application is first accessed (early in config/boot.rb)"
    lines << "  - class-var (@@foo) blockers from mattr_accessor/cattr_accessor are"
    lines << "    rerouted by the shim automatically once installed"
    lines << "  - raw class-ivar (@foo) blockers are NOT fixed by the shim; patch the"
    lines << "    gem or use Ractor.make_shareable + a constant for shareable state"
    lines << "  - for per-Ractor mutable state use Ractor.store_if_absent(key) { default }"
  end

  out = lines.join("\n")
  $stderr.puts(out) if print
  out
end

.safe_class(val) ⇒ Object

Safely derive a value's class name. BasicObject (and its subclasses) don't define .class, so calling it raises NoMethodError — fall back to "BasicObject" in that case.



32
33
34
35
36
# File 'lib/ractor_rails_shim/roles/check.rb', line 32

def safe_class(val)
  val.class.name || val.class.to_s
rescue NoMethodError
  "BasicObject"
end

.scanObject

Scan all loaded classes/modules and report class ivars AND class variables holding unshareable values. Returns an array of Finding.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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
# File 'lib/ractor_rails_shim/roles/check.rb', line 41

def scan
  findings = []
  seen = {}

  ObjectSpace.each_object(Module) do |mod|
    next if mod.name.nil? || mod.name.empty?
    next if mod.name.start_with?("#", "Ractor::", "Thread::", "Fiber::", "ObjectSpace::")

    # Inspect class-level instance variables.
    mod.instance_variables.each do |ivar|
      next if ivar == :@_sandbox # Rails internal, ignored
      begin
        val = mod.instance_variable_get(ivar)
      rescue StandardError => e
        next
      end

      shareable = begin
        Ractor.shareable?(val)
      rescue StandardError => e
        false
      end
      next if shareable

      key = "#{mod.name}#{ivar}"
      next if seen[key]
      seen[key] = true

      source = locate(mod, ivar)
      findings << Finding.new(
        owner: mod.name,
        ivar: ivar.to_s,
        value_class: safe_class(val),
        shareable: shareable,
        source: source,
        kind: :ivar
      )
    end

    # Inspect class variables (@@foo) — these back mattr_accessor /
    # cattr_accessor and are ALSO subject to Ractor::IsolationError
    # from non-main Ractors (verified on Ruby 4.0.5).
    begin
      mod.class_variables.each do |cvar|
        begin
          val = mod.class_variable_get(cvar)
        rescue StandardError => e
          next
        end

        shareable = begin
          Ractor.shareable?(val)
        rescue StandardError => e
          false
        end
        next if shareable

        key = "#{mod.name}#{cvar}"
        next if seen[key]
        seen[key] = true

        source = locate(mod, cvar)
        findings << Finding.new(
          owner: mod.name,
          ivar: cvar.to_s,
          value_class: safe_class(val),
          shareable: shareable,
          source: source,
          kind: :cvar
        )
      end
    rescue StandardError => e
      # Some modules raise on class_variables enumeration; skip.
      next
    end
  end

  findings.sort_by { |f| [f.owner, f.ivar] }
end

.scan_appObject

Scan app + gem classes outside the Rails framework.



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

def scan_app
  scan - scan_rails
end

.scan_railsObject

Scan only Rails framework modules (Railties, ActiveRecord, etc.) — the ones the shim targets. Useful for "is Rails itself clean?"



123
124
125
126
127
# File 'lib/ractor_rails_shim/roles/check.rb', line 123

def scan_rails
  scan.select { |f| f.owner.start_with?("Rails", "ActiveRecord", "ActiveSupport",
    "ActionController", "ActionView", "ActionDispatch", "ActionMailer",
    "ActiveJob", "ActionCable", "ActionText", "ActionMailbox", "ActiveStorage") }
end