Skip to content

fix(vm): guard Ruby-sized allocations against the bound that fails, and stop the handler eating the cause (#777) - #789

Merged
tannevaled merged 4 commits into
mainfrom
fix/777-allocation-guards
Oct 5, 2026
Merged

tannevaled merged 4 commits into
mainfrom
fix/777-allocation-guards

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Fixes #777. Three separable defects in one trace, plus the census that says which other sites share their shape.

1. MRI bibliography — why the two classes differ

The asymmetry in the report is real, it is deliberate in MRI, and it comes down to which ceiling each operator divides. Both guards divide; neither forms the product.

MRI source the test ceiling
Array#* array.c v4.0.5:5235 (rb_ary_times, :5216) if (ARY_MAX_SIZE/len < RARRAY_LEN(ary)) rb_raise(rb_eArgError, "argument too big"); ARY_MAX_SIZE = LONG_MAX / (int)sizeof(VALUE) — array.c v4.0.5:73
String#* string.c v4.0.5:2593 (rb_str_times, :2560) if (len && LONG_MAX/len < RSTRING_LEN(str)) rb_raise(rb_eArgError, "argument too big"); bare LONG_MAX

Array#* divides a real allocation ceiling: ARY_MAX_SIZE is LONG_MAX/8, roughly 2**60. String#* divides LONG_MAX itself, which is a pure arithmetic-overflow test — a String's element is one byte, so no product of byte counts can exceed a long without overflowing it.

That single difference produces the whole table:

  • [1] * (2**62) — ARY_MAX_SIZE/2**62 is 0, which is below RARRAY_LEN([1]) == 1, so Array#* refuses → ArgumentError.
  • "x" * (2**62) — LONG_MAX/2**62 is 1, which is not below RSTRING_LEN("x") == 1, so String#* proceeds and the allocation is what fails → NoMemoryError.
  • "ab" * (2**62) — LONG_MAX/2**62 == 1 < 2, so String#* refuses after all → ArgumentError.

So the fourth row is not an inconsistency in MRI; it is the boundary of a guard that was only ever about overflow. rbgo's stringTimes had transcribed MRI's String guard correctly. What it lacked was the second half: MRI allocates and raises NoMemoryError, while Go's make panics. This change reproduces the asymmetry rather than normalising it.

Supporting citations:

  • ary_new's own refusal: array.c v4.0.5:730, if (capa > ARY_MAX_SIZE) rb_raise(rb_eArgError, "array size too big"); — a different message from Array#*'s.
  • rb_ary_fill's own refusal: array.c v4.0.5:5087, if (beg >= ARY_MAX_SIZE || len > ARY_MAX_SIZE - beg) rb_raise(rb_eArgError, "argument too big"); — and this one is "argument too big", not ary_new's message.
  • NoMemoryError's message literal: gc.c v4.0.5:5545, rb_vm_register_special_exception(ruby_error_nomemory, rb_eNoMemError, "failed to allocate memory");

Correction to the issue: the tag v4.0.5 does exist in github.com/ruby/ruby — the naming changed at 4.0 from v3_4_1-style underscores to dotted v4.0.5. Every citation above is read from v4.0.5, the exact version installed here (ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]), so no nearby-tag approximation was needed.

2. The three regimes, and why the guards divide

rbgo formed the product first — len(a.Elems)*int(n) as an int — and tested the result. That admits three regimes, and before this change each failed differently and none failed correctly:

regime expression what happened
wrapped negative [1,2] * (2**63-1) 2*(2**63-1) is -2 → make panics cap out of range
over what make takes [1] * (2**62) positive, above maxAlloc → make panics cap out of range
wrapped to exactly zero [1,2,3,4] * (2**62) 4*2**62 == 2**64 == 0 → make SUCCEEDS with capacity 0, then the fill loop appends 2**62 slices

The wrap-to-zero case is confirmed, and it is the worst of the three. It was the one claim in the report not verified first-hand. Measured on the pre-fix binary under an RSS watchdog (validated on a known positive first): no panic, no exception, nothing on stderr — it passed 1.1 GB resident in 60 s and was still climbing when the harness killed it. A guard written only against a negative or over-max product would not have caught it.

MRI's shape makes all three unrepresentable at once, because dividing never forms the product. Both guards now divide, and the ceilings are derived rather than written down:

