Repository navigation
vm: p on a self-referential object or Struct killed the process - #852
Conversation
Three lines of ordinary Ruby, measured on `main` (3a3fb81): ```console $ rbgo -e 'class Node; attr_accessor :peer; end a = Node.new; b = Node.new; a.peer = b; b.peer = a p a' runtime: goroutine stack exceeds 1000000000-byte limit fatal error: stack overflow $ echo $? 2 ``` **Two ordinary objects pointing at each other.** A doubly-linked list, a parent with a back-pointer, an ORM association. And a `fatal error: stack overflow` is not a panic: `callNative`'s recover cannot convert it, and nothing else can either — the embedding host dies with the interpreter, which is precisely what that recover exists to prevent ("arbitrary input from an embedding host — the wasm playground REPL, say — can never crash the process", object_model.go:1552). No adversary is required. This is a bug an ordinary program walks into. ## The population the existing guard had cleared `internal/object`'s `ReprEnter`/`ReprLeave` is the right mechanism and was already used by Array, Hash, Set, Data and Enumerator — `TestRecursiveStructureInspect` covers exactly those. `defaultObjectInspect` (builtins.go) and `Struct#to_s`/`#inspect` (struct.go) never reached it. Measured, every case exit 2 on `main` and exit 0 here: | | `main` | this branch | |---|---|---| | `a.peer = b; b.peer = a; p a` | **fatal, exit 2** | `#<Node:0x…08 @peer=#<Node:0x…10 @peer=#<Node:0x…08 ...>>>` | | `class K; def initialize; @me = self; end; end; K.new.inspect` | **fatal** | `#<K:0x…08 @me=#<K:0x…08 ...>>` | | `a.peer = a; p [a]` | **fatal** | `[#<N2:0x…08 @peer=#<N2:0x…08 ...>>]` | | `a.peer = a; p({k: a})` | **fatal** | `{k: #<N3:0x…08 @peer=#<N3:0x…08 ...>>}` | | `S = Struct.new(:x); s = S.new(nil); s.x = s; s.inspect` | **fatal** | `#<struct S x=#<struct S:...>>` | | `…; s.to_s` | **fatal** | same | | `…; s.to_a.inspect` | **fatal** | `[#<struct S x=#<struct S:...>>]` | ⛔ **Wrapping it in an Array or a Hash did not save it**, and that is the part worth noticing: those guard THEIR OWN identity, so the container printed once and the recursion came straight back through the object's inspect. A guard over five types is not a guard over the five types' members. ## The renderings are MRI's, from the two functions that do this upstream Both use `rb_exec_recursive`, and both were read at tag `v4.0.9`: - `object.c`, `inspect_obj`: `if (recur) rb_str_cat2(str, " ...");` then `">"`. So a re-entered object prints `#<Node:0x0000000000000008 ...>`. - `struct.c`, `inspect_struct`: `if (recur) return rb_str_cat2(str, ":...>");`. So a re-entered named struct prints `#<struct S:...>`. ⚠ One deliberate divergence, recorded at the site rather than guessed at: MRI appends the class path on re-entry even for an **anonymous** struct, where it omits it otherwise (`if (recur || first != '#')`), so MRI would print `#<struct #<Class:0x…>:...>`. This keeps rbgo's own naming rule in both cases and prints `#<struct:...>`. The value of the change is not dying; that corner can be matched separately if anyone wants it. ## Tests `TestRecursiveObjectAndStructInspectDoNotKillTheProcess` in `crash_hardening_test.go` — beside the test whose population was too narrow, deliberately, so the two read together. Its last two cases are the **negative control**, and they matter as much as the rest: the guard must CLEAR between siblings, or `p [x, x]` would print the second `x` as `...` and silently corrupt ordinary output. Both an object and a struct are checked that way. `-race` clean on these paths (`ReprEnter` is mutex-guarded and the lock is never held across the recursion, as its own comment states). Full `internal/vm` suite green, 246s, `-count=1`. `gofmt` and `go vet` clean. ## Where it came from Auditing #768 — "no call-depth limit … kills the embedding host with a fatal Go stack overflow" — which is **fixed but still open**: `def f; f; end; f` now raises a rescuable `SystemStackError` and the program survives. I probed 16 other recursion shapes (lambda, proc, send, public_send, method_missing, mutual, block through each/map, define_method, instance_eval, to_proc, Comparable) and the depth guard covers every one of them. What it does not cover is recursion that is **not** Ruby-level calls: the repr walk is Go recursion through `Inspect()`, so no call-depth counter sees it. That is this change. #768 should be closed on the strength of that probe; I have left it open and said so there rather than closing someone else's issue from a side quest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The neighbours were checked, and they are fineA fix to one Go-recursion path invites the question of the others, so I probed
So equality, hashing, flatten and both serialisers already guard themselves — Depth without a cycle is a different vector, and it holds tooAn identity guard does nothing for a structure that is merely very deep — there $ rbgo -e 'a = []; 200000.times { a = [a] }; puts a.inspect.length'
400002
$ rbgo -e 'a = []; 200000.times { a = [a] }; p Marshal.dump(a).bytesize'
400004200,000 levels, both fine — a Go goroutine stack grows on demand toward the 1 GB I am not claiming a bound was proven. I am saying the depth vector was looked 🤖 Generated with Claude Code |
```console $ timeout 6 rbgo -e '(1..).to_set'; echo $? 124 ``` On `main` today. `Enumerable#to_set` enumerated the endless range forever, allocating as it went. **One expression**, from any untrusted Ruby an embedding host evaluates — and unlike the repr recursion fixed in #852 there is nothing to recover from, because nothing faults: no stack overflows, no panic is raised, the process simply never returns. ## How it hid: every other member of the family was already guarded | | on `main` | |---|---| | `(1..).to_a` | `RangeError: cannot convert endless range to an array` | | `(1..).last` | `RangeError: cannot get the last element of endless range` | | `(1..).max` | `RangeError: cannot get the maximum of endless range` | | `(1..).size` | `Infinity` | | **`(1..).to_set`** | **hangs** | `Range#to_set` is **new in Ruby 4.0**, so there was no 3.4-era guard to copy — and its spec sits behind `ruby_version_is "4.0"`, which the gate cannot reach while the shim resolves at 3.4.1 (#799). The version sweep recorded `core/range/to_set_spec.rb` as **"failed to load"**; it had not failed to load, the per-file `TIMEOUT` had killed it. I only found that out by running the file by hand and watching it not finish. ## MRI's own, and the message is asserted character for character range.c at tag v4.0.9: ```c static VALUE range_to_set(int argc, VALUE *argv, VALUE range) { if (NIL_P(RANGE_END(range))) { rb_raise(rb_eRangeError, "cannot convert endless range to a set"); } return rb_call_super(argc, argv); } ``` `core/range/to_set_spec.rb:22` asserts that wording exactly. The check is on `end`, not on either endpoint, so a **beginless** range still fails from the iteration as before (`TypeError: can't iterate from NilClass`) — that is a test. **Unconditional, not version-gated.** No spec asserts the old behaviour — nothing can assert a hang — so the only thing a version gate would preserve is the way to wedge the interpreter. ## Measured, with the control `core/range/to_set_spec.rb` on the pinned corpus: | shim `CUR_VERSION` | binary | result | |---|---|---| | 3.4.1 | this branch | `pass=3 fail=1` — **`BASELINE` records 3**, unchanged | | 4.0.9 | this branch | **`pass=4 fail=1`**, exit 0 | | 4.0.9 | `main` | **exit 124 — hung**, no `RBGO_RESULT` at all | The third row is the control, and it is what makes the second mean something. The `fail=1` in both of my rows is **pre-existing and unrelated**: it is `Range#to_set given positional arguments | instantiates an object of provided as the first argument set class` — rbgo returns a plain `Set` where the spec wants the given subclass. It is why `BASELINE` says 3 for a file with 4 active examples at 3.4.1, and this change neither fixes nor touches it. ## Also found, and deliberately NOT changed here `min`/`max` with a custom comparison block diverge from MRI by one word: ``` rbgo : cannot get the minimum of unbounded range with custom comparison method MRI : cannot get the minimum of endless range with custom comparison method (range.c:1649) MRI : cannot get the maximum of beginless range with custom comparison method (range.c:1762) ``` rbgo uses **one** format string with a `%s` for min/max (builtins.go:11903) and the word `unbounded` for both, where MRI uses **opposite** words — `endless` for min, `beginless` for max. So it is not a one-word fix but an asymmetry that is easy to invert, and **no spec asserts either message**, so nothing would catch getting it backwards. Filed rather than folded into a crash fix. Tests in `crash_hardening_test.go`, beside the recursion guards, since a hang belongs to the same class. `gofmt` clean, frozen prelude regenerated (`prelude_frozen_test.go` is what would catch forgetting that). Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
vm:
pon a self-referential object or Struct killed the processThree lines of ordinary Ruby, measured on
main(3a3fb81):Two ordinary objects pointing at each other. A doubly-linked list, a parent
with a back-pointer, an ORM association. And a
fatal error: stack overflowisnot a panic:
callNative's recover cannot convert it, and nothing else caneither — the embedding host dies with the interpreter, which is precisely what
that recover exists to prevent ("arbitrary input from an embedding host — the
wasm playground REPL, say — can never crash the process", object_model.go:1552).
No adversary is required. This is a bug an ordinary program walks into.
The population the existing guard had cleared
internal/object'sReprEnter/ReprLeaveis the right mechanism and wasalready used by Array, Hash, Set, Data and Enumerator —
TestRecursiveStructureInspectcovers exactly those.defaultObjectInspect(builtins.go) and
Struct#to_s/#inspect(struct.go) never reached it.Measured, every case exit 2 on
mainand exit 0 here:maina.peer = b; b.peer = a; p a#<Node:0x…08 @peer=#<Node:0x…10 @peer=#<Node:0x…08 ...>>>class K; def initialize; @me = self; end; end; K.new.inspect#<K:0x…08 @me=#<K:0x…08 ...>>a.peer = a; p [a][#<N2:0x…08 @peer=#<N2:0x…08 ...>>]a.peer = a; p({k: a}){k: #<N3:0x…08 @peer=#<N3:0x…08 ...>>}S = Struct.new(:x); s = S.new(nil); s.x = s; s.inspect#<struct S x=#<struct S:...>>…; s.to_s…; s.to_a.inspect[#<struct S x=#<struct S:...>>]⛔ Wrapping it in an Array or a Hash did not save it, and that is the part
worth noticing: those guard THEIR OWN identity, so the container printed once
and the recursion came straight back through the object's inspect. A guard over
five types is not a guard over the five types' members.
The renderings are MRI's, from the two functions that do this upstream
Both use
rb_exec_recursive, and both were read at tagv4.0.9:object.c,inspect_obj:if (recur) rb_str_cat2(str, " ...");then">".So a re-entered object prints
#<Node:0x0000000000000008 ...>.struct.c,inspect_struct:if (recur) return rb_str_cat2(str, ":...>");.So a re-entered named struct prints
#<struct S:...>.⚠ One deliberate divergence, recorded at the site rather than guessed at: MRI
appends the class path on re-entry even for an anonymous struct, where it
omits it otherwise (
if (recur || first != '#')), so MRI would print#<struct #<Class:0x…>:...>. This keeps rbgo's own naming rule in both casesand prints
#<struct:...>. The value of the change is not dying; that cornercan be matched separately if anyone wants it.
Tests
TestRecursiveObjectAndStructInspectDoNotKillTheProcessincrash_hardening_test.go— beside the test whose population was too narrow,deliberately, so the two read together.
Its last two cases are the negative control, and they matter as much as the
rest: the guard must CLEAR between siblings, or
p [x, x]would print the secondxas...and silently corrupt ordinary output. Both an object and a structare checked that way.
-raceclean on these paths (ReprEnteris mutex-guarded and the lock is neverheld across the recursion, as its own comment states). Full
internal/vmsuitegreen, 246s,
-count=1.gofmtandgo vetclean.Where it came from
Auditing #768 — "no call-depth limit … kills the embedding host with a fatal Go
stack overflow" — which is fixed but still open:
def f; f; end; fnowraises a rescuable
SystemStackErrorand the program survives. I probed 16 otherrecursion shapes (lambda, proc, send, public_send, method_missing, mutual, block
through each/map, define_method, instance_eval, to_proc, Comparable) and the
depth guard covers every one of them.
What it does not cover is recursion that is not Ruby-level calls: the repr
walk is Go recursion through
Inspect(), so no call-depth counter sees it. Thatis this change.
#768 should be closed on the strength of that probe; I have left it open and
said so there rather than closing someone else's issue from a side quest.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
🤖 Generated with Claude Code