Skip to content

An internal raise carries $! as its cause, through BOTH of its doors - #907

Merged
tannevaled merged 1 commit into
mainfrom
internal-raise-cause
Nov 7, 2026
Merged

tannevaled merged 1 commit into
mainfrom
internal-raise-cause

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes the last two language/send_spec.rb failures from #799 that are not the parser's: pass=75 fail=3 → pass=77 fail=1.

begin
  begin
    raise "orig"
  rescue
    nil.no_such_method
  end
rescue Exception => e
  e.cause   # rbgo: nil      MRI: #<RuntimeError: orig>
end

⛔ It was never a missing feature — it was two unstamped doors

autoCause, applyRaiseCause, curExc, @__cause__, an explicit cause: and a cycle walk were all already here, and Kernel#raise linked causes correctly. Only an internal raise did not, and internal raises take two different routes:

route when stamped
raiseWithIvars an internal raise carrying data — NoMethodError#name, #receiver, #args at the raise, where MRI reads it
raise() helper panics with a RubyError struct, no object where exceptionObject builds one

My first attempt stamped only the second and changed nothing observable. The dispatcher's NameError/NoMethodError come through raiseWithIvars, so exceptionObject returned at its e.Obj != nil line and the new call was unreachable — and the comment I had written there asserted the opposite.

A three-shape probe localised it in one run, rather than reasoning about it:

before
A Kernel#raise inside a rescue cause set ✓
B internal raise inside a rescue nil
C internal raise via a method call nil

A passing is what mattered: it proved autoCause and curExc were both fine, so the fault had to be reachability, not mechanism.

Bibliography

exc_setup_message takes the cause from get_ec_errinfo(ec) when none was given; exc_setup_cause declines a self-cause (cause != exc) and terminates the chain; a bare re-raise gets nil; a cycle raises ArgumentError: circular causes. autoCause already encoded the first three — another reason this is a routing fix and not a feature.

Measured

cmd/oracle, nine rules, three engines — reference, control (main at ac87a757), treatment:

ruby@4.0 vs rbgo-ctl906:  stdout 394 vs 336 bytes
ruby@4.0 vs rbgo-trt907:  AGREES, byte for byte

The control differs on exactly three: an internal raise inside a rescue, a two-link chain, and an internal raise inside an ensure.

⛔ The negative rules are the point

Stamping a cause on every internal raise is how this change goes wrong, and no positive row would notice. Measured on 4.0.7 and asserted:

written cause
no enclosing rescue nil
after the rescue has finished nil
a bare re-raise (the same exception) nil — no self-cause
an explicit cause: wins over $!
an explicit cause: nil suppresses the automatic one

⛔ Both stamps are load-bearing, and the partition is exact

A second stamp that changed nothing would be a rule nobody authorised, and "both work" cannot show that. Each was disabled in turn against the new test:

disabled fails passes
raiseWithIvars' stamp the 4 object-carrying rows the 2 objectless
exceptionObject's stamp exactly the other 2 the 4 object-carrying

Disjoint and complementary.

⛔ The corpus sweep is running

Every internally-raised exception inside any rescue now carries a cause — corpus-wide, and far wider than the two examples this closes. specdelta -dir core,language -move none -timeout 5m against ac87a757 is running and will be posted here with its KILLED on timeout count: non-zero means the clock decided a count and the run is void.

11 table rows in internal/vm/internal_raise_cause_test.go. go test ./...: 14 packages ok, 0 failures.

Refs #799

🤖 Generated with Claude Code

language/send_spec.rb: pass=75 fail=3 -> pass=77 fail=1. The two examples
"raises NameError with $! as a cause" and "raises NoMethodError with $! as a
cause" now pass; the one left is the SyntaxError for a literal block plus a
block argument, which is the parser's.

## It was never a missing feature

autoCause, applyRaiseCause, curExc, @__cause__, an explicit `cause:` and a
cycle walk were all already here, and Kernel#raise linked causes correctly. An
INTERNAL raise did not, and there are TWO doors:

    raiseWithIvars   builds its object up front to carry @name/@receiver/@Args
                     -> stamped at the RAISE, which is where MRI reads it
    raise() helper   panics with a RubyError STRUCT carrying no object
                     -> stamped where exceptionObject builds one

My first attempt stamped only the second and changed NOTHING OBSERVABLE: the
dispatcher's NameError/NoMethodError come through raiseWithIvars, so
exceptionObject returned at its `e.Obj != nil` line and the new call was
unreachable. Localised by probing three shapes rather than by reasoning:

    A  Kernel#raise inside a rescue    cause set     <- already worked
    B  internal raise inside a rescue  cause=nil
    C  internal raise via a method     cause=nil

