Repository navigation
fix(vm): raise SystemStackError instead of killing the host process (#768) - #769
Conversation
…768) A Ruby program with runaway recursion took the whole host process down with it. The first line of output was runtime: goroutine stack exceeds 1000000000-byte limit followed by 50772 bytes of goroutine dump and exit status 2, where MRI 4.0.5 prints a rescued SystemStackError and exits 0. This is not only a conformance gap. A Go stack overflow is a FATAL RUNTIME ERROR, not a panic: a deferred recover() never runs, so an embedding host cannot contain it. ruby.Run is the documented embedding API, so three lines of untrusted Ruby could end the host process. Two parts: * SystemStackError, under Exception and NOT StandardError, so a bare `rescue` does not catch it. MRI sets it up that way in Init_Proc (proc.c v3_4_1:4424) and registers "stack level too deep" on the line after; the message here is copied, not invented. Verified against the MRI 4.0.5 on this machine: SystemStackError.ancestors is [SystemStackError, Exception, Object, Kernel, BasicObject]. * A call-depth limit in exec, which is the ONE place a Ruby-level frame is created, so one check covers methods, blocks, procs, lambdas, define_method bodies, class and module bodies and eval. The depth is len(vm.frameNames) rather than a counter of its own, and that is the point: a counter would need decrementing on each of exec's exits, and the ones that unwind by panic never run exec's tail. frameNames is already restored on every unwind path, and restored by TRUNCATING TO A SAVED DEPTH rather than by counting pops, so the depth cannot be left inflated by a rescued runaway recursion and cannot poison a later one. raiseDeep needs no Ruby frame of its own, which is what lets it be called from the place that has just refused to create one: exceptionObject builds the exception as a Go *RObject and never dispatches #initialize, and rbgo runs rescue/ensure bodies as instructions inside the frame that owns them. MRI needs a preallocated instance for exactly the same reason. The constant is a placeholder in this commit; the measurement that chooses it, and the standing test that re-derives it, follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sendCatchBreak recovers a breakSignal and returns its value, but it
never restored the per-frame tracking stacks. The block's exec frame —
and every frame between it and the catch — unwound by panic and so
never ran exec's tail that pops them, leaving
[1, 2, 3].each { |x| break if x == 2 }
one frameNames entry heavier than it started, permanently.
This was invisible while those stacks only fed backtraces and
Kernel#caller: the next unwind CAUGHT anywhere truncates to its own
saved depth and swallows the stale entry with it, so the leak only
survives when nothing deeper is caught afterwards. No backtrace test
ever stood in that position.
It stopped being invisible the moment len(frameNames) became the
call-depth limit: leaked depth is budget that never comes back, so a
long-running program breaking out of a loop a few tens of thousands of
times would raise SystemStackError with no recursion in sight. The
test that found it measures the depth a runaway recursion reaches,
twice, with the non-local exit in between — 197 then 196 is the leak,
197 then 197 is the fix.
The restoration is the same six depths Kernel#catch and findYield keep
(find.go), for the same reason. frameCrefs and frameMethods mirror
frameNames one for one and are restored WITH it, or index i stops
meaning the same frame in all three (#649).
TestDepthDoesNotLeakOnAnyNonLocalExit covers break, next, a block
`return`, throw/catch, an ensure crossed by a raise, and the deep
variants of each; TestASecondRunawayRecursionStillRaises states the
consequence a user would notice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28 recursion paths bisected in subprocesses — binary-searching the
largest limit each path can be given before the Go runtime kills the
process, under a reduced debug.SetMaxStack — to get BYTES OF GO STACK
PER RUBY FRAME, which is the portable quantity. Worst and best, on
darwin/arm64 with Go 1.27.1:
send(:public_send, :send, :f, n + 1) 8132 B/frame WORST
catch(:x) { f(n + 1) } 6531 B/frame
1.times { f(n + 1) } 6228 B/frame
f(n + 1) 5619 B/frame best
Measuring the cheapest path would have overstated the margin by 45%.
The figure is stable across stack sizes — 6452.8, 6450.3, 6448.4,
6448.1 B/frame at SetMaxStack of 8, 16, 64 and 256 MiB — so it is a
property of the call path, not of the measurement.
THE DEPTH IT IMPLIES IS NOT 1 GB DIVIDED BY IT, and that error would
have been a factor of two. Go's default maximum is 1,000,000,000 bytes
(decimal) and goroutine stacks are powers of two, so the largest stack
actually permitted is 2^29 = 512 MiB; 2^30 exceeds the default. The
worst path's overflow depth is 512 MiB / 8132 = 66016, and bisecting
it directly at Go's real default with no SetMaxStack measured 66051 —
0.05% apart, which is what makes the model quotable rather than a
story about one number.
16384 is 4.03x below that 66051 and 1.50x above MRI's deepest reachable
depth (10919 frames of `def f(n); $d = n; f(n + 1); end` on the MRI
4.0.5 on this machine; only 3853 levels through a block, because MRI's
budget is in BYTES and so its depth is not one number). Above MRI means
no program that works there raises here; the asymmetric margin below is
because overshooting is fatal and unrecoverable while undershooting
raises an exception the program already had to handle.
TestStackDepthLimitSitsBelowTheGoOverflow re-derives the whole thing on
every run and fails under a 2x margin.
KNOWN INCOMPLETE, and measured rather than assumed: a frame count
cannot bound the Go stack on its own, because a program can put an
unbounded number of NATIVE dispatch layers between two Ruby frames,
and a native layer costs Go stack while pushing no frame. Each extra
Kernel#send layer costs 833 B:
sends per Ruby frame 1 2 4 8 16 32
bytes per frame 7282 8117 9777 13107 19761 33091
overflow depth 73728 66144 54912 40960 27168 16224
so ~25 nested sends per frame bring the overflow depth below this
limit, and NO limit above MRI's 10919 can cover an unbounded chain.
Closing it needs a count of native dispatch nesting too, which is a
change to the hot dispatch path (vm.send) and a scope decision of its
own rather than a line added here. Recorded in stackdepth.go and on
issue #768 rather than left for the next person to rediscover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shape the design could plausibly have got wrong. rbgo keeps ONE frame stack for the whole VM, shared by every Ruby thread under the GVL, and Run resets it to [:0] at its boundaries — so a thread body could have been handed a FRESH depth budget while its parent's Go frames were still live, letting a program amplify its reachable depth by one limit per thread it spawns. Measured with the real constant rather than reasoned about: plain recursion reaches 16382 and the thread-nested shape reaches 8191, exactly half, because each level costs two entries (the method frame and the block frame). The shared stack keeps accumulating across the boundary instead of restarting. That is conservative — each Ruby thread is a goroutine with its own Go stack, so the budget is stricter than the stacks require — and conservative is the safe direction for a limit whose job is to stay below a fatal error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A VM is not always built through New: internal tests assemble a &VM{}
literal and bootstrap it themselves (bareVM in prelude_test.go). Those
VMs got maxCallDepth 0, and because exec's check is `>=`, a ZERO LIMIT
REFUSES EVERY FRAME rather than none. The full suite said so:
--- FAIL: TestLoadSourceRuntimeError
got SystemStackError: stack level too deep, want NoMethodError
--- FAIL: TestLoadSourceSuccess
unexpected error: SystemStackError: stack level too deep
`x = 1 + 1` died on its first call. Confirmed as a regression from this
branch and not a pre-existing failure by running ./internal/vm on
origin/main in a separate checkout: it passes there.
bootstrap is the one gate every usable VM passes through — a VM that
has not been bootstrapped has no classes and cannot run anything — so
the assignment belongs there, unconditionally, with the constructor's
copy removed so there is one source of truth and nothing to drift. A
test that wants a lower limit sets it after construction, which is
after bootstrap has run.
TestEveryBootstrappedVMHasALimit now covers the literal-plus-bootstrap
path and not only the constructor, and says why zero is the dangerous
value in both directions: `>=` reads it as "refuse everything", and a
check written the other way round would read it as "no limit at all",
which is silently the original defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent verification, from the session that filed #768I rebuilt this branch ( 1. The issue's probe, byte for byte. Identical output and identical exit status: 2. 3. The counter does not leak on any non-local exit. This is the part I was most worried about, so each unwind path gets 300 frames and is then followed by a bounded recursion that must still reach 400: Byte-identical to MRI, and the trailing 4. The limit is above MRI's, which is the safe direction.
So no program that recurses legitimately on MRI can hit rbgo's ceiling first. Worth stating explicitly because the opposite choice — a limit tuned tightly to the Go stack — would have turned an availability fix into a conformance regression, and that trade is invisible unless someone measures the depth. 5. Every call shape raises, not just the cheap one. This is the check that matters most, since Go stack bytes per Ruby frame differ by path and the limit has to hold on the worst one. Eight shapes, all No shape I could construct produced a Still out of scope here, deliberately, so it is not mistaken for a gap in this PR:
Both are tracked on #768. 🤖 Generated with Claude Code |
What this fixes
A Ruby program with runaway recursion took the whole host process down with it.
SystemStackError rescued: SystemStackError/still alivemainruntime: goroutine stack exceeds 1000000000-byte limitSystemStackError rescued: SystemStackError/still alivestdout is byte-identical to MRI's (
cmpclean), stderr is empty on both sides, exit status matches. The run takes 1.46 s.This is a security finding and not only a conformance gap: a Go stack overflow is a fatal runtime error, not a panic, so a deferred
recover()never runs and an embedding host cannot contain it.ruby.Run(src, out)is the documented embedding API, so three lines of untrusted Ruby could end the host process. See #768, where therecover()anddebug.SetMaxStackcontrols are written up.Before this change,
grep -rn 'SystemStackError|stack level too deep|maxDepth|callDepth' internal/vmhad no matches,p defined?(SystemStackError)printednil, andSystemStackError.ancestorsraisedNameError.SystemStackError.superclassisExceptionSet by MRI in
Init_Proc:proc.c:4424–4425at tagv3_4_1— note:v4_0_5andv4_0_0do not exist ingithub.com/ruby/ruby(both 404), so the citation is from the newest tag that is published upstream. The facts it asserts are confirmed first-hand against the MRI 4.0.5 installed here:No
StandardErroranywhere in it, so a barerescuemust not catch it, andTestBareRescueDoesNotCatchSystemStackErrorasserts that. The message is copied fromproc.c:4425, not invented.The limit: measured, not chosen by taste
28 recursion paths were bisected in subprocesses — binary-searching the largest limit each path can be given before the Go runtime kills the process, under a reduced
debug.SetMaxStack— which yields bytes of Go stack per Ruby frame, the portable quantity. On darwin/arm64, Go 1.27.1:send(:public_send, :send, :f, n + 1)catch(:x) { f(n + 1) }1.times { f(n + 1) }/[1].each { }[1].sort_by { }/{1=>2}.each { }1.instance_exec { }[1].inject(0) { }/flat_map/zipdefine_method(:f) { }f(n + 1)(plain send)Measuring the cheapest path would have overstated the margin by 45%. The 20 paths not listed (
Struct,method_missing,superchains,Method#call, symbol-to-proc, operator dispatch,to_sthrough interpolation,select/min_by/sum/each_slice/each_with_object/group_by, nested blocks,catch) all fall between 5619 and 6531.Bytes-per-frame is stable across stack sizes — 6452.8, 6450.3, 6448.4, 6448.1 B/frame at
SetMaxStackof 8, 16, 64 and 256 MiB — so it is a property of rbgo's call path, not of the measurement.The depth is not 1 GB divided by it
Getting this wrong would have been a factor of two, and my first extrapolation did get it wrong. Go's default maximum is 1,000,000,000 bytes (decimal) and goroutine stacks are powers of two, so the largest stack actually permitted is
2^29= 536,870,912 bytes — 512 MiB, because2^30= 1,073,741,824 exceeds the default.SetMaxStackat all: 660510.05% apart. A second path cross-checks it:
catch_blockpredicted 82208, measured 81442 (0.9%). A naive1<<30 / bpfwould have said 166521 — and a limit chosen under that belief would not have been safe.Chosen: 16384
MRI's budget is in bytes, so its depth is not one number. Against the MRI 4.0.5 here: 10919 frames of
def f(n); $d = n; f(n + 1); end, but only 3853 levels of the same recursion through a block. 10919 is the figure that binds: a limit below MRI's deepest would make rbgo raise where MRI does not, turning a security fix into a conformance regression.TestDefaultCallDepthLimitLeavesRoomForMRIProgrammesguards from that side;TestStackDepthLimitSitsBelowTheGoOverflowre-derives the whole measurement on every run and fails under a 2x margin.The margin is deliberately asymmetric: overshooting is fatal and unrecoverable, undershooting raises an exception the program already had to handle. It also has to absorb a figure measured on one platform and toolchain.
Why
len(vm.frameNames)and not a counterexecis the one place a Ruby-level frame is created — methods, blocks, procs, lambdas,define_methodbodies, class and module bodies,evalandinstance_evalall arrive there — so one check covers every way a Ruby program can recurse.TestEveryKindOfRubyFrameIsCounteddrives 14 different dispatch paths into it.The depth is
len(vm.frameNames)rather than a counter of its own, and that is the whole reason it cannot leak. A counter would have to be decremented on each ofexec's exits, and the ones that unwind by panic never runexec's tail.frameNamesalready solves that, and solves it by truncating to a saved depth rather than counting pops: whoever catches an unwind resets the stack to its own depth, discarding every abandoned frame's entry in one assignment. That makes the depth self-healing — it cannot be left inflated by a rescued runaway recursion, so it cannot poison a later one.raiseDeepneeds no Ruby frame of its own, which is what lets it be called from the place that has just refused to create one:exceptionObjectbuilds the exception as a Go*RObjectand never dispatches#initialize, and rbgo runsrescue/ensurebodies as instructions inside the frame that owns them rather than as new frames. MRI needs a preallocated instance (special_exceptions[ruby_error_sysstack],vm_insnhelper.c:61) for exactly the same reason.What the depth does on each non-local exit
exec's tail truncates toframeNamesDepth-1(vm.go:3299)recovertruncates toframeNamesDepth(vm.go:3185)return/nextreturnSignalrecovertruncates toframeNamesDepth-1(vm.go:2124)breakthrow/catchKernel#catchkeeps the same six depths;findYieldlikewise (find.go:146)ensurecrossed by a raiseRunframeNames[:0]at all four boundariesA real pre-existing leak, found by the test this design owes
sendCatchBreakrecovers abreakSignaland returns its value but never restored the per-frame tracking stacks, soleft one
frameNamesentry behind, permanently. That was invisible while those stacks only fed backtraces andKernel#caller: the next unwind caught anywhere truncates to its own saved depth and swallows the stale entry with it, so the leak only survives when nothing deeper is caught afterwards — and no backtrace test ever stood in that position.It stopped being invisible the moment
len(frameNames)became the limit: leaked depth is budget that never comes back, so a long-running program breaking out of a loop a few tens of thousands of times would raiseSystemStackErrorwith no recursion in sight.TestDepthDoesNotLeakOnAnyNonLocalExitis what found it. It drives a runaway recursion to the limit, rescues it, performs a non-local exit, then does it again and compares the depth reached: 197 then 196 is the leak, 197 then 197 is the fix. It coversbreak,next, a blockreturn,throw/catch, anensurecrossed by a raise, and a deep variant of each.TestASecondRunawayRecursionStillRaisesstates the consequence a user would notice, three rounds in a row.frameCrefsandframeMethodsare restored withframeNames, or indexistops meaning the same frame in all three (#649).Known incomplete, and measured rather than assumed
A frame count cannot bound the Go stack on its own. A program can put an unbounded number of native dispatch layers between two Ruby frames, and a native layer costs Go stack while pushing no frame. Each extra
Kernel#sendlayer insend(:send, …, :f, n + 1)costs 833 B:So about 25 nested sends per frame bring the overflow depth below 16384, and no limit above MRI's 10919 can cover an unbounded chain — this is a property of the approach, not of the constant.
Closing it needs a count of native dispatch nesting as well. That is a change to the hot dispatch path (
vm.sendis reached from theOpSendhandler on an inline-cache miss, vm.go:2617) and therefore a scope decision with a benchmark attached, not a line added here. It is recorded instackdepth.goand on #768 rather than left for the next person to rediscover.I also could not use the obvious byte-budget approach MRI uses: Go has no portable way to read the stack pointer, and a stored stack address cannot serve as a baseline because the Go runtime copies a goroutine's stack when it grows, leaving the saved address stale by the relocation delta.
One more shape, checked rather than reasoned about
rbgo keeps one frame stack for the whole VM, shared by every Ruby thread under the GVL, and
Runresets it to[:0]at its boundaries — so a thread body could have been handed a fresh depth budget while its parent's Go frames were still live, letting a program amplify its reachable depth by one limit per thread it spawns. Measured with the real constant: plain recursion reaches 16382, and recursion that spawns and joins aThreadat every level reaches 8191 — exactly half, because each level costs two entries (the method frame and the block frame). The shared stack keeps accumulating across the boundary instead of restarting.That is conservative: each Ruby thread is a goroutine with its own Go stack, so the budget is stricter than the stacks require. Conservative is the safe direction for a limit whose job is to stay below a fatal error.
TestTheLimitHoldsAcrossAThreadBoundarypins it.A second gap this makes reachable, not asked for and not fixed here
The probe in this PR rescues the error, and there rbgo is byte-identical to MRI. Unrescued, it is not — and only because the limit now lets a 16384-deep
SystemStackErrorbacktrace exist at all:The first line is identical (
t3.rb:1:in 'Object#f': stack level too deep (SystemStackError)) and so is the exit status. MRI truncates, and the rule is specific to this one class —Thread::Backtrace.limitdefaults to-1, unlimited, so nothing general is at work (vm_backtrace.c:911). Fromeval_error.c:231–240:giving a head,
\t ... N levels..., and a tail.This is a pre-existing shape, not a new behaviour in kind. At depth 3000 raising a
RuntimeError,mainand MRI both print all 3003 lines — rbgo already printed full backtraces, and MRI does too below its threshold. What changed is that a backtrace this deep is now reachable instead of the process dying. Fixing it means teaching the uncaught-exception printer a class-specific truncation rule with its own tests, which is a separable conformance change rather than a line to add here, so it is left for a follow-up and recorded on #768.One useful by-product: MRI's own marker read
... 10908 levels..., and 10908 + the 13 printed frames corroborates the 10919 figure measured separately — two independent routes to the same number.The regression this branch made, and what caught it
Worth recording, because it is the failure mode of the design and not an accident.
maxCallDepthwas first set in the constructor — but a VM is not always built through one: internal tests assemble a&VM{}literal and bootstrap it by hand (bareVMinprelude_test.go). Those VMs gotmaxCallDepth0, and sinceexec's check is>=, a zero limit refuses every frame rather than none:x = 1 + 1died on its first call. The same two tests failed on thearch arm64check run of the first push, and./internal/vmwas run againstorigin/mainin a separate checkout to confirm it was this branch's regression and not a pre-existing failure —mainpasses.The assignment now lives in
bootstrap, unconditionally, with the constructor's copy removed so there is one source of truth.bootstrapis the one gate every usable VM passes through: a VM that has not been bootstrapped has no classes and cannot run anything.TestEveryBootstrappedVMHasALimitcovers the literal-plus-bootstrap path and not only the constructor, and names why zero is dangerous in both directions —>=reads it as "refuse everything", and a check written the other way round would read it as "no limit at all", which is silently the original defect.Verification
ruby/spec conformance ratchet — OK, nothing went backwards
The authoritative figure, from this PR's own
ruby/spec ratchetcheck run on ubuntu (corpus pinned at87b1631992bd00cf0c4934474766d54dad088191):0 files regressed, 12 improved, +20 examples.
The
language/movers are the shape a depth limit produces: a spec file that previously killed the rbgo process contributed 0 for everything after the crash, so the whole tail of the file comes back at once —keyword_arguments_spec.rb+6 is the clearest case.BASELINEis deliberately not regenerated here: locking in a win is a separate decision, and the sweep that would write it must be the ubuntu one that recorded the current file.A local run disagreed, and the disagreement is instructive. The same sweep on macOS reported +24 across 16 files — the extra four being
core/file/stat/inspect_spec.rb,core/kernel/backtick_spec.rb,core/marshal/load_spec.rbandcore/process/setrlimit_spec.rb, all plausibly platform rather than this change.BASELINEwas recorded on the ubuntu lane, so only the ubuntu measurement is comparable to it; the macOS number is quoted here only to show that the two agree on direction and on everylanguage/file.Also worth knowing: the cached corpus at
/tmp/rubyspec-corpuson the development machine contained zero*_spec.rbfiles while still having thelanguage/andcore/directoriesrun.shprobes for, so a local sweep against it would have measured nothing. The judge does refuse an empty sweep with exit 2, so the guard held — but the local run used a freshly cloned corpus.Per-function coverage ratchet — OK on both records
The ubuntu record passes against the same (macOS) profile too, exit 0, reporting 3 functions that reached 100%. So none of the new code is below 100%.
The rest
GOOS=wasip1 GOARCH=wasm go build ./...— greenGOOS=js GOARCH=wasm go build ./...— green, and thewasm (js, browser)check run passesgofmt -l .— emptygo build -tags rbgo_closed ./internal/... .— green.go build -tags rbgo_closed ./...fails atcmd/rbgo/closed_main.go: undefined: embeddedProgramonmaintoo (verified againstorigin/mainin a separate checkout):embeddedProgramis injected byrbgo build --closedvia-overlay, so a bare build of that package can never link. Not a regression from this branch.CGO_ENABLED=0 go test ./... -timeout=90m— see the threetest (…)check runs🤖 Generated with Claude Code