Debuggability of Spinel programs
June 18, 2026 · View on GitHub
Prerequisite: 00-architecture-constraints.md. The conclusions here are consequences of those facts.
Debugging a Spinel program splits cleanly into two activities that want completely different tools:
- Debugging the Ruby semantics — "is my logic right?" Do this under CRuby.
- Debugging the binary — "what is the compiled program actually doing?" Do this with a native debugger.
Conflating them is what makes "can I use byebug?" feel like a hard question. It isn't, once you split it.
byebug / pry: structurally impossible against the binary
byebug and pry are CRuby-VM artifacts:
- byebug is a C extension that hooks
TracePoint/ the VM's line-event machinery. Spinel has no VM and noTracePoint. binding.pryneeds a liveBinding(a mutable local-variable table) plusevalto run arbitrary expressions in that frame. Spinel has noBindingand noeval(constraint 3), and locals are native C variables that may be in registers or optimized away entirely.- You cannot even load them: no
dlopen, no C-ext loading (constraint 6).
A live REPL into a Spinel binary contradicts the AOT/closed-world model and is out of scope permanently — not a missing feature, a category error. Don't chase it.
The cheap, correct answer: debug the same .rb under CRuby
The compiled subset is a subset of real Ruby. For everything except ffi_func
calls, the program runs identically under ruby, where byebug / pry /
debug.gem / ruby-lsp work at full fidelity. The only shim needed is defining
the ffi_func module methods in plain Ruby (which tep already does for its
batteries).
This is not a workaround — it's already the ecosystem's posture:
spinelgemsships averifiedrung: a differential run that executes a behaviour smoke under both CRuby and a Spinel-compiled harness and compares. That's CRuby-as-oracle, formalized.tepexists partly to "exercise Spinel against real Ruby; reduce bugs to minimal repros." Same idea: CRuby is where you understand the program; Spinel is where you ship it.
So the highest-value debugging story requires zero new code and is the default recommendation.
What works today, for free (source-level tooling)
A Spinel program is Ruby source parsed by Prism — the same parser ruby-lsp uses. So all static, source-level tooling already works:
- ruby-lsp — go-to-definition, completion, hover, formatting, symbols.
(
spinelgemsalready has a.ruby-lspdir; it's in use.) - RBS / Steep / Sorbet — type checking. Spinel even reads RBS to seed inference, so the signatures you write for the type checker double as compiler hints.
rubocop_spinel(gurgeous) — author-time cops that flag Spinel-unsupported Ruby (class << self,Thread.new, …) as you type. This is the static-risk signalspinelgems' probe also wants to consume.
The "auto LSP" you asked about partly already exists — it's just generic ruby-lsp. The interesting part is making it Spinel-aware (below).
What's cheap to build (binary-side + Spinel-aware), ranked by leverage
1. #line directives → step through Ruby source in gdb/lldb ✓ SHIPPED
This shipped and is no longer a deliverable. Spinel stamps #line N "app.rb"
before each statement by default (--line-map; opt out --no-line-map),
resting on the Prism node locations the analyzer carries (constraint 5). The C
toolchain then produces DWARF that maps to Ruby source lines. Combined with
the existing sp_<name> / lv_<name> naming (constraint 4), --debug + the
default-on line map let gdb/lldb:
- break by Ruby line (
break app.rb:42), print lv_cto inspect a Ruby local,- show native backtraces of compiled frames,
- watch, step, reverse-debug (rr), Time-Travel.
--debug builds -g -O0 and disables Spinel's own static inline promotion
for faithful stepping (-g alone adds debug info without forcing -O0); at
-O2 the C compiler reorders/inlines and DWARF gets lossy, which is why
--debug drops the optimization.
2. Opt-in shadow call stack → restore backtrace / caller
Under --debug, push/pop {file, line, method} onto a thread-local array at
call entry/exit, and wire Exception#backtrace / caller to read it instead of
returning the empty sp_StrArray they return today (constraint 2). Gate it
behind the debug build because it adds per-call overhead. Medium effort;
restores the most-missed Ruby debugging affordance and makes exception output
actually useful.
3. Export inference results as RBS ✓ SHIPPED
The analyzer computes per-node inferred types and already reads RBS (constraint
8). Run backwards, this now ships: --emit-rbs writes sig/*.rbs for the
whole program, and --emit-types writes per-position inferred types plus
degrade diagnostics as JSON. Benefits, now realized:
- feeds Steep / ruby-lsp / Sorbet with ground-truth signatures,
- doubles as a miscompile diagnostic — you can see where a param widened
to
poly(the slow path) or where a type came out wrong, - closes the loop with constraint 8's existing RBS-in path.
4. Spinel-aware LSP addon — the "auto LSP" worth wanting
Not generated from nothing; the hard part (whole-program inference, serialized to the IR's per-node type cache) is done. A thin ruby-lsp addon that reads that cache can surface, on hover / as diagnostics:
- "Spinel infers
int_arrayhere", - "this widened to
poly— slow path; here's why", - "this class can't be value-typed because
" (loses the stack-alloc win), - "this call degrades to a no-op / can't be resolved" — the scariest case.
That last one matters most. spinelgems' architecture doc names the central
danger explicitly: silent miscompiles — where "it compiled" ≠ "it works".
The "emitting 0" framing is the legacy behavior; the C compiler now either
hard-errors (spinel: unsupported ..., no C emitted) or silently lowers an
unresolved dynamic-receiver call (or .new on an unresolved constant) to
nil/0. That residual silent path is surfaced by SPINEL_WARN_UNRESOLVED
(file:line per site), so "no warning fires" is no longer unconditional. A static
linter (rubocop_spinel) catches some of this at author time, but only the
compiler's own inference knows when a specific call site degraded. Surfacing
that in the editor is a uniquely-Spinel tool and mostly plumbing over existing
data.
This is the most interesting thing to build after #line.
What can't work (don't attempt)
- Live REPL /
binding.pryinto the binary (needs VM +eval+ live retyping). - Full
TracePoint/set_trace_funcemulation (needs the VM event model). - Generic reflective inspection of arbitrary live objects (no uniform object header — constraint 1).
Lean on the CRuby dual-run for all of these.
Recommended sequencing
Two of the original items have shipped upstream; the ranking below covers the remaining net-new work:
- ✓ SHIPPED:
#line+--debugmode (native debugger steps through Ruby). - ✓ SHIPPED: RBS export from inference (
--emit-rbs;--emit-typesJSON), feeding external type checkers.
Remaining, re-ranked:
- Spinel-aware ruby-lsp addon surfacing inferred types + degrade warnings,
reading the
--emit-typesJSON (which already carries the degrade diagnostics). - Opt-in shadow call stack for
backtrace/caller— note nativeException#backtrace+Kernel#callerare now wired upstream (#1300), so this is largely subsumed.
(2)'s compiler-side piece landed in matz/spinel; (1) can live as a standalone
tool, and arguably belongs in spinelgems' orbit since it already speaks RBS
and ledgers.
Honest note
spinelgems is organized around the premise that the dangerous failure mode is
the silent one. That's the strongest argument for prioritizing inference-export
/ Spinel-aware-LSP work over a fancier runtime debugger: the bugs that hurt
aren't crashes you can catch in gdb, they're correct-looking binaries that
quietly do the wrong thing. The conclusion holds — and is now realized by the
differential/migration layer on top of upstream's first-party tools: doctor's
SPINEL_WARN_UNRESOLVED scan, --emit-types degrade diagnostics, and
value-bisection all make the compiler's analysis visible. A runtime debugger
doesn't.