aryMaxSize = int64(math.MaxInt) / int64(unsafe.Sizeof(object.Value(nil)))  // ARY_MAX_SIZE
strMaxSize = int64(math.MaxInt)                                            // bare LONG_MAX

unsafe.Sizeof takes the element width from object.Value's own representation, so the bound follows the target word size (32-bit wasm included) and cannot be left behind by a change to Value. TestRepeatBoundsAreDerivedNotGuessed checks both at the boundary and one past it.

Whatever survives the division guard is allocated through allocValues/allocBytes, which convert Go's makeslice panic into NoMemoryError with MRI's message. That is exactly the regime MRI reaches for "x" * (2**62).

One residual, stated plainly: Go's maxAlloc is not reachable from a program, so it cannot be tested for up front — recovering the panic is the only way to name that bound. A capacity below maxAlloc that the host cannot actually satisfy makes the Go runtime abort with a fatal out of memory that no recover can catch, where MRI would raise NoMemoryError. That is a property of the runtime, not of this guard, and it is documented at the conversion site.

3. Before / after, measured

Every "after" cell is byte-for-byte identical to MRI 4.0.5, measured on the installed interpreter. Oversized probes ran under a hard timeout plus an RSS watchdog validated on a known positive (an unbounded allocator, killed at 323 MB) and a known negative (/usr/bin/true, clean exit).

expression before after MRI 4.0.5
[1] * (2**62) panic: makeslice: cap out of range [recovered, repanicked] ArgumentError: argument too big ArgumentError: argument too big
[1,2] * (2**62) panic: makeslice: cap out of range [recovered, repanicked] ArgumentError: argument too big ArgumentError: argument too big
[1,2,3,4] * (2**62) unbounded growth — no panic, no exception ArgumentError: argument too big ArgumentError: argument too big
[1,2] * (2**63-1) panic: makeslice: cap out of range [recovered, repanicked] ArgumentError: argument too big ArgumentError: argument too big
"x" * (2**62) panic: makeslice: cap out of range [recovered, repanicked] NoMemoryError: failed to allocate memory NoMemoryError: failed to allocate memory
"ab" * (2**62) ArgumentError: argument too big ArgumentError: argument too big ArgumentError: argument too big
[] * (2**62) hang (loops 2**62 times appending nothing) [] []
[] * -1 ArgumentError: negative argument ArgumentError: negative argument ArgumentError: negative argument
[1] * (10**40) TypeError RangeError: bignum too big to convert into 'long' RangeError: bignum too big to convert into 'long'
[1,2] * 2.9 [1, 2, 1, 2] [1, 2, 1, 2] [1, 2, 1, 2]
Array.new(2**62) ArgumentError: new: runtime error: makeslice: cap out of range ArgumentError: array size too big ArgumentError: array size too big
[1,2,3].fill(0, 0, 2**62) ArgumentError: array size too big ArgumentError: argument too big ArgumentError: argument too big
[1,2,3].fill(10, 1, 1 << 50) ArgumentError (the 1<<40 cap) NoMemoryError: failed to allocate memory NoMemoryError: failed to allocate memory
"x".ljust(2**62) ArgumentError: ljust: runtime error: makeslice: len out of range NoMemoryError: failed to allocate memory NoMemoryError: failed to allocate memory
"x".rjust(2**62) ArgumentError: rjust: runtime error: makeslice: len out of range NoMemoryError: failed to allocate memory NoMemoryError: failed to allocate memory
"x".center(2**62) ArgumentError: center: runtime error: makeslice: len out of range NoMemoryError: failed to allocate memory NoMemoryError: failed to allocate memory
(1..2**62).to_a ArgumentError: to_a: runtime error: makeslice: cap out of range NoMemoryError: failed to allocate memory NoMemoryError (MRI materialises until it is refused)
1 << (2**62) ArgumentError: <<: runtime error: makeslice: len out of range NoMemoryError: failed to allocate memory NoMemoryError: failed to allocate memory
Random.new.bytes(-1) ArgumentError: bytes: runtime error: makeslice: len out of range ArgumentError: negative string size (or size too big) ArgumentError: negative string size (or size too big)

