Class: Bundler::Spinel::Probe
- Inherits:
-
Object
- Object
- Bundler::Spinel::Probe
- Defined in:
- lib/bundler/spinel/probe.rb
Overview
Decides a compatibility verdict for an unpacked gem against the current Spinel engine. Two complementary signals, because Spinel’s failure modes are not exit codes:
1. COMPILE SIGNAL. Spinel never exits non-zero on unsupported Ruby; it
emits `warning: ... cannot resolve call to 'X' ... (emitting 0)` and
degrades the call to a no-op. So we compile the gem's lib entrypoints
with `spinel -c` and parse stderr. An unresolved-call warning names
the exact missing feature (`unresolved:eval`), which is what makes the
verdict forward-compatible — re-probing under a newer engine clears it
the moment Spinel learns that call.
2. STATIC RISK SIGNAL. Some constructs (define_method, instance_eval, a
bare `send`) are degraded with *no* warning at all, and dead-code
elimination can hide an unsupported call that's defined-but-uncalled
in a library. So we also scan the source for known-risky tokens. These
don't reject on their own (the gem may never hit that path) but they
downgrade `clean` → `risky`.
NEITHER signal catches Spinel’s *silent miscompiles* (local-var-name collapse, Int-0-as-nil). Only the ‘verified` rung — running the gem’s own tests through a Spinel-compiled harness — does. The lock-time gate trusts ‘clean`; only a curated whitelist / platform variant trusts `verified`.
Constant Summary collapse
- UNRESOLVED_CALL =
An unsupported call — the strongest, most precise signal, and the one that’s forward-compatible (names the exact feature Spinel lacks today).
/cannot resolve call to '([^']+)'/.freeze
- UNRESOLVED_REQUIRE =
A ‘require “x”` Spinel couldn’t follow. Spinel has no load path (plain require resolves only against <spinel>/lib), so a gem’s own split files and stdlib deps surface here. Informational, NOT a standalone reject: it’s as often a probe limitation as a real incompatibility.
/require "([^"]+)" could not be resolved/.freeze
- REQUIRE_CALL_FAIL =
Since the #1383 fix, an unresolvable plain ‘require` no longer warns + continues (exit 0) — it’s emitted as an unsupported CallNode and the compile EXITS NON-ZERO. On the whole 189k corpus that turned ~thousands of previously-‘clean` gems (the universal `require “gem/version”` idiom) into spurious `analyze-failed`. We detect this exact form so a require-ONLY compile failure is classified as the no-load-path limitation, not a codegen failure. (b60fbd7; see harness/findings.)
/unsupported call:.*CallNode `require`/.freeze
- HARD_COMPILE_ERROR =
A genuine codegen/compile failure — the C compiler choked, the analyzer died, or an unsupported construct OTHER than a plain require. Presence of any of these means the failure is real, not just the load-path limit.
%r{ out\.c:\d+.*\berror: | # gcc error on emitted C \bfatal\b | \bSegmentation\ fault\b | \banalyze\ failed\b | unsupported\ puts\ argument | # an unsupported non-require construct unsupported\ call:\ (?!.*CallNode\ `require`) # any non-require unsupported call }ix.freeze
- ANALYZE_FAILED =
/\b(analyze failed|fatal)\b/i.freeze
- COMPILE_TIMEOUT =
Spinel’s analyze pass can spin for minutes on pathological inputs (no internal bound). In a wholesale survey that’s indistinguishable from a hang, so we cap each compile and treat an overrun as its own reject reason — ‘analyze-timeout` is itself a useful roadmap signal (which gems blow up the analyzer). Override with SPINEL_COMPILE_TIMEOUT (seconds).
Integer(ENV.fetch("SPINEL_COMPILE_TIMEOUT", "60"))
- HARD_REJECT_TOKENS =
Tokens whose mere presence in ‘lib/*/.rb` makes the gem a definite Spinel reject — there’s no path under which the compile would succeed, so we skip the (expensive) spinel call entirely and record a ‘rejected` verdict from the static scan alone. Conservative set: only constructs Spinel will never support (threads, Mutex, TracePoint). Metaprogramming tokens like `define_method` stay in RISK_TOKENS below — they degrade silently, so the compile signal is still the right call there. Constructs that put a gem outside the AOT closed-world model entirely —rejected from a static scan, before a compile is even attempted. Thread/Mutex lived here until matz/spinel#1360 made them run (single- threaded, carrying the block’s value): they’re now compiled + flagged ‘risky` (below), not hard-rejected. TracePoint/set_trace_func stay —there is no degenerate-but-correct lowering for runtime tracing.
{ /\bTracePoint\b/ => "TracePoint", /\bset_trace_func\b/ => "set_trace_func" }.freeze
- RISK_TOKENS =
token => reason. Tokens Spinel cannot honour and may silently no-op.
{ # Thread/Mutex run single-threaded since matz/spinel#1360 — correct for # defensive use (a mutex guarding state, Thread.new for a value), but # degenerate for genuine concurrency: compiles, flagged, fails # `check --strict`. (Demoted from HARD_REJECT after #1360.) /\bThread\.(new|start|fork)\b/ => "thread", /\bMutex\.new\b/ => "mutex", /\bMutex_m\b/ => "mutex_m", /\beval\s*\(/ => "eval", /\binstance_eval\b/ => "instance_eval", /\b(class|module)_eval\b/ => "class_eval", /\bdefine_method\b/ => "define_method", /\bmethod_missing\b/ => "method_missing", /\brespond_to_missing\?/ => "respond_to_missing", /\bconst_missing\b/ => "const_missing", /\.send\s*\(/ => "send", /\bpublic_send\b/ => "public_send", /\bObjectSpace\b/ => "objectspace", /\b(TracePoint|set_trace_func)\b/ => "tracepoint", /\bbinding\b/ => "binding" }.freeze
Instance Method Summary collapse
-
#initialize(engine, ledger) ⇒ Probe
constructor
A new instance of Probe.
-
#probe(gem_name, version, dir) ⇒ Object
gem_name, version, dir(unpacked) -> recorded Ledger::Verdict.
Constructor Details
#initialize(engine, ledger) ⇒ Probe
Returns a new instance of Probe.
109 110 111 112 |
# File 'lib/bundler/spinel/probe.rb', line 109 def initialize(engine, ledger) @engine = engine @ledger = ledger end |
Instance Method Details
#probe(gem_name, version, dir) ⇒ Object
gem_name, version, dir(unpacked) -> recorded Ledger::Verdict
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 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/bundler/spinel/probe.rb', line 115 def probe(gem_name, version, dir) @engine.ensure! # Fail fast on cheap static signals before spending the spinel-compile # budget — no entrypoint, a C extension, or a top-level token Spinel # will never support → record `rejected` and skip the compile. if (fast = static_hard_reject(dir, gem_name)) return @ledger.record(@ledger.build( gem: gem_name, version: version, rev: @engine.rev, verdict: "rejected", reasons: fast, risks: [], probe: "static" )) end sig = compile_signal(dir, gem_name) risks = static_signal(dir) # Unfollowed requires are notes, not rejections — record them so a human # can see when a verdict is entangled with the no-load-path limitation. risks += sig[:requires].map { |r| "needs:#{r}" } verdict, reasons = if !sig[:calls].empty? # Genuine unsupported call(s): unambiguous, forward-compatible reject. ["rejected", sig[:calls].map { |s| "unresolved:#{s}" }] elsif sig[:timed_out] # Analyzer ran past the cap — pathological for Spinel, not the gem. ["rejected", ["analyze-timeout"]] elsif sig[:require_only_fail] # Compile failed solely on an unresolvable plain `require` (b60fbd7 # hard-fails these where cb23cc6 warned + continued). That's the # no-load-path limitation, not a codegen failure — a real Spinel # project vendors deps so the require resolves. Classify as the # load-path limit (risky), keeping the needs: notes below. ["risky", ["load-path:require"]] elsif sig[:analyze_failed] || !sig[:exit_ok] ["rejected", ["analyze-failed"]] elsif !static_only_risks(risks).empty? ["risky", []] else ["clean", []] end @ledger.record(@ledger.build( gem: gem_name, version: version, rev: @engine.rev, verdict: verdict, reasons: reasons.uniq, risks: risks.uniq, probe: "compile+scan" )) end |