Skip to content

vm: p on a self-referential object or Struct killed the process - #852

Merged
tannevaled merged 1 commit into
mainfrom
inspect-cycle-guard
Nov 1, 2026
Merged

tannevaled merged 1 commit into
mainfrom
inspect-cycle-guard

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

vm: p on a self-referential object or Struct killed the process

Three lines of ordinary Ruby, measured on main (3a3fb81):

$ 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

🤖 Generated with Claude Code

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>
@tannevaled

Copy link
Copy Markdown
Contributor Author

The neighbours were checked, and they are fine

A fix to one Go-recursion path invites the question of the others, so I probed
every path I could think of that walks a user-controlled object graph in Go.
Each in its own process, on this branch's binary, so one crash could not hide
the rest. All exit 0:

answer
a = []; a << a; b = []; b << b; a == b true
h[:k] = h; g[:k] = g; h == g true
S = Struct.new(:x); a.x = a; b.x = b; a == b true
a = []; a << a; a.hash an Integer
S.new(nil) self-referential, .hash an Integer
a = []; a << a; a.flatten ArgumentError: tried to flatten recursive array — MRI's exact message
a = []; a << a; Marshal.dump(a) 6 bytes (an object link)
require "json"; a << a; JSON.generate(a) JSON::NestingError: nesting of 100 is too deep…

So equality, hashing, flatten and both serialisers already guard themselves —
MRI uses rb_exec_recursive_paired for the first three (struct.c:1444, 1525) and
rbgo matches. The inspect/to_s path was the only gap in this class that I
could find, which is also why it is the only thing this PR changes.

Depth without a cycle is a different vector, and it holds too

An identity guard does nothing for a structure that is merely very deep — there
is no repeat to detect. Worth probing because it is reachable from parsing:

$ rbgo -e 'a = []; 200000.times { a = [a] }; puts a.inspect.length'
400002
$ rbgo -e 'a = []; 200000.times { a = [a] }; p Marshal.dump(a).bytesize'
400004

200,000 levels, both fine — a Go goroutine stack grows on demand toward the 1 GB
limit, so a shallow frame repeated that often still fits. And the usual way such
a structure would arrive from outside is capped before it gets here: JSON refuses
at nesting 100.

I am not claiming a bound was proven. I am saying the depth vector was looked
for, that 200k is well past anything a parser will hand us, and that the failure
this PR fixes needed no depth at all — two objects were enough.

🤖 Generated with Claude Code

@tannevaled
tannevaled merged commit cc9e6c5 into main Nov 1, 2026
25 checks passed
@tannevaled
tannevaled deleted the inspect-cycle-guard branch November 1, 2026 01:19
tannevaled added a commit that referenced this pull request Nov 1, 2026
```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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant