Repository navigation
v0.1.0 — first tagged release
First tagged release. The module had no tags at all until now — it was consumable only by pseudo-version — so this is an initial v0.x, not a bump. v0.x promises no API compatibility yet, which is the honest signal for an interpreter at this stage.
What it is
A Ruby 4.0 VM and front end in pure Go, CGO_ENABLED=0. One function is exported; everything else lives under internal/, so the compatibility surface is deliberately tiny:
func Run(src string, out io.Writer) errorRequires Go 1.26.4 or later.
Read this before embedding it
Run is not a sandbox. Conformance to MRI is the goal, and MRI is not sandboxed either — so neither is this. Through that one function, Ruby source can spawn /bin/sh, read and write the filesystem as the host's uid, open sockets, require any absolute path, and change the host process's ENV and working directory. Do not pass untrusted Ruby to it.
Stated here rather than left to be discovered, because MRI is normally a CLI a person invokes on their own code, whereas Run is a function someone links into a server. The README's Run is not a sandbox section carries the measurements, and SECURITY.md says which of these are defects and which are intended.
Three contract details worth knowing before you build on it, all measured and now pinned by tests:
- Every call is a fresh VM, but the same process. The Ruby heap is not shared between calls;
ENV, the working directory, open files and signal handlers are. A script that setsENV["PATH"]changes it for your program, and it stays changed afterRunreturns. - A program that exits is reported as success.
exit,exit!andabortstop the program andRunreturnsnil, so you cannot currently tell "finished" from "stopped". Only a Ruby exception becomes a Goerror. Kernel#forkexists and does not fork —fork { … }runs the block in the same process, so the "child" mutates the parent.Process.forkcorrectly does not exist.
Fixed in this release
Runaway recursion no longer kills the host process. Before this, a three-line Ruby program overflowed the Go stack — a fatal runtime error, not a panic, so recover() could not contain it and a host embedding the interpreter lost its process. There was no call-depth limit anywhere and no SystemStackError class at all.
SystemStackError is now raised with MRI's message, in MRI's position in the hierarchy (superclass is Exception, so a bare rescue does not catch it — as in MRI). Verified against MRI 4.0.5 across eight call shapes — plain send, block/yield, send, lambda, define_method, method_missing, mutual recursion, proc-in-block — all raising, none fatal, with the depth unwinding correctly through raise, block return, throw/catch and break. The ceiling is 16,383 Ruby frames, deliberately above MRI's deepest here, so no program that recurses legitimately on MRI meets rbgo's limit first.
This is not yet a security boundary, and the gap is measured rather than guessed. rbgo's limit counts Ruby frames; MRI's counts bytes. A program can put native dispatch layers between two Ruby frames — each costs Go stack and pushes no Ruby frame — so a frame count cannot bound the stack. Measured on this release, with send(:send, …, :f, x+1):
| native dispatch layers per Ruby frame | result |
|---|---|
| 1, 8, 32 | SystemStackError at depth 16382, exit 0 |
| 34, 36, 38, 40, 48, 56, 60, 64, 128, 256 | fatal error: stack overflow, exit 2 |
So 33 layers is enough to get back to killing the host. MRI runs the 64-layer program cleanly (SystemStackError at depth 10913, exit 0), because a byte budget sees what a frame count cannot. Closing it needs the native dispatch nesting counted too, which touches the hot send path and therefore wants a benchmark before it lands — tracked on #768. In practice this release takes the trigger from three lines of ordinary Ruby to a deliberately constructed 33-deep dispatch chain, which is a large improvement and not a closed door.
Measured conformance
23,481 ruby/spec examples pass, over 2,202 of the 2,206 spec files in language/ + core/ (4 produce no result). The number is not a self-report: it is the sum of scripts/conformance/rubyspec/BASELINE, the per-file record CI enforces on every pull request. No file may pass fewer examples than its row or stop loading, so the figure moves in one direction only — and a run that gains examples overall still fails if any single file drops. One did during this cycle: +22 across fifteen files with one down by 1, which a scalar floor would have waved through.
The README's badge and prose are checked against that file by a test, so they cannot drift from it again.
Read 23,481 as a floor rather than a ceiling: BASELINE has not been regenerated since the SystemStackError fix landed, and CI's ratchet reported that change alone unblocking about 20 further examples across 12 files — the shape expected when files that used to kill the process stop doing so. Regenerating the record is a separate, deliberate step, so the gated number lags the measured one by design.
What CI gates
test on macOS, Ubuntu and Windows; arch on amd64 and arm64 native runners; wasm for GOOS=wasip1 (built and run under wazero, a pure-Go runtime) and for GOOS=js; the ruby/spec per-file ratchet; and an rbgo build integration lane. Coverage is gated per function rather than as a rounded total — one function dropping below 100 % fails the lane and is named. The gate runs on linux and macOS; Windows is excluded because a few POSIX-only paths are unreachable there.
Known limits, stated rather than hidden
- Only
language/andcore/are swept. ruby/spec'slibrary/andoptional/suites are not, so this is not a percentage of Ruby. - The conformance table in the README is a dated full sweep (2026-09-28) whose non-passing rows are not derivable from the baseline; the badge tracks the baseline, which moves with every merge. The README says which is which.
- Front-end conformance figures for Rails/Puppet date from 2026-06-27 and carry that date in the README; they have not been re-measured since.
- Database, search, KV and heavy-network gem backends are compiled out on wasm and degrade to a clean error at
require.
Open security issues at the time of this tag, so they are not discovered as surprises. All are filed with measurements and reproductions:
| go-ruby-parser/parser#48 | deeply nested source still overflows the Go stack fatally, in the parser — 140 KB is enough, and Run parses before it executes, so the fix above does not cover it |
| #771 | Net::HTTP to a server in the program's own Thread hangs without bound; MRI does it in 81 ms |
| #773 | Run returns nil for exit/exit!/abort |
| #775 | YAML.safe_load instantiates arbitrary classes; permitted_classes: [] permits everything |
| #776 | Regexp.timeout= is stored but never applied, and a fired limit reads as "no match" |
| #777 | Array#*/String#* reach make() with a rejected size |
🤖 Generated with Claude Code