The Array#fill row at 1 << 50 changed an existing test's expectation. That test was asserting the cap rather than MRI: maxFillSize was an unjustified 1<<40, so every length between 1<<40 and ARY_MAX_SIZE was refused here and not refused by MRI. Measured on 4.0.5, 1<<50 raises NoMemoryError, and 1<<40 and 1<<35 are genuinely materialised (both passed 2 GB resident before the probe's cap stopped them) — so the old bound was wrong in both directions. It is now derived from ARY_MAX_SIZE.

4. Defect (b) — the re-panic, and what it cost

Run's deferred handler converted the recovered value with an unchecked r.(RubyError). Any other panic value made the assertion panic inside the handler — the origin of [recovered, repanicked].

A host-shaped harness, run against both trees, on [1] * (2**62):

                  before                                                        after
returned err  :   <nil>                                                         ArgumentError: argument too big
escaped panic :   interface conversion: interface {} is runtime.errorString,     <nil>
                  not vm.RubyError
frameNames    :   1   <- the handler's cleanup never ran                        0

The frameNames: 1 is measured, not inferred: the cleanup genuinely did not run, so a host that recovered in a wrapper carried on with a VM holding a half-unwound frame stack.

The fix is a type switch with a default arm that wraps the value in a RubyError, so the cause survives and the cleanup runs. This is independent of (a): fixing the guards removed #777's trigger, but any future non-RubyError panic was laundered the same way. TestNonRubyErrorPanicKeepsItsCause exercises the default arm through a native method that panics with a plain string — a path (a) does not touch — and asserts all three things the assertion cost: that no panic escapes Run, that the cause survives in the returned error, and that the frame stacks were reset.

5. Defect (c) — does (b)'s fix resolve it?

Yes, for this trigger class, and the API is untouched. Run's handler ends in result, err = nil, rerr; the unchecked assertion panicked before reaching it, which is why err was nil for a program that had not completed. With the default arm in place that assignment is always reached, so a non-RubyError panic now returns a non-nil error.

Worth being precise about attribution: in the table above [1] * (2**62) returns a non-nil error because (a) turned it into an ordinary RubyError. The claim that (b) alone fixes (c) is carried by TestNonRubyErrorPanicKeepsItsCause, where (a) is not involved at all and Run still returns a non-nil error carrying the cause.

This corroborates #773 from a second direction. No API reshaping here — the three candidate shapes stay #773's decision.

