Module: Rigor::Plugin::Isolation

Defined in:
lib/rigor/plugin/isolation.rb

Overview

ADR-39 slice 5 — the selectable isolation strategy for target-library invocation. A plugin invokes a pure method on a trusted target library (e.g. ActiveSupport::Inflector.pluralize("post")) through Isolation.call; how much the invocation is isolated from Rigor's own process is a configurable strategy (RIGOR_PLUGIN_ISOLATION env; the exe/rigor launcher maps .rigor.yml's plugins_isolation: onto it before re-exec). Three backends behind one interface:

  • none — load into the main space and call directly. Lowest cost; no isolation. Used as the fallback where fork is unavailable; fine because the invoked library is trusted + pure.
  • ruby_box — call inside a Box (Ruby::Box, RUBY_BOX=1). Isolates core-class monkey-patches + lets gem versions coexist, but a native crash in the boxed work still takes the process down (in-process).
  • process (default) — call in a forked worker (Process); returns data over a pipe. The strongest: a child crash (even SIGSEGV) is contained — the parent survives and declines. Higher cost (fork + IPC).

All three answer with the method's return value, or raise Unavailable (never approximate) when the target library cannot be reached in the chosen strategy — the caller's per-plugin rescue turns that into silence, never a wrong fact.

Defined Under Namespace

Modules: Direct, Process, RubyBox Classes: Unavailable

Constant Summary collapse

STRATEGIES =
%w[none ruby_box process].freeze
DEFAULT =

The default strategy. process (a crash-contained forked worker) is the default: it isolates the target library's monkey-patches + crashes from Rigor with no in-process contamination, and forks a single persistent worker (not one per call). It falls back to none where fork is unavailable (see #backend).

"process"

Class Method Summary collapse

Class Method Details

.backendObject

The backend module for the configured strategy. process (including the default) falls back to Direct where fork is unavailable (Windows / JRuby) so inflection still works rather than silently degrading — the libraries are trusted + pure, so the main-space fallback is acceptable when no fork-based isolation can be had.



117
118
119
120
121
122
123
# File 'lib/rigor/plugin/isolation.rb', line 117

def backend
  case strategy_name
  when "ruby_box" then RubyBox
  when "none" then Direct
  else Process.available? ? Process : Direct
  end
end

.bundle_gem_dirs(bundle_root) ⇒ Object

The RubyGems-shaped directories under a bundler install root: bundler nests them as <root>/ruby/<ruby-version>/; a BUNDLE_PATH may also point directly at such a directory. Only directories that actually carry a specifications/ index are returned.



84
85
86
87
88
89
90
# File 'lib/rigor/plugin/isolation.rb', line 84

def bundle_gem_dirs(bundle_root)
  return [] if bundle_root.nil? || bundle_root.to_s.empty?

  root = bundle_root.to_s
  (Dir.glob(File.join(root, "ruby", "*")) + [root])
    .select { |dir| File.directory?(File.join(dir, "specifications")) }
end

.bundle_require_paths(bundle_root) ⇒ Object

Every bundle gem's require paths, newest version per gem name. Loading a specifications/*.gemspec evaluates RubyGems-generated metadata of an installed third-party gem — the same trust level as requiring the gem, which is what the caller is about to do.



72
73
74
75
76
77
78
79
# File 'lib/rigor/plugin/isolation.rb', line 72

def bundle_require_paths(bundle_root)
  bundle_gem_dirs(bundle_root).flat_map do |gem_home|
    specs = Dir.glob(File.join(gem_home, "specifications", "*.gemspec"))
               .filter_map { |file| Gem::Specification.load(file) }
    specs.group_by(&:name)
         .flat_map { |_, versions| versions.max_by(&:version).full_require_paths }
  end
end

.call(feature:, receiver:, method:, args:) ⇒ Object

Invokes receiver.method(*args) on a target library, requiring feature first, under the configured isolation strategy. receiver is a constant name (String), method a Symbol from the caller's allow-list, and args simple, Marshal-able / inspectable values (Strings) — never free input. Returns the result, or raises Unavailable.



109
110
111
# File 'lib/rigor/plugin/isolation.rb', line 109

def call(feature:, receiver:, method:, args:)
  backend.call(feature: feature, receiver: receiver, method: method, args: args)
end

.require_with_target_bundle(feature, bundle_root) ⇒ Object

Requires feature, falling back to the analyzed project's bundler install tree. The fallback appends every bundle gem's full_require_paths (from its RubyGems-generated specifications/ gemspec — the gem's own metadata, so nonstandard require paths like concurrent-ruby's lib/concurrent-ruby resolve correctly) to $LOAD_PATH and retries. $LOAD_PATH is appended, never prepended, and only on a failed require: Rigor's own activated gems keep precedence, and a host environment that carries the gem never consults the bundle. (Gem.paths augmentation was rejected — Bundler-locked processes silently ignore it.) Under the default process strategy the mutation happens inside the forked worker only; under none/Direct it lands in the main space, which is what that strategy means (documented tradeoff — the invoked library is trusted + pure).



59
60
61
62
63
64
65
66
67
# File 'lib/rigor/plugin/isolation.rb', line 59

def require_with_target_bundle(feature, bundle_root)
  require feature
rescue ::LoadError
  added = bundle_require_paths(bundle_root) - $LOAD_PATH
  raise if added.empty?

  $LOAD_PATH.concat(added)
  require feature
end

.strategy_nameObject

The configured strategy name (RIGOR_PLUGIN_ISOLATION), defaulting to DEFAULT for any unset / unrecognised value.



100
101
102
103
# File 'lib/rigor/plugin/isolation.rb', line 100

def strategy_name
  name = ENV["RIGOR_PLUGIN_ISOLATION"].to_s
  STRATEGIES.include?(name) ? name : DEFAULT
end

.target_bundle_rootObject

The analyzed project's resolved bundler install root (e.g. <project>/vendor/bundle), or nil. Set from the same bundler.* resolution that feeds bundle sig discovery (O4 / ADR-82 WD9) — by the runner's pre-passes BEFORE any plugin #prepare runs, by Environment.for_project (which also covers pool workers), and by rigor plugins' probe. When a target library cannot be required from Rigor's own gem environment (the standalone gem install rigortype case — activesupport is deliberately NOT a runtime dependency), require_with_target_bundle falls back to requiring it from this bundle: the analyzed Rails project always carries its own activesupport on disk, and loading the project's locked copy is the higher-fidelity source of inflection rules anyway (ADR-79).



42
43
44
# File 'lib/rigor/plugin/isolation.rb', line 42

def target_bundle_root
  @target_bundle_root
end

.target_bundle_root=(root) ⇒ Object



46
47
48
# File 'lib/rigor/plugin/isolation.rb', line 46

def target_bundle_root=(root)
  @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s)
end