A passing proved autoCause and curExc were both fine, so the fault had to be
reachability, not mechanism.

## Bibliography

exc_setup_message (eval.c at v4.0.9) takes the cause from get_ec_errinfo(ec)
when none was given; exc_setup_cause declines a self-cause (`cause != exc`) and
terminates the chain; a bare re-raise gets nil; a cycle raises ArgumentError
"circular causes". autoCause already encoded the first three.

## Measured

cmd/oracle against /opt/homebrew/opt/ruby@4.0/bin/ruby (ruby 4.0.7, +PRISM),
nine rules, three engines: the treatment AGREES byte for byte, the control
differs on exactly three -- an internal raise inside a rescue, a two-link
chain, and an internal raise inside an ensure.

## ⛔ The negative rules are the point

Stamping a cause on EVERY internal raise is how this goes wrong, and no
positive row would notice. Measured and asserted:

    no enclosing rescue                      cause=nil
    after the rescue has finished            cause=nil
    a bare re-raise (same exception)         cause=nil   (no self-cause)
    an explicit cause:                       wins over $!
    an explicit cause: nil                   suppresses the automatic one

## ⛔ Both stamps are load-bearing, and the partition is exact

A second stamp that changed nothing would be a rule nobody authorised, and
"both work" cannot show that. Each was disabled in turn:

    without raiseWithIvars' stamp   the 4 object-carrying rows FAIL, the 2
                                    objectless ones pass
    without exceptionObject's       exactly the other 2 fail

Disjoint and complementary.

11 table rows in internal/vm/internal_raise_cause_test.go. go test ./... : 14
packages ok, 0 failures.

Refs #799

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled

Copy link
Copy Markdown
Contributor Author

The corpus sweep, as promised — and the corpus had already named this defect

PROVENANCE
  control   rbgo-ctl907  — built from ac87a75752a3   (main, with #906)
  treatment rbgo-trt907b — built from b48c394e6443   (this branch)
MOVE none declared: both engines read the same corpus, so every delta below is the ENGINE

file                                                     control   treat  delta
language/send_spec.rb                                         75      77     +2
core/exception/cause_spec.rb                                   3       4     +1
core/random/bytes_spec.rb                                      8       9     +1

2206 file(s): control 23699 passing, treatment 23703 passing, +4
3 file(s) moved, 0 regressed

-dir core,language -move none -timeout 5m. Clean on every channel: KILLED on timeout 0, regressed 0, example(s) WENT RED 0, UNSTABLE 0, tally-mismatch 0.

⛔ The honest figure is +3, not +4

core/random/bytes_spec.rb is the file documented as answering 8 or 9 on the same binary. Here it drew 9 for the treatment and 8 for the control — a +1 in this change's favour. Earlier today the same file cost me a false -1 on #904, and a flake that flatters is exactly as false as one that accuses, so it was judged the same way:

$ specdelta -dir core/random,core/exception -repeat 3 …
core/exception/cause_spec.rb    3   4   +1
core/random/bytes_spec.rb       8   8   +0  ⛔ UNSTABLE: an engine disagreed with itself, delta excluded

49 file(s): control 267 passing, treatment 268 passing, +1
1 file(s) moved, 0 regressed

So: +2 in send_spec, +1 in cause_spec, and the third mover is noise. The +4 in the full run is three real gains plus a coin flip.

⭐ core/exception/cause_spec.rb named this defect before I did

The example that stopped failing:

Exception#cause | is set for internal errors caused by user errors
    wrong cause: got nil, want #<RuntimeError: cause>

"internal errors" — the corpus had an example for exactly the distinction this PR is about (Kernel#raise was fine, internal raises were not), in a file nobody pointed me at and which #799 never mentions. I found the defect through language/send_spec.rb instead.

That is now the third time in this arc the rule was already stated somewhere I was not reading: #904's parenthesised-call rule had an example in block_spec, def_spec and lambda_spec as well as send_spec. Worth saying plainly: these defects were not obscure, they were unlooked-for, and the cheap instrument is a corpus-wide sweep rather than a closer reading of one file.

What is still failing in send_spec

One example, and it is the parser's rather than the VM's:

raises a SyntaxError with both a literal block and an object as block
    expected SyntaxError, got NameError

rbgo accepts f(&blk) { } silently — not a missing SyntaxError but an undefined choice of which block wins. Separate from this PR; #905 is the neighbouring parser defect found the same way.

language/send_spec.rb over this arc: pass=73 fail=5 → pass=77 fail=1, against a table that credited it with 1.

🤖 Generated with Claude Code

@tannevaled
tannevaled merged commit e8d3abe into main Nov 7, 2026
26 checks passed
@tannevaled
tannevaled deleted the internal-raise-cause branch November 7, 2026 20:29
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