6. The make( census

Every make( in non-test Go under internal/vm/, with a denominator.

bucket count
total make( occurrences 857
make(map[...]) / make(chan ...) — none takes a Ruby-supplied capacity 83 (discarded)
size is len(...)/cap(...) of a live Go container — bounded by memory already allocated 649 (discarded)
individually read and traced 125
…discarded: clamped Ruby counts (clampCount, if n > len(...) { n = len(...) }), bytecode operands, literals 87
…real candidates (size traceable to a Ruby-supplied number) 38

Verdicts: 0 UNGUARDED · 7 GUARDED-EXPLICITLY · 31 GUARDED-BY-BACKSTOP.

The dispatch fact that sets the verdicts: m.native(...) is called from exactly one place, callNative, so every native method body is under the backstop. The only Ruby-sized make sites on an opcode path (binaryOp → binaryOpBuiltin, which does not go through callNative) are String#* and Array#* — precisely the two reported — and both now carry their own guard. Nothing is left on a path where a make panic would escape to Run.

GUARDED-EXPLICITLY (7)

site method guard
arith.go:762 String#* repeatFitsString against strMaxSize, then allocBytes
arith.go:801 Array#* repeatFitsArray against aryMaxSize, then allocValues; plus zero-count and empty-receiver short-circuits
builtins.go:3234 Array.new fitsArrayAlloc → "array size too big"; Bignum and negative already checked
builtins.go:9442 Array#fill int64(end) > maxFillSize → "argument too big"; maxFillSize = aryMaxSize
alloc_bounds.go:92, :100 allocValues, allocBytes defer convertAllocPanic() → NoMemoryError
alloc_bounds.go randomByteCount sign check → MRI's "negative string size (or size too big)"

GUARDED-BY-BACKSTOP (31)

No guard of their own; a make panic is caught by callNative and reported as NoMemoryError: failed to allocate memory instead of an ArgumentError carrying Go's internal text. Named ones: String#ljust/#rjust/#center (makePad, builtins.go:10884), Range#to_a/#entries/#last(n)/#max(n) (rangeElems, builtins.go:11892), Range#first(n)/#min(n) (:11812, :11832), Range#take(n) on an endless range (:5922), Array#repeated_combination/#repeated_permutation (:4383–:4426), Array#insert (:3564), Struct#values_at(range) (struct.go:643), Enumerator#first/#take (enumerator.go:1046), four IO::Buffer sites, StringIO/IO #pos=/#seek/#truncate/#pwrite, seven socket recv/read sites (all sign-checked already), Net::FTP#retrbinary, Benchmark.bmbm.

Three findings the census turned up that the two reported sites did not cover

  1. A negative size and an oversized one are indistinguishable to the backstop. Go's makeslice uses the same panic text — "makeslice: len out of range" — for len < 0 and for len > maxAlloc. So the backstop alone would report MRI's ArgumentError case as NoMemoryError. Three sites checked neither sign nor magnitude (Random#bytes, Random.bytes, OpenSSL::Random.random_bytes); they now check the sign at the site with MRI's exact message, which both fixes the class and makes a fault that does reach the backstop from them genuinely an oversized allocation. This was a regression my own backstop introduced, caught by the census and fixed in the same change.

  2. arraySpliceAssign (builtins.go:8129) is the same hazard without a make. a[2**40] = 1 runs for len(a.Elems) < start { append(..., NilV) } — it grows with no make to panic on, so the backstop only sees it once growslice refuses, after the loop has consumed everything it can get. Left alone deliberately: it is a growth loop, not a bound on a product, and fixing it is a different change. isAllocFault does list "growslice:", so the eventual refusal is at least classified correctly.

  3. Several backstopped sites accept the capacity and then run a fill loop for the full Ruby count (makePad, rangeFirstN, rangeElems, repeated_permutation, Struct#values_at). "x".ljust(2**31) is in that regime: the make succeeds, the loop is what hurts. Also out of scope here, and recorded rather than quietly skipped.

Two census entries that are not defects

  • Hash.new with a capacity: not implemented. There is no capacity: keyword in rbgo, and every make(map[K]V, n) in the package takes len(...)/.Len()/a constant. So nothing is sized from it and there is no allocation hazard. (MRI 4.0.5 gives RuntimeError: st_table too big for Hash.new(capacity: 2**62) where rbgo returns a Hash — a separate conformance gap, not an allocation one.)
  • Integer#<< at a size the backstop cannot catch is MRI-conformant. The census flagged 1 << (2**40) (~137 GB inside math/big) as the worst remaining hole. A control says otherwise: MRI does the same thing. Both rbgo and ruby grew past 2 GB resident on that expression and were killed by the watchdog. It is a legitimate 137 GB integer that both implementations materialise until the machine refuses. Not a defect, and not introduced here. (1 << (2**62) is large enough to trip the range check, and matches MRI's NoMemoryError.)

7. Benchmark

Array#* and String#* are hot, so the guards were measured, not assumed free. No benchmark existed for either; internal/vm/repeat_bench_test.go adds five. The machine is shared (load average ~13), so before/after rounds were interleaved and the minimum of three taken rather than measured in separate windows.

benchmark before (ns/op) after (ns/op) Δ
BenchmarkArrayTimesSmall 150,517 145,793 −3.1 %
BenchmarkArrayTimesWide 175,936 179,125 +1.8 %
BenchmarkArrayTimesZero 113,958 103,646 −9.0 %
BenchmarkStringTimesSmall 128,891 130,248 +1.1 %
BenchmarkStringTimesWide 66,907 66,078 −1.2 %

Everything but the zero-count case is inside the run-to-run spread on a loaded machine; allocs/op is identical throughout, so the deferred recover adds no allocation. ArrayTimesZero is genuinely faster: the new n == 0 short-circuit returns before allocating or looping.

8. Conformance delta

scripts/conformance/rubyspec/run.sh as CI invokes it (which builds and execs cmd/ratchet against the per-file BASELINE — there is no scalar FLOOR), with an isolated corpus directory so no peer session's /tmp state was touched.

branch      : files 2206/2206 · passing examples 23510 (baseline 23481, +29) · ratchet OK
origin/main : files 2206/2206 · passing examples 23511 (baseline 23481, +30) · ratchet OK

Delta attributable to this change: zero. Both pass. The +29/+30 is the baseline being stale on main, not this branch's work — core/array/multiply_spec.rb: 13 → 14 appears in the control too, so it is not mine either.

The one differing file is core/random/bytes_spec.rb (control 9, branch 8), and it is a pre-existing flake, not a regression: run four times against each binary it gives pass=8 and pass=9 on both. The cause is now known and is also pre-existing — Random#bytes returns a string tagged UTF-8 where MRI returns ASCII-8BIT (object.NewStringBytes instead of NewStringBytesEnc, inconsistent with the class-side Random.bytes three lines away), so #size varies with whether the random bytes happen to form valid UTF-8. Identical before and after this change; worth its own issue.

9. Verification

  • Full go test ./... -timeout=90m -race with the CI -coverpkg set: see below.
  • GOOS=wasip1 GOARCH=wasm build + vet, GOOS=js GOARCH=wasm build ./... + vet: all clean. One catch worth recording — the first draft of the new test reused runSrcErr, which sits behind //go:build !windows && !wasm; CI vets the test binary for both wasm lanes, so that would have gone red there and nowhere else. The test now carries its own runKeepingError.
  • gofmt -l . empty.
  • Per-function coverage ratchet: see below.

10. Tests added

internal/vm/alloc_bounds_internal_test.go:

  • TestRepeatBoundRegimes — a case per regime (wrapped-negative, over-max, wrap-to-exactly-zero), the String/Array class asymmetry per receiver length, the empty-receiver short-circuit and that a negative count still raises through it, NUM2LONG count coercion, Array.new/Array#fill messages, and the NoMemoryError backstop per site.
  • TestAllocPanicTextStillNamesMakeslice — the known positive the fault classifier is validated on: it provokes a real makeslice panic and asserts the text still carries the prefix isAllocFault reads, so a Go rename is reported instead of silently sending every refused allocation back to an ArgumentError with Go internals in it. Its negative side asserts an index/slice/nil fault is not reclassified.
  • TestConvertAllocPanicOnlyConvertsAnAllocationFault — the helper's contract. Its re-panic arm cannot fire from either production caller (inside make([]T, 0, n) nothing but makeslice can panic), so this is deliberately a contract test and not a reachability claim; it earns its place because a helper that swallowed an unrelated panic and returned a nil slice would be worse than the defect Array#* and String#* reach make() with a rejected size; the top-level recover then repanics, destroying the cause, and Run() returns nil #777 fixed.
  • TestRepeatBoundsAreDerivedNotGuessed — both ceilings against the definitions they transcribe, at the boundary and one past it.
  • TestNonRubyErrorPanicKeepsItsCause — (b) and (c) through a native panic, asserting no escape, the cause intact, and all five frame stacks reset.
  • TestRunReportsAnErrorForARefusedAllocation — the two defects closed on Array#* and String#* reach make() with a rejected size; the top-level recover then repanics, destroying the cause, and Run() returns nil #777's own trigger.

🤖 Generated with Claude Code

tannevaled and others added 4 commits October 5, 2026 15:59
)

`Array#*` had no overflow guard at all and `String#*` had one that tested
arithmetic overflow of the product, which is not the bound `make` enforces.
At a one-byte receiver `MaxInt/len(src)` is the largest int64, so the guard
was vacuous and every count reached `make`.

Forming the product first admits three regimes, and before this change each
failed differently and none failed correctly:

  wrapped NEGATIVE     [1,2] * (2**63-1)     2*(2**63-1) is -2
  over what make takes [1] * (2**62)         positive, above maxAlloc
  wrapped to ZERO      [1,2,3,4] * (2**62)   4*2**62 is EXACTLY 0

The last was silent: `make` succeeded with capacity 0, so there was no panic
and no exception, and the fill loop then appended 2**62 slices. Measured on
the pre-fix binary it passed 1.1 GB resident in 60 s, still climbing.

MRI's guards DIVIDE a ceiling by one factor and compare the quotient with the
other, so the product is never formed. Transcribing that shape refuses all
three regimes with one test. The two ceilings differ, and that difference is
the whole asymmetry between the classes MRI raises:

  array.c  v4.0.5:5235  ARY_MAX_SIZE/len < RARRAY_LEN(ary)  -> ArgumentError
  string.c v4.0.5:2593  LONG_MAX/len     < RSTRING_LEN(str)  -> ArgumentError

Array#* divides ARY_MAX_SIZE (LONG_MAX/sizeof(VALUE), array.c v4.0.5:73), a
real allocation ceiling; String#* divides bare LONG_MAX, a pure overflow test.
So `[1] * (2**62)` is an ArgumentError while `"x" * (2**62)` is a NoMemoryError
(the allocation is what fails), and `"ab" * (2**62)` is an ArgumentError again.
That asymmetry is reproduced rather than normalised.

Also in this change:

- Array#* routes through the VM (arrayTimes) for MRI's NUM2LONG coercion, so
  an out-of-range Bignum count is RangeError and not TypeError, a Float
  truncates, and an empty receiver short-circuits -- `[] * (2**62)` used to
  loop 2**62 times appending nothing.
- Array.new checks ARY_MAX_SIZE itself (array.c v4.0.5:730, "array size too
  big") instead of letting the capacity reach `make`, whose panic callNative
  reported as `ArgumentError: new: runtime error: makeslice: cap out of range`.
- Array#fill's cap is derived from ARY_MAX_SIZE rather than an unjustified
  1<<40, and reports rb_ary_fill's own message (array.c v4.0.5:5087).
- callNative classifies an allocation fault as NoMemoryError with MRI's
  message (gc.c v4.0.5:5545) instead of an ArgumentError carrying Go's
  internal text. This is the backstop for every unguarded `make` whose size
  comes from a Ruby value: ljust/rjust/center, Range#to_a and Integer#<< were
  all leaking `makeslice: len out of range` into a Ruby message.

Second, independent defect: Run's deferred handler converted the recovered
value with an unchecked `r.(RubyError)`, so any other panic value made the
assertion panic INSIDE the handler. That is where `[recovered, repanicked]`
came from, and it cost the cause (the assertion's value replaced it) and every
line of cleanup below, leaving a host that recovers on a VM with a half-unwound
frame stack. It also made Run return a nil error for a program that had not
completed, because the final assignment was never reached. A type switch with
a default arm wrapping the value keeps the cause legible for any future
non-RubyError panic, and resolves the nil-error reading of #773 for this
trigger without touching the API.

Expectations measured byte-for-byte against the installed ruby 4.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestRepeatBoundRegimes covers the three ways a Ruby-sized product used to
reach `make`: wrapped negative, positive but above what make accepts, and
wrapped to EXACTLY zero -- the silent one, where make succeeded with capacity
0 and the fill loop grew without bound. It also pins the String/Array class
asymmetry per receiver length, the empty-receiver short-circuit (and that a
negative count still raises through it), the NUM2LONG count coercion, and the
NoMemoryError backstop for ljust/rjust/center/Range#to_a/Integer#<<.

TestAllocPanicTextStillNamesMakeslice is the known positive the fault
classifier is validated on: it provokes a real makeslice panic and asserts the
text still carries the prefix isAllocFault reads, so a Go rename is reported
rather than silently sending every refused allocation back to being an
ArgumentError with Go internals in its message. Its negative side asserts that
an ordinary index/slice/nil fault is NOT reclassified.

TestRepeatBoundsAreDerivedNotGuessed checks both ceilings against the
definitions they transcribe, at the boundary and one past it, so the bound
cannot drift from object.Value's width or the target word size.

TestNonRubyErrorPanicKeepsItsCause exercises Run's handler through a native
method that panics with a plain string, which callNative passes through
untouched. It asserts all three things the unchecked assertion cost: that no
panic escapes Run, that the cause survives in the returned error, and that the
handler's frame-stack cleanup ran.

runKeepingError duplicates runSrcErr deliberately: that one sits behind
`//go:build !windows && !wasm`, and CI vets the test binary for GOOS=wasip1
and GOOS=js.

The benchmarks measure what the guards cost on the hot path. Interleaved
before/after rounds (minimum of 3, load average ~13 on a shared machine):
Array#* small 150.5us -> 145.8us, wide 175.9us -> 179.1us, zero-count
114.0us -> 103.6us (the short-circuit), String#* small 128.9us -> 130.2us,
wide 66.9us -> 66.1us. Identical allocs/op throughout: the deferred recover
adds no allocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by the make( census, and it is a regression the backstop in the previous
commit introduced rather than a pre-existing one.

Go's makeslice raises the SAME panic text -- "makeslice: len out of range" --
for a negative length and for one above maxAlloc. callNative cannot tell those
apart from the text, so classifying every allocation fault as NoMemoryError
reported MRI's ArgumentError case as NoMemoryError:

                            before                      after this commit
  Random.new.bytes(-1)      ArgumentError: bytes:       ArgumentError: negative
                            runtime error: makeslice:   string size (or size too
                            len out of range            big)

MRI 4.0.5 raises `ArgumentError: negative string size (or size too big)`, so
the class was accidentally right before the backstop and wrong after it. The
message was wrong either way.

Three sites checked neither sign nor magnitude: Random#bytes, Random.bytes and
OpenSSL::Random.random_bytes. They now go through randomByteCount, which
settles the sign at the site with MRI's exact message. That fixes the class and
makes a fault that DOES reach the backstop from these methods genuinely an
oversized allocation -- `Random.new.bytes(2**62)` still gives MRI's
NoMemoryError.

Every other candidate the census found already checks its sign (the seven
socket recv/read sites, Array#insert, Range#take, IO#truncate, IO#pwrite,
IO::Buffer's bufSizeArg), so this is the whole of the ambiguity.

Also corrects an existing Array#fill expectation that was asserting the cap
rather than MRI. maxFillSize was an unjustified 1<<40, so every length between
1<<40 and ARY_MAX_SIZE was refused by rbgo and NOT by MRI. Measured on 4.0.5:
`[1,2,3].fill(10, 1, 1 << 50)` raises NoMemoryError, while 1<<40 and 1<<35 are
genuinely materialised (both passed 2 GB resident before the probe's cap
stopped them) -- the old bound was wrong in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught this and it is a correction to the previous commit, not a new
defect. `TestRunPropagatesInternalPanic` (whitebox_test.go:155) went red on
both arch lanes:

  // A non-RubyError panic inside exec (here an out-of-range local) must
  // propagate out of Run rather than be swallowed.

Wrapping every non-RubyError panic into a returned RubyError, as the previous
commit did, contradicted a deliberate and documented policy this VM already
draws in one place: callNative converts a fault caused by Ruby INPUT into a
rescuable exception so an embedding host can never be crashed by a program it
ran, while a broken VM INVARIANT stays loud so a real defect is not laundered
into an error a host would log and move past. An out-of-range local is the
second kind, and turning it into a Ruby error would hide exactly the bugs that
assertion exists to surface.

What #777 actually broke was the CAUSE and the CLEANUP, not the propagation.
Both are fixed without touching the propagation. Measured, host-shaped, on both
kinds of panic:

                         before                                  after
  [1]*(2**62)            err=<nil>                               err=ArgumentError: argument too big
  (a Ruby-input fault)   escaped=interface conversion: ...       escaped=<nil>
                                  runtime.errorString
                         frameNames=1                            frameNames=0

  panic("a broken VM     err=<nil>                               err=<nil>
  invariant")            escaped=interface conversion: ...       escaped=a broken VM invariant
                                  is string
                         frameNames=2                            frameNames=0

So the Ruby-input fault becomes a legible returned exception (because the
allocation guards make it an ordinary RubyError), and the invariant break still
propagates -- but carrying its OWN value instead of the failed assertion's, and
leaving a clean VM instead of a half-unwound frame stack.

Consequence for the #773 reading, stated precisely: this defect's fix does not
by itself make Run return non-nil. The allocation guards do, for #777's
trigger. TestNonRubyErrorPanicKeepsItsCause now asserts what is actually
guaranteed -- the panic propagates, its cause is the original value, and all
five frame stacks are reset -- rather than the wrapping the previous commit
assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 0dd53da into main Oct 5, 2026
9 checks passed
@tannevaled
tannevaled deleted the fix/777-allocation-guards branch October 5, 2026 15:56
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.

Array#* and String#* reach make() with a rejected size; the top-level recover then repanics, destroying the cause, and Run() returns nil

1 participant