Skip to content

Follow the ensure codegen of mruby-compiler, and fix a pending return past the frame - #313

Merged
HirohitoHigashi merged 3 commits into
mrubyc:masterfrom
hasumikin:fix/op-rescue-non-exception
Sep 13, 2026
Merged

HirohitoHigashi merged 3 commits into
mrubyc:masterfrom
hasumikin:fix/op-rescue-non-exception

Conversation

@hasumikin

@hasumikin hasumikin commented Sep 10, 2026

Copy link
Copy Markdown
Member

This PR contains three patches.

Two of them catch up with a change in upstream mruby-compiler; the other fixes a long-standing bug in mruby/c itself.
They are in one PR because neither passes CI on its own.

Let OP_RESCUE answer false for a non-exception value

Follows mruby/mruby@b7df80f ("mruby-compiler: name the exception as $! in a rescue modifier and an ensure"), which picoruby picked up through its mruby-compiler sync.

The codegen now emits OP_RESCUE at the entry of an ensure clause to decide whether $! should name the exception it is unwinding:

EXCEPT   R3            ; nil on a normal entry
GETGV    R4  $!
OCLASS   R5
GETMCNST R5  ::Exception
RESCUE   R3  R5        ; R3.is_a?(Exception)?
JMPNOT   R5  ...
SETGV    $!  R3

So R[a] of OP_RESCUE is no longer always an exception: it is nil when the clause is entered normally, or a break/return passing through.
The mruby VM was changed accordingly (!mrb_break_p(exc) && ...), while op_rescue in mruby/c asserted MRBC_TT_EXCEPTION and aborted every begin ... ensure ... end on the normal path:

femtoruby: vm.c:1039: op_rescue: Assertion `mrbc_type(regs[a]) == MRBC_TT_EXCEPTION' failed.

This is backward compatible.
An mrbc from before that change (at mruby 4.0) emits OP_RESCUE only inside a rescue clause, where R[a] is always an exception, so the new check is always true there and the result is unchanged.

Carry a pending return's value in a box, not past the frame

This is an existing bug, independent of the compiler: bytecode from an mrbc (even in mruby 4.0) crashes the same way.

When a return has an ensure clause to run, sub_op_return parked the return value in regs[nregs], the register just past the current frame, and jumped to the clause.
But a method called from the ensure clause builds its frame exactly there, so anything but a C function in the clause clobbered the value:

def m
  return 42
ensure
  puts "bye"   # Kernel#puts is Ruby: its frame overlaps regs[nregs]
end
p m
# => class.h:277: mrbc_find_class_by_object: Assertion `!"Invalid value type."' failed.

This patch solves that by introducing release_pending_return() (see diff)

test/ensure_return_test.rb covers a method call in the ensure clause, a local rewritten by the clause, two ensure clauses, a nested pending return, a raise replacing the return, an object value, and $! (now it is set by mruby-compiler) in an ensure clause entered normally, by an exception, and by a break.

Add Object#__pat_values for a hash pattern's type check

Follows mruby/mruby@b2d2cd1 ("mruby-compiler: hold #deconstruct_keys to the Hash it has to answer"). The compiler now sends __pat_values to whatever #deconstruct_keys answered, even for a pattern with no keys, and relies on the receiver's class for the type check: Hash#__pat_values answers the values, Object#__pat_values raises TypeError: deconstruct_keys must return Hash.

Backward compatible as well: an older mrbc never sends __pat_values to anything but a Hash.

Since mruby b7df80fc4 the compiler emits OP_RESCUE at the entry of an
ensure to decide whether `$!` should name the exception. R[a] is then
nil on a normal entry, or a break/return passing through, neither an
exception, and the assertion in op_rescue aborted every `begin ...
ensure ... end` compiled by that compiler.

Check the type before asking mrbc_obj_is_kind_of, which cannot classify
those values. Bytecode from an older compiler is unaffected: it emits
OP_RESCUE only in a rescue clause, where R[a] is always an exception.
sub_op_return parked the value of a `return` that has an ensure clause
to run in regs[nregs], the register just past the frame. A method the
ensure clause calls builds its frame exactly there, so anything but a C
function in the clause clobbered the value, and the caller then read
garbage:

    def m
      return 42
    ensure
      puts "bye"   # Kernel#puts is Ruby: the frame overlaps regs[nregs]
    end

Keep the value in a heap box that the MRBC_TT_RETURN marker points to,
as OP_JMPUW keeps its target in `handle`. OP_RAISEIF takes it back and
returns through sub_op_return, which also covers the next ensure on the
way out, the initialize/super rules and the top level; the old path
asserted in mrbc_pop_callinfo on a top-level return.

A raise or break in the ensure clause abandons the return and leaves
the marker in a register, so mrbc_pop_callinfo and mrbc_vm_end release
the box with the register.

The new test also covers `$!` in an ensure clause, which the previous
commit made reachable.
Since mruby b2d2cd15e2463bcc4941b5a293802f3a9a83ae96 the compiler
sends __pat_values to whatever #deconstruct_keys answered, even for a
pattern that names no keys, and relies on the receiver's class to make
that the type check: Hash answers the values, and Object raises the
TypeError CRuby raises for a hook that answers anything else.

mruby/c only had Hash#__pat_values, so a hook answering nil or a
Symbol ended in a NoMethodError that leaked the helper's name:

    undefined local variable or method '__pat_values' for NilClass

Add the Object side with the same message as mruby. The test that
expected a nil answer to read as a failed match now expects the
TypeError, which is what CRuby and mruby raise, and a non-Hash answer
is covered too.
@HirohitoHigashi

Copy link
Copy Markdown
Member

I generally agree with this proposal.
After checking the opcode output for exceptions and ensure blocks in the latest mrbc, I found a few differences compared to version 4.0.
While I support merging/accepting this now, we should probably review the overall exception handling flow down the line.

@HirohitoHigashi
HirohitoHigashi merged commit ca1a19d into mrubyc:master Sep 13, 2026
15 of 16 checks passed
@hasumikin
hasumikin deleted the fix/op-rescue-non-exception branch September 13, 2026 10:13
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.

2 participants