Class: RactorRailsShim::Check

Inherits:
Object
  • Object
show all
Defined in:
lib/ractor_rails_shim/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).



125
126
127
128
129
130
131
132
133
134
135
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
# File 'lib/ractor_rails_shim/check.rb', line 125

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|
      tag = f.kind == :cvar ? " (mattr/cattr — shim targets)" : ""
      lines << "  #{f.owner}#{f.ivar} = #{f.value_class}#{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.



21
22
23
24
25
# File 'lib/ractor_rails_shim/check.rb', line 21

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.



30
31
32
33
34
35
36
37
38
39
40
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
# File 'lib/ractor_rails_shim/check.rb', line 30

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 => e
        next
      end

      shareable = begin
        Ractor.shareable?(val)
      rescue => 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 => e
          next
        end

        shareable = begin
          Ractor.shareable?(val)
        rescue => 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 => 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.



119
120
121
# File 'lib/ractor_rails_shim/check.rb', line 119

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?"



112
113
114
115
116
# File 'lib/ractor_rails_shim/check.rb', line 112

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