Repository navigation
fix(vm): guard Ruby-sized allocations against the bound that fails, and stop the handler eating the cause (#777) - #789
Merged
Conversation
) `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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Array#*array.cv4.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.cv4.0.5:73String#*string.cv4.0.5:2593 (rb_str_times, :2560)if (len && LONG_MAX/len < RSTRING_LEN(str)) rb_raise(rb_eArgError, "argument too big");LONG_MAXArray#*divides a real allocation ceiling:ARY_MAX_SIZEisLONG_MAX/8, roughly2**60.String#*dividesLONG_MAXitself, 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**62is0, which is belowRARRAY_LEN([1]) == 1, soArray#*refuses →ArgumentError."x" * (2**62)—LONG_MAX/2**62is1, which is not belowRSTRING_LEN("x") == 1, soString#*proceeds and the allocation is what fails →NoMemoryError."ab" * (2**62)—LONG_MAX/2**62 == 1 < 2, soString#*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'sstringTimeshad transcribed MRI's String guard correctly. What it lacked was the second half: MRI allocates and raisesNoMemoryError, while Go'smakepanics. This change reproduces the asymmetry rather than normalising it.Supporting citations:
ary_new's own refusal:array.cv4.0.5:730,if (capa > ARY_MAX_SIZE) rb_raise(rb_eArgError, "array size too big");— a different message fromArray#*'s.rb_ary_fill's own refusal:array.cv4.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", notary_new's message.NoMemoryError's message literal:gc.cv4.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.5does exist ingithub.com/ruby/ruby— the naming changed at 4.0 fromv3_4_1-style underscores to dottedv4.0.5. Every citation above is read fromv4.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
rbgoformed the product first —len(a.Elems)*int(n)as anint— and tested the result. That admits three regimes, and before this change each failed differently and none failed correctly:[1,2] * (2**63-1)2*(2**63-1)is-2→makepanicscap out of rangemaketakes[1] * (2**62)maxAlloc→makepanicscap out of range[1,2,3,4] * (2**62)4*2**62 == 2**64 == 0→makeSUCCEEDS with capacity 0, then the fill loop appends2**62slicesThe 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:
unsafe.Sizeoftakes the element width fromobject.Value's own representation, so the bound follows the target word size (32-bit wasm included) and cannot be left behind by a change toValue.TestRepeatBoundsAreDerivedNotGuessedchecks both at the boundary and one past it.Whatever survives the division guard is allocated through
allocValues/allocBytes, which convert Go'smakeslicepanic intoNoMemoryErrorwith MRI's message. That is exactly the regime MRI reaches for"x" * (2**62).One residual, stated plainly: Go's
maxAllocis 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 belowmaxAllocthat the host cannot actually satisfy makes the Go runtime abort with a fatalout of memorythat norecovercan catch, where MRI would raiseNoMemoryError. 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
timeoutplus an RSS watchdog validated on a known positive (an unbounded allocator, killed at 323 MB) and a known negative (/usr/bin/true, clean exit).[1] * (2**62)panic: makeslice: cap out of range [recovered, repanicked]ArgumentError: argument too bigArgumentError: argument too big[1,2] * (2**62)panic: makeslice: cap out of range [recovered, repanicked]ArgumentError: argument too bigArgumentError: argument too big[1,2,3,4] * (2**62)ArgumentError: argument too bigArgumentError: argument too big[1,2] * (2**63-1)panic: makeslice: cap out of range [recovered, repanicked]ArgumentError: argument too bigArgumentError: argument too big"x" * (2**62)panic: makeslice: cap out of range [recovered, repanicked]NoMemoryError: failed to allocate memoryNoMemoryError: failed to allocate memory"ab" * (2**62)ArgumentError: argument too bigArgumentError: argument too bigArgumentError: argument too big[] * (2**62)2**62times appending nothing)[][][] * -1ArgumentError: negative argumentArgumentError: negative argumentArgumentError: negative argument[1] * (10**40)TypeErrorRangeError: 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 rangeArgumentError: array size too bigArgumentError: array size too big[1,2,3].fill(0, 0, 2**62)ArgumentError: array size too bigArgumentError: argument too bigArgumentError: argument too big[1,2,3].fill(10, 1, 1 << 50)ArgumentError(the1<<40cap)NoMemoryError: failed to allocate memoryNoMemoryError: failed to allocate memory"x".ljust(2**62)ArgumentError: ljust: runtime error: makeslice: len out of rangeNoMemoryError: failed to allocate memoryNoMemoryError: failed to allocate memory"x".rjust(2**62)ArgumentError: rjust: runtime error: makeslice: len out of rangeNoMemoryError: failed to allocate memoryNoMemoryError: failed to allocate memory"x".center(2**62)ArgumentError: center: runtime error: makeslice: len out of rangeNoMemoryError: failed to allocate memoryNoMemoryError: failed to allocate memory(1..2**62).to_aArgumentError: to_a: runtime error: makeslice: cap out of rangeNoMemoryError: failed to allocate memoryNoMemoryError(MRI materialises until it is refused)1 << (2**62)ArgumentError: <<: runtime error: makeslice: len out of rangeNoMemoryError: failed to allocate memoryNoMemoryError: failed to allocate memoryRandom.new.bytes(-1)ArgumentError: bytes: runtime error: makeslice: len out of rangeArgumentError: negative string size (or size too big)ArgumentError: negative string size (or size too big)The
Array#fillrow at1 << 50changed an existing test's expectation. That test was asserting the cap rather than MRI:maxFillSizewas an unjustified1<<40, so every length between1<<40andARY_MAX_SIZEwas refused here and not refused by MRI. Measured on 4.0.5,1<<50raisesNoMemoryError, and1<<40and1<<35are 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 fromARY_MAX_SIZE.4. Defect (b) — the re-panic, and what it cost
Run's deferred handler converted the recovered value with an uncheckedr.(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):The
frameNames: 1is 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-RubyErrorpanic was laundered the same way.TestNonRubyErrorPanicKeepsItsCauseexercises 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 escapesRun, 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 inresult, err = nil, rerr; the unchecked assertion panicked before reaching it, which is whyerrwasnilfor a program that had not completed. With the default arm in place that assignment is always reached, so a non-RubyErrorpanic 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 ordinaryRubyError. The claim that (b) alone fixes (c) is carried byTestNonRubyErrorPanicKeepsItsCause, where (a) is not involved at all andRunstill 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(censusEvery
make(in non-test Go underinternal/vm/, with a denominator.make(occurrencesmake(map[...])/make(chan ...)— none takes a Ruby-supplied capacitylen(...)/cap(...)of a live Go container — bounded by memory already allocatedclampCount,if n > len(...) { n = len(...) }), bytecode operands, literalsVerdicts: 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-sizedmakesites on an opcode path (binaryOp→binaryOpBuiltin, which does not go throughcallNative) areString#*andArray#*— precisely the two reported — and both now carry their own guard. Nothing is left on a path where amakepanic would escape toRun.GUARDED-EXPLICITLY (7)
arith.go:762String#*repeatFitsStringagainststrMaxSize, thenallocBytesarith.go:801Array#*repeatFitsArrayagainstaryMaxSize, thenallocValues; plus zero-count and empty-receiver short-circuitsbuiltins.go:3234Array.newfitsArrayAlloc→"array size too big"; Bignum and negative already checkedbuiltins.go:9442Array#fillint64(end) > maxFillSize→"argument too big";maxFillSize = aryMaxSizealloc_bounds.go:92,:100allocValues,allocBytesdefer convertAllocPanic()→NoMemoryErroralloc_bounds.gorandomByteCount"negative string size (or size too big)"GUARDED-BY-BACKSTOP (31)
No guard of their own; a
makepanic is caught bycallNativeand reported asNoMemoryError: failed to allocate memoryinstead of anArgumentErrorcarrying 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), fourIO::Buffersites,StringIO/IO#pos=/#seek/#truncate/#pwrite, seven socketrecv/readsites (all sign-checked already),Net::FTP#retrbinary,Benchmark.bmbm.Three findings the census turned up that the two reported sites did not cover
A negative size and an oversized one are indistinguishable to the backstop. Go's
makesliceuses the same panic text —"makeslice: len out of range"— forlen < 0and forlen > maxAlloc. So the backstop alone would report MRI'sArgumentErrorcase asNoMemoryError. 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.arraySpliceAssign(builtins.go:8129) is the same hazard without amake.a[2**40] = 1runsfor len(a.Elems) < start { append(..., NilV) }— it grows with nomaketo panic on, so the backstop only sees it oncegrowslicerefuses, 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.isAllocFaultdoes list"growslice:", so the eventual refusal is at least classified correctly.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: themakesucceeds, the loop is what hurts. Also out of scope here, and recorded rather than quietly skipped.Two census entries that are not defects
Hash.newwith a capacity: not implemented. There is nocapacity:keyword inrbgo, and everymake(map[K]V, n)in the package takeslen(...)/.Len()/a constant. So nothing is sized from it and there is no allocation hazard. (MRI 4.0.5 givesRuntimeError: st_table too bigforHash.new(capacity: 2**62)whererbgoreturns a Hash — a separate conformance gap, not an allocation one.)Integer#<<at a size the backstop cannot catch is MRI-conformant. The census flagged1 << (2**40)(~137 GB insidemath/big) as the worst remaining hole. A control says otherwise: MRI does the same thing. Bothrbgoandrubygrew 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'sNoMemoryError.)7. Benchmark
Array#*andString#*are hot, so the guards were measured, not assumed free. No benchmark existed for either;internal/vm/repeat_bench_test.goadds 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.BenchmarkArrayTimesSmallBenchmarkArrayTimesWideBenchmarkArrayTimesZeroBenchmarkStringTimesSmallBenchmarkStringTimesWideEverything but the zero-count case is inside the run-to-run spread on a loaded machine;
allocs/opis identical throughout, so the deferredrecoveradds no allocation.ArrayTimesZerois genuinely faster: the newn == 0short-circuit returns before allocating or looping.8. Conformance delta
scripts/conformance/rubyspec/run.shas CI invokes it (which builds andexecscmd/ratchetagainst the per-fileBASELINE— there is no scalar FLOOR), with an isolated corpus directory so no peer session's/tmpstate was touched.Delta attributable to this change: zero. Both pass. The
+29/+30is the baseline being stale onmain, not this branch's work —core/array/multiply_spec.rb: 13 → 14appears 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 givespass=8andpass=9on both. The cause is now known and is also pre-existing —Random#bytesreturns a string taggedUTF-8where MRI returnsASCII-8BIT(object.NewStringBytesinstead ofNewStringBytesEnc, inconsistent with the class-sideRandom.bytesthree lines away), so#sizevaries with whether the random bytes happen to form valid UTF-8. Identical before and after this change; worth its own issue.9. Verification
go test ./... -timeout=90m -racewith the CI-coverpkgset: see below.GOOS=wasip1 GOARCH=wasmbuild + vet,GOOS=js GOARCH=wasmbuild./...+ vet: all clean. One catch worth recording — the first draft of the new test reusedrunSrcErr, 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 ownrunKeepingError.gofmt -l .empty.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,NUM2LONGcount coercion,Array.new/Array#fillmessages, and theNoMemoryErrorbackstop per site.TestAllocPanicTextStillNamesMakeslice— the known positive the fault classifier is validated on: it provokes a realmakeslicepanic and asserts the text still carries the prefixisAllocFaultreads, so a Go rename is reported instead of silently sending every refused allocation back to anArgumentErrorwith 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 (insidemake([]T, 0, n)nothing butmakeslicecan 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