Exception handling in monoruby — mechanism and CRuby contrast
How monoruby raises, unwinds, catches, and reports exceptions, and how the
design differs from CRuby. The through-line is laziness: monoruby stores
the minimum at raise time and defers the expensive work (exception-object
materialization, backtrace string formatting) until something actually asks
for it. This keeps the raise path — including the control-flow “pseudo
exceptions” (return from a block, break, throw, retry, and internal
StopIteration) that reuse the same machinery — cheap.
Primary sources:
../monoruby/src/globals/error.rs—MonorubyErr,MonorubyErrKind, backtrace formatting.../monoruby/src/executor.rs—set_error/take_error,$!handling,take_ex_obj(materialization),complete_backtrace_for_rescue, ensure deferral.../monoruby/src/codegen/jit_module.rs—handle_error(the unwinder).../monoruby/src/globals/store/iseq.rs— the per-method exception table (get_exception_dest,covering_ensure,errinfo_restore_slots,nonlocal_exit_needs_vm_unwind).../monoruby/src/builtins/exception.rs,../monoruby/builtins/startup.rb— the Ruby-visibleExceptionAPI (#backtrace,#backtrace_locations,#set_backtrace,#cause, …).
1. The big picture
raise / error in a builtin or VM op
│ vm.set_error(MonorubyErr) (executor.rs:1074)
▼
error sentinel returned ──► entry_raise ──► handle_error(vm, globals, meta, pc)
│ (jit_module.rs:85)
┌───────────────────────────────────────────────┤
│ For the *current* frame: │
│ 1. dispatch control-flow kinds early │
│ (MethodReturn / Throw / BlockBreak / │
│ Retry / Redo) — may resume or redirect │
│ 2. push this frame's (loc, sourceinfo, fid) │ ← incremental
│ onto err.trace │ trace capture
│ 3. consult the frame's exception table: │
│ • rescue dest? → complete backtrace, │
│ materialize object, goto rescue │
│ • ensure dest? → defer unwind, goto │
│ ensure │
│ • neither? → return error to caller │
└───────────────────────────────────────────────┘
│ unwind one frame, re-enter handle_error
▼
… up to the top level (main.rs) if never caught
handle_error runs once per frame as the exception unwinds. There is no
separate “raise” bytecode that snapshots the whole stack; the stack is
recorded incrementally, one frame at a time, as control leaves each frame.
CRuby contrast
CRuby captures the backtrace eagerly at raise time
(rb_ec_setup_exception → rb_vm_get_backtrace walks the whole control-frame
stack and stores it on the exception object) before unwinding starts. That is
simple and makes #backtrace a stored-field read, but it pays the full
stack-walk cost on every raise — including the many raises that are caught
immediately and whose backtrace is never inspected. monoruby instead pays only
for the frames it actually unwinds through, defers the caller frames to the
catch point, and defers string formatting to #backtrace.
2. MonorubyErr — the in-flight error
MonorubyErr (error.rs:9) is the value held in Executor.exception while an
error is propagating. It is a Rust struct, not a Ruby object:
| field | purpose |
|---|---|
kind | MonorubyErrKind — the error class / control-flow tag (see §3) |
message | the message string |
trace | Vec<(Option<(Loc, SourceInfoRef)>, Option<FuncId>)> — the backtrace, built incrementally as cheap tuples (no strings) |
original | when re-raising an existing exception object (raise exc), that Value, so identity + ivars survive |
explicit_cause | an explicit cause: keyword (Some(nil) for cause: nil) |
payload | kind-specific extra data surfaced as hidden ivars on materialization (e.g. LocalJumpError#exit_value, StopIteration#result) |
The exception object (RVALUE of class RuntimeError, etc.) is not
created here. It is materialized lazily by take_ex_obj (§8) only when a
rescue actually binds it or the top level needs to print it. Deferring
Value allocation is the first half of the laziness story.
MonorubyErr::mark (error.rs:91) participates in GC: while an error is in
flight it is not a Ruby object, so the GC cannot reach the Values it smuggles
(original, explicit_cause, payload, and the receiver/tag/value payloads
of a few kinds) through the normal object graph — mark roots them explicitly.
3. Two families of MonorubyErrKind
MonorubyErrKind (error.rs:1111 and above) mixes two conceptually
different things into one enum, because monoruby routes both through the same
unwinder:
3a. Real exceptions (catchable by rescue)
Runtime, NotMethod, Name, Type, Index, Key, Frozen, Load,
Range, DivideByZero, StopIteration, SystemExit, IO, Arguments,
Syntax, Other(ClassId) (any user-defined subclass), … Each maps to a Ruby
exception class via from_class_id (error.rs:1143) / a class id, and each
may carry structured data (e.g. NotMethod { name, receiver }) that becomes
hidden ivars on the materialized object.
3b. Control-flow pseudo-exceptions (NOT ordinary rescue targets)
These reuse the unwinding machinery to implement non-local control flow, the
same way CRuby uses its throw/catch-table TAG_* mechanism:
| kind | Ruby construct | how it stops unwinding |
|---|---|---|
MethodReturn(val, lfp) | return from a block/proc/lambda | stops at the target frame lfp |
BlockBreak(val, fid, lfp) | break out of a block | resumes the block’s defining call, or degrades to LocalJumpError |
Throw(tag, val) | Kernel#throw / Kernel#catch | intercepted only by a matching catch, never by rescue |
Retry | retry in a rescue clause | redirected to the begin-region start |
Redo | redo in a loop | redirected to the loop body start |
Fatal | a Rust panic! caught at an extern "C" boundary | uncatchable — propagates straight to the top level |
The crucial property, exploited for performance, is that handle_error
dispatches every control-flow kind before it touches err.trace
(jit_module.rs:118–238, all ahead of the push_error_location at
jit_module.rs:242). A MethodReturn / Throw / BlockBreak therefore
never accumulates a backtrace tuple and never materializes an exception object.
return from a block and break are as cheap as they can be while still
threading through ensure bodies correctly.
CRuby contrast
CRuby likewise implements return/break/next/redo/retry/throw with
its internal tag mechanism rather than real exceptions, and likewise does not
build a Ruby backtrace for them. Fatal corresponds to CRuby’s rb_fatal /
uncatchable fatal class. The taxonomy is deliberately parallel; monoruby just
folds it into one Rust enum.
4. The unwinder: handle_error
handle_error (jit_module.rs:85) is the heart of the mechanism. For the
current frame’s FuncKind:
ISeq (Ruby) frames:
-
Retry/Redo (
jit_module.rs:106) — take the error andgotothe begin-region / loop start encoded in the instruction. No trace, no object. -
MethodReturn (
jit_module.rs:118) — if this frame is the targetlfp, return the value here; if anensuresits in the way, defer the unwind across it; otherwise keep propagating.$!is restored from the region-entry save on the way out (restore_errinfo_on_exit). -
Throw (
jit_module.rs:157) — run any interveningensure, else keep propagating (a matchingKernel#catchframe consumes it).These three non-local-exit arms ask
covering_ensure(pc), notget_exception_dest(pc): theensureto run is the innermost covering region that has one, which is not always the innermost covering region. Nest a rescue-onlybegininside anensureregion and the tightest entry carries noensure, so askingget_exception_destconcluded there was none and skipped the body outright (#1185). Running the innermost ensure-bearing region chains the rest by itself — itsEnsureEndre-delivers the exit from a pc outside that region, where the next one out is now innermost. -
BlockBreak (
jit_module.rs:173) — at the block’s defining frame, if the in-progress call site is the one that received this block, resume it with the break value (CRuby’sBREAKcatch table); otherwise degrade toLocalJumpError(“break from proc-closure”). -
Incremental trace capture (
jit_module.rs:242) —push_error_location(loc, sourceinfo, fid)appends this frame’s cheap tuple. Only real exceptions reach here. -
Fatal (
jit_module.rs:247) — never caught; skiprescue/ensure, propagate to the top. -
Exception table lookup (
jit_module.rs:251) —get_exception_dest(pc)returns(rescue_pc, ensure_pc, err_slot)for the innermost region coveringpc:- rescue → call
complete_backtrace_for_rescue(§7), materialize the object withtake_ex_obj, store it into$!and the handler’s error slot, andgotothe rescue clause. - ensure →
defer_unwind(§6) andgotothe ensure body. - neither →
return ErrorReturn::return_err(), unwinding one frame; the caller re-entershandle_error.
- rescue → call
Builtin (native) frames (jit_module.rs:265): only the control-flow kinds
that can pass through a builtin are handled (MethodReturn, Throw,
BlockBreak); a real exception records an internal trace frame
(push_internal_error_location, no source location — printed as <internal>)
and unwinds. Builtins have no Ruby-level rescue.
The exception table itself is built by bytecodegen and stored per method
(iseq.rs:504). Entries nest innermost-first, so get_exception_dest returns
the tightest enclosing region — right for a raise, which the tightest rescue
catches, but not for a non-local exit, which wants the tightest ensure (see
arm 2 above).
CRuby contrast
CRuby’s unwinder (vm_exec_handle_exception / the catch_table on each ISEQ)
is structurally the same idea: a per-ISEQ table of (type, start, end, cont, sp) entries scanned as the stack unwinds, with CATCH_TYPE_RESCUE,
ENSURE, RETRY, BREAK, REDO, NEXT. monoruby’s ExceptionMapEntry
plays the role of a catch_table entry; ErrorReturn::{goto, return_err, return_normal} plays the role of CRuby’s THROW_DATA / continuation.
5. $! (errinfo) and the deferred-unwind stack
Executor.errinfo holds Ruby’s $! — the exception currently being handled —
and is set when a rescue catches (set_errinfo, executor.rs:1092). Because
control can leave a frame while it is suspended inside a rescue clause (a
return/break jumping out mid-handler), the region-entry value of $! is
saved into a bytecode slot, and restore_errinfo_on_exit
(jit_module.rs:72) replays those saves (outermost wins) when such a frame is
exited. errinfo_restore_slots (iseq.rs:543) enumerates the relevant slots.
Only the generic unwind replays them, so a frame owing one cannot take the
JIT’s specialized teardown — ISeqInfo::nonlocal_exit_needs_vm_unwind pairs
that condition with covering_ensure as the two reasons a non-local exit has
to go through handle_error at all. A protected region that is neither (a
plain begin..rescue the exit merely passes through) contributes nothing: a
rescue does not intercept a non-local exit.
6. ensure and deferred unwind
ensure complicates unwinding because the ensure body must run with an empty
error slot (so it can itself raise/return), yet the original in-flight
error must be re-raised afterwards unless the body overrides it. monoruby models
this with a deferred-unwind stack (executor.rs:1102–1153):
defer_unwind(lfp)moves the in-flight error out ofexceptionand stashes it keyed by frame, thengotoes the ensure body.finish_ensure(lfp)(theEnsureEndhook) re-raises the deferred error — unless the ensure body left a new error pending, in which case the new one wins (CRuby: araise/return/throwinsideensuresupersedes).discard_deferred_unwind(lfp)drops a deferral when the frame leaves by some other path so itsEnsureEndwill not consume it.
This mirrors CRuby’s CATCH_TYPE_ENSURE continuation plus the “ensure result
overrides pending throw” rule.
Note that bytecodegen compiles the body once per edge, which is what makes
the compiled EnsureEnd cheap (below). A begin..ensure without rescue
still gets a rescue_pc: it names a second copy of the body that ends in
raise rather than EnsureEnd.
def m(a); begin; a * 2; ensure; $n = 1; end; end
[(:00002..:00004, rescue=:00005, ensure=:00008, err_slot=%4)]
BB1 :00005 %5 = 1 / :00006 $n = %5 / :00007 raise %4 <- the exception edge
BB2 :00008 %4 = 1 / :00009 $n = %4 / :00010 ensure_end <- the normal edge
So an exception takes BB1 and never reaches BB2’s EnsureEnd; a non-local exit
takes handle_error’s goto(ensure) into BB2 in the VM; and compiled code
falls into BB2 only on normal completion.
6.1 The compiled EnsureEnd’s gate
EnsureEnd asks one question — “is a deferred unwind parked for this frame?” —
and for JIT-compiled code the answer is always no. defer_unwind has five call
sites: three in handle_error, each immediately followed by
ErrorReturn::goto(ensure), which resumes the VM, and two in the splice helpers
(§6.3), which pair with ensure_end_spliced rather than with this call. The
exception edge cannot arrive either, per the two-copy layout above.
It was nevertheless a runtime call on the normal path of every compiled
ensure region — measured at about 40 cycles per execution: a trivial
begin..ensure made a hot method 63% slower than the same method without
one. It now sits behind the same one-word mirror emit_ret tests
(Executor::deferred_top_lfp, #1186): a compare and a not-taken branch.
The gate is conservative rather than an elision — when the mirror does name
this frame it runs exactly the old sequence — which matters because compiled
code demonstrably can run with a deferral parked: a loop inside an ensure
body entered by an unwind can be re-entered by OSR, which is why emit_ret
carries the same gate. Worth about 10% on a hot method with a trivial
ensure, and 14% on §6.3’s find-first shape.
6.2 The region-entry $! save
A protected region saves $! at its entry so a non-local exit leaving a
rescue clause can put it back (§5). That save read $! through the generic
hooked-global runtime call, once per invocation of every method carrying a
begin..ensure or begin..rescue:
:00001 %2 = $(errinfo)
mov rdi,rbx / mov rsi,r12 / mov edx,0x6a before
movabs rax,<runtime::get_global_var> / call rax
mov rax,QWORD PTR [rbx+0x198] after
$! is a plain Value field of the Executor and $(errinfo)’s hook is
Some(vm.errinfo()), so AsmInst::LoadErrinfo is the same read — with the
call’s FP save set and GP flush gone with it. The load is equivalent only
because rbx is the current Executor, which is what $! is per (CRuby
keeps errinfo per execution context, and so does monoruby), so a Fiber or
Thread reads its own.
Only the internal name is specialized. $(errinfo) is what bytecodegen emits
and is not a name Ruby’s parser can produce, so no program can alias it,
trace_var it or otherwise put a hook in the way; user-written $! reads keep
the generic path. The name is pre-interned as
IdentId::GVAR_ERRINFO_INTERNAL so the recognition is an integer compare
rather than a lock and a string hash.
Worth about 28% on a hot method with a trivial ensure (0.663-0.675 s ->
0.475-0.486 s over three runs). Together with the gate above, the surcharge for
putting a begin..ensure around a hot method’s body falls from +63% to +23%
over the same method without one.
6.3 Spliced non-local exits (issue #1185)
A break / non-local return whose whole chain is specialized-inlined into
one JIT unit lowers to the specialized teardown (lea rbp += Σ; leave; ret) — three instructions, no handle_error. An ensure on the way out
used to disqualify that outright: nonlocal_exit_needs_vm_unwind (§5) sent
the exit down the generic unwind, which interprets the ensure bodies,
converts the suspended frames by the chain-deopt walk, and — for break —
leaves the defining frame in the VM until the next loop_start re-enters by
OSR.
Splicing keeps the teardown and reaches the ensure body as ordinary
compiled code: the exit defers its unwind exactly as handle_error
would (defer_block_break_at / defer_method_return_at), and the region’s
EnsureEnd delivers it — ensure_end_spliced classifies the parked
deferral and the compiled arm runs the teardown for that kind. The gain is
that the unwind edge now exists in the CFG, so the ensure’s writes are
visible to the abstract interpreter instead of happening behind its back.
This was built in two stages, by which frame owns the region. Stage 1
(SplicePlan::SameFrame, #1187) handled the exit’s own frame, where the
body is a block of the iseq being compiled and the exit is an ordinary
forward branch to it. §6.5 removed the need for it: the exit now replays
its own frame’s bodies inline and crosses no region of its own, so
covering_ensure never names the current frame at an exit’s pc.
Measured over the whole test suite, stage 1 went from 304 splices to 0, and
it was deleted; try_splice_exit still refuses a same-frame host rather
than assuming, in case bytecodegen ever stops replaying. What remains is:
An intermediate frame (stage 2). The owner is a suspended frame: its
compile is parked at the call that leads to the exit, and its ensure body
is several machine frames away, so there is no branch to emit. The splice
travels by the machine’s own return path instead:
AsmInst::SplicedExitToOuter, at the exit, builds the error wherevm.cfp()is still the exiting frame (that is what resolves abreak’s target) but keys the deferral on the host frame’s LFP, read from the frame chain —defer_block_break_at/defer_method_return_at.- The same instruction then sets rbp to the frame the host called and
leave; rets. That lands at the host’s call site withSplicedExitKind::outer_tag()in the return register — a value no normal return can produce (low three bits000, and noRValuelives at address 8 or 16). AsmInst::SplicedExitLanding, emitted after that call whenever a nested compile asked for one, recognizes the marker and branches into the host’sensurebody. It is an ordinary side branch of the host’s own CFG, so the body’s entry merge sees this path exactly as it sees the normal fall-through.
The landing edge’s state is the host’s state right after the call, which is
what the ret really arrives at, with two corrections: every widen the call
reached is re-applied (the resume adopts the return join’s kept claims,
and a spliced exit is by definition not a returning path), and every temp
still void at the call is claimed as a boxed Value in its slot (a temp the
ensure body keeps live may only be written later in the begin body; the
prologue nil-fills the frame, so the claim is true, and nothing on this path
reads it).
Two things are re-proved at the exit rather than inherited, both by
defer_*_at and both before anything is torn down, so a refusal is the
generic raise from the exit’s own pc with every frame still in place:
- the call site’s own capture guard (
immediate_evict) is emitted after the landing, sodefer_*_atchecks the host’sMetafor the two bitsbranch_if_capturedtests and degenerates when the callee promoted the host frame to the heap; - the exit’s target. The JIT laid the chain out statically — a
breakreturns into the defining frame one below the popped iter frame, areturnreturns from the popped home method — and handsdefer_*_atthat frame’s LFP (SplicedExitToOuter::expect, read off the chain at a static rbp offset exactly as the host’s is). The runtime resolved the same target from the frame’s current style (err_block_break/err_method_return: a block promoted to a lambda breaks locally, adefine_methodbody catches areturnthe static walk passed through), and the splice proceeds only when the two agree. A refusal here matters more than it looks: the runtime target may be one of the very frames the hop would pop, and a mismatch found only at the host’sEnsureEndwould have nowhere left to deliver.
A degenerate error (LocalJumpError out of a proc-escaped block) takes the
same exit. With the target settled at the exit, finish_ensure_spliced
classifies a parked deferral by kind alone.
That is not where the check started. It used to sit at the EnsureEnd, as a
comparison against the host’s own outer() / outermost() — the relation the
exiting block’s frame satisfies (stage 1) and one an intermediate method
host never does, having no outer. So every stage-2 delivery took the
re-raise instead of the arm: handle_error, the chain-deopt walk, the VM and
an OSR re-entry, on top of the deferral. Instrumented over the whole test
suite, codes 2 / 3 had never once been returned. Measured on the 1-host shape
with a trivial body so the machinery is what is timed, the hop went from
~270 ns to ~105 ns per exit once deliveries took the arm, and the
surcharge over the same loop with no ensure from 1.62× to 1.20×.
The spliced EnsureEnd also sits behind the same one-word deferral gate as
the plain form (§6.1): a host’s normal completion reaches it far more often
than a spliced exit does, and with nothing parked for the frame the dispatch
could only have answered “continue”.
With the hop at ~100 ns, chaining pays: a break crossing two
intermediate ensures went from 1.28 s (the generic unwind) to 0.83 s on the
same 1.28M-exit benchmark whose no-ensure baseline is 0.63 s and whose
one-host time is 0.75 s — the second hop costs about what the first does,
where the generic unwind’s second region had cost ~0.08 s on top of its
~0.35 s entry. That arithmetic is why the hop had to be made cheap first: at
the ~270 ns it cost before deliveries took the arm, a second hop would have
lost to the generic path.
More than one ensure on the way out chains hop by hop. try_splice_exit
collects every host the unwind crosses (innermost first) and, once each has
passed the checks below, records on each host’s EnsureEnd what to do with
the exit once its body has run (SpliceHop): every host but the last hands
it on — SplicedArm::Hop re-keys the deferral on the next host (the mirror
follows, so that host’s EnsureEnd gate sees it), tears down to the frame the
next host called and rets the marker into its landing, which is the tail of
the exit’s own hop run from the EnsureEnd instead — and the last host
delivers through the teardown arm as above (SplicedArm::Final). Each
landing is requested on its host at the exit, all at once; hosts emit them as
their own compiles resume, innermost host first, which is the order the
recursion unwinds in anyway. The exit registers one return context, at the
target, from the last host; a hand-on registers none, as the exit’s own hop
does not — the next host’s landing is a branch edge of that host’s CFG. A
next host that can no longer be entered by compiled code (captured to the
heap since) puts the error back in flight at that EnsureEnd, and the generic
unwind resumes from there with every frame below it still intact.
A host’s arm is static — one destination per kind — so an exit that would
route a kind through a host somewhere other than an exit already recorded is
refused (spliced_ensure_conflicts; the second route used to be silently
overwritten, a latent stage-2 bug with one host too).
The route also carries what the exit claimed about its value. The value
the delivering EnsureEnd hands over is the very one the exit left with (it
rides the deferral unchanged), so the ReturnState the exit’s own state made
(as_return: the constant it is, or its class) holds at delivery. Each exit
routed through a host joins its claim into the route, and the delivering host
registers the joined claim — under its own invariants, since the ensure
bodies ran in between — as the exit’s return context at the target
(as_return_like). The target’s continuation therefore learns the class or
the constant exactly as it would from a plain specialized break / return,
and a spliced exit whose value agrees with the normal return path no longer
collapses that join to Value. (It used to register as_return_any.)
try_splice_exit refuses everything it cannot prove: a dispatch arm, a
loop-rooted frame (whose compile may not cover the body), a $! restore
owed anywhere on the way out, a conflicting route through a host, a body that
is not a basic-block head, a body containing an exit of its own (next /
break / return / retry / redo, which would leave the deferral parked
past the frame) or a nested handler, and a host whose in-progress call site is
not one of the two shapes that emit a landing. Every refusal falls back to the
generic unwind, which handles every case.
Measured on the shape the issue names — a break-with-ensure that is the
normal exit of an inner iteration inside a hot loop in the block’s defining
frame — this is worth roughly 10% (0.79–0.83 s → 0.69–0.77 s over
repeated runs). The same exit without the ensure runs in 0.35 s, so most
of what is left is the deferral machinery itself — two runtime calls and a
MonorubyErr per exit — rather than the unwind the splice removed. On a
chain that merely tears down (no hot continuation to return to) the
difference is within noise.
6.4 Replayed ensure bodies and the exception table
The two copies above are not the only ones. A non-local exit written inside
a region — a local return, a loop break / next / redo, retry —
does not go through handle_error at all: bytecodegen replays the bodies of
every region the exit leaves inline, innermost first, immediately ahead of
the exit instruction (gen_all_pending_ensures, gen_loop_pending_ensures).
Those inline copies sit lexically inside the very regions they replay, so the exception table covered them like any other code in the region. A copy that raised was therefore handed straight back to the region whose body was running, and the body ran a second time:
begin
begin
return :never
ensure
$log << :inner # ran twice; CRuby runs it once
raise "E"
end
ensure
$log << :outer
end
The generator already states the rule on its own side: while it emits the body
of the region at stack index idx, it truncates its ensure stack to
ensures[..idx], so a return written in an ensure body does not
re-generate that body (and begin return 1 ensure return 2 end returns 2, as
in CRuby). The table now says the same thing at run time.
Each region gets an id (BytecodeGen::new_region_id), carried by every
exception-table entry it emits and by its entry on the ensure stack. Each
replayed copy is recorded as a replay span — a BcIndex range plus the
ids of the regions it runs outside of: the one whose body it is, and the inner
ones the exit has already replayed. ISeqInfo::active_entries drops those
entries, and every “which regions are in force at this pc” lookup goes through
it (get_exception_dest, covering_ensure, single_covering_ensure), so the
raise path and the non-local-exit path agree.
Ids rather than nesting depths, because a begin written inside an ensure
body is a region of its own and must keep catching:
begin
return :done
ensure
begin
raise "E"
rescue => e # still catches
end
end
Bodies are generated once per copy, so that nested region gets a fresh id in each copy and never collides with the region being replayed — where a depth count would, since the generator’s truncated stack gives it the same depth as the region whose body it is in.
Spans nest, too (an ensure body may hold an exit that replays further bodies),
so a pc is checked against every span covering it, not just the innermost.
errinfo_restore_slots takes the same cut for the same reason — a replayed
body is preceded by its region’s $! restore, so restoring again on the way
out would undo whatever the body did to $! — but it cannot go through
active_entries, because it is keyed on the rescue clause spans rather than
the region spans. It applies the replay-span filter directly
(ISeqInfo::is_replayed_at).
6.5 Non-local exits replay their own frame inline (issue #1185)
A break out of a block and a non-local return were the last exits that left
the job to handle_error: the unwinder found the covering region, deferred the
exit, ran the body interpreted, and re-delivered it from EnsureEnd. That is
what §6.3’s splice machinery was built to compile around.
They now replay their own frame’s open regions inline, exactly as emit_ret
does for a local return — same set, same order, same $! protocol
(gen_method_return / gen_block_break → replay_ensures_for_nonlocal_exit).
With the bodies emitted ahead of the exit there is nothing left to compile
around: the exit crosses no region, the JIT lowers it to the plain specialized
teardown, and no deferral is created.
The exit value is generated before the replay and popped after it, so the
bodies take their temps above it and the exit instruction’s recorded sp is
unchanged — raising it by the value’s own slot changed JIT liveness even for
exits that replay nothing, and cost about 4%.
What stops the bodies running a second time is the §6.4 machinery, reused
rather than duplicated: emit_nonlocal_exit extends the spans that replay just
recorded over the exit instruction itself. handle_error is handed exactly
that pc, and asks the same “which regions are in force here?” question the
raise path asks — so covering_ensure finds nothing to run,
errinfo_restore_slots nothing to restore, nonlocal_exit_needs_vm_unwind
answers false, and try_splice_exit (which starts from
single_covering_ensure) declines on its own. No separate table, and no third
place to keep in sync.
A span is therefore recorded even when the body generated no code: an
ensure nil end still has a region the unwinder would otherwise run through
the VM. The empty span is inert until the exit extends it.
Measured on the break-inside-its-own-begin..ensure shape, with a trivial
body so the machinery is what is being timed: 0.389 s → 0.207 s against
0.185 s for the same loop with no ensure at all — a 2.1× surcharge down to
1.12×. With a body that does real work ($n += 1) the remaining gap is the
body: 0.690 s → 0.605 s. Standard benchmarks are unchanged.
7. Backtrace construction — the key contrast
This is where the laziness pays off and where the recent work (PR #896) focused. A backtrace has three cost components, and monoruby defers each:
(a) The raise→rescue frames. These are captured incrementally by
push_error_location as handle_error unwinds each frame (§4 step 5). They
must be captured during unwinding because those frames are destroyed as the
stack pops — they cannot be walked later. Cost: one 3-word tuple push per
frame, no string formatting.
(b) The frames above the rescuing frame (the rest of the live stack at
raise time). The incremental capture never sees these, because unwinding stops
at the rescuing frame. CRuby includes them (its eager snapshot walked the whole
stack). monoruby fills them in at the catch point with
Executor::complete_backtrace_for_rescue (executor.rs:complete_backtrace_for_rescue,
called from jit_module.rs:256 just before take_ex_obj):
#![allow(unused)]
fn main() {
// Walk the rescuing frame's callers via each inner frame's saved
// call-site pc — the same mechanism as Kernel#caller — appending the
// cheap (loc, sourceinfo, fid) tuples. No strings; formatting stays lazy.
}
Why the catch point, and not lazily at #backtrace time? Because it is the
last moment the full stack is coherent: the raise→rescue tuples are already
collected in (a), and the caller frames are still live (we are about to run a
rescue clause nested inside them). If we deferred this walk to #backtrace,
an exception object that escaped its rescue clause and was inspected later would
find those caller frames gone — yielding a truncated, wrong backtrace. CRuby
avoids the problem by snapshotting everything eagerly at raise; monoruby
snapshots the caller half at catch, which is strictly cheaper (only exceptions
that reach a real rescue pay for it) while remaining correct.
(c) String formatting. Fully deferred to Exception#backtrace
(exception.rs:backtrace), which turns the tuples into "file:line:in 'method'" strings and memoizes the resulting Array in the /backtrace
hidden ivar, so repeated calls return the same mutable object (matching
CRuby’s e.backtrace.equal?(e.backtrace) and e.backtrace.unshift(x)
visibility). #set_backtrace writes the same /backtrace ivar, unifying the
explicit store with the memo.
#backtrace_locations is intentionally decoupled from the string backtrace via
the __raise_backtrace intrinsic (raise-time capture only), so
set_backtrace(strings) on a never-raised exception keeps
#backtrace_locations nil, while an Array of Thread::Backtrace::Location
sets both — matching CRuby 3.4+.
Cost summary for the hot paths
| scenario | backtrace cost in monoruby |
|---|---|
return from block, break, throw | none — dispatched before trace capture (§3b) |
StopIteration caught by loop | a few tuple pushes only — loop catches at the Rust level (err.is_stop_iteration(), kernel.rs:908), so it never hits a bytecode rescue, so complete_backtrace_for_rescue and take_ex_obj are never called |
exception caught by a Ruby rescue | raise→rescue tuples + one caller-stack walk (tuples only); strings only if #backtrace is called |
| uncaught exception (top level) | full tuple trace; formatted once by the reporter |
CRuby contrast (backtrace)
- When captured: CRuby eagerly at raise; monoruby incrementally on unwind + once at catch.
- What is stored: CRuby a
rb_backtrace_t(frame snapshots); monoruby cheap(loc, sourceinfo, fid)tuples. #backtracestrings: both format lazily and memoize; monoruby in the/backtraceivar.- Control-flow tags: neither builds a Ruby backtrace for them.
- Frame labels: monoruby renders owners with their fully-qualified name
(
Ns::Cx.foo, special-casingObject#foo) infunc_description(../monoruby/src/globals/store.rs), matching CRuby’sNs::Cx.foo.
8. Materializing the exception object — take_ex_obj
take_ex_obj (executor.rs:1174) converts the in-flight MonorubyErr into a
Ruby Value, called only at a catch point or the top level:
- Re-raise (
err.originalset): return the same object, filling itstraceonly if still empty (CRuby assigns a backtrace only when the exception lacks one). - Fresh object: allocate
Value::new_exception(err)and attach kind-specific hidden ivars —LoadError#path,SystemExit#status,NoMethodError#{name,receiver},NameError#{name,receiver},KeyError#{receiver,key},FrozenError#receiver,LocalJumpError#exit_value#reason,StopIteration#result,SyntaxError#path, … Hidden ivars use/-prefixed names so they are excluded from#instance_variables.
- Cause chaining (
chain_cause,executor.rs:1335): an explicitcause:keyword wins; otherwise CRuby’sexc_setup_cause— if a different exception is currently being handled ($!), record it as/cause.cause: nilsuppresses the implicit chain.
CRuby contrast
CRuby builds the exception object at raise (it is the raise). monoruby’s
split — Rust MonorubyErr while in flight, Ruby object only at catch — is what
lets it skip object allocation entirely for the immediately-caught and
control-flow cases. The materialized object’s ivar layout and cause semantics
are kept CRuby-compatible.
9. Fatal errors
A Rust panic! caught at an extern "C" trampoline becomes
MonorubyErrKind::Fatal. is_fatal() (error.rs:1137) makes handle_error
skip both rescue and ensure and propagate straight to the top
(jit_module.rs:247), because VM/interpreter state may be inconsistent after a
panic. This matches CRuby’s uncatchable fatal — not interceptable even by
rescue Exception.
10. Top-level reporting
An exception that reaches the top uncaught is printed by the reporter in
error.rs (show_error_message_and_all_loc, error.rs:167): the message line
plus each caller frame as \tfrom <file>:<line>:in '<method>', honouring
--backtrace-limit=N (extra frames collapse into \t ... K levels...). The
compact single-location form (show_error_message_and_loc) is used where CRuby
prints only the origin (e.g. SyntaxError, which also gets a source excerpt).
11. File map
| concern | location |
|---|---|
| in-flight error type + kinds | ../monoruby/src/globals/error.rs |
| unwinder | ../monoruby/src/codegen/jit_module.rs (handle_error) |
set/take error, $!, ensure defer | ../monoruby/src/executor.rs |
| catch-time caller walk | ../monoruby/src/executor.rs (complete_backtrace_for_rescue) |
| object materialization + cause | ../monoruby/src/executor.rs (take_ex_obj, chain_cause) |
| per-method exception table | ../monoruby/src/globals/store/iseq.rs |
| JIT-spliced non-local exits (§6.3) | ../monoruby/src/codegen/jitgen/context.rs (try_splice_exit), jitgen/compile.rs (emit_spliced_exit), jitgen/compile/method_call.rs (emit_spliced_landing) |
| frame-label rendering | ../monoruby/src/globals/store.rs (func_description) |
Ruby Exception API (Rust side) | ../monoruby/src/builtins/exception.rs |
Ruby Exception API (Ruby side) | ../monoruby/builtins/startup.rb |
Kernel#raise / #loop / #caller | ../monoruby/src/builtins/kernel.rs |
| differential tests | ../monoruby/tests/backtrace.rs, tests/exception_api.rs |
| spliced-exit regression tests | ../monoruby/tests/nonlocal_exit_ensure.rs, tests/nonlocal_exit_intermediate_ensure.rs, tests/nonlocal_exit_rescue.rs |
EnsureEnd gate tests (§6.1) | ../monoruby/tests/ensure_end_deferral_gate.rs |
region-entry $! tests (§6.2) | ../monoruby/tests/errinfo_inline_load.rs |
12. Design summary
monoruby’s exception mechanism is CRuby-compatible at the Ruby surface
(rescue/ensure/retry/redo, Exception API, cause chaining, backtrace
format, uncatchable fatals) while diverging in when work happens:
- Raise stores the minimum — a Rust
MonorubyErrwith cheap trace tuples; no Ruby object, no formatted strings. - The stack is recorded incrementally on unwind, not snapshotted eagerly.
- Caller frames are completed once, at the catch point — the last coherent
moment — not eagerly at raise and not unsafely late at
#backtrace. - Control-flow constructs pay nothing for backtraces because they are
dispatched before trace capture, and internal
StopIteration(vialoop) is caught at the Rust level below the bytecode-rescuepath.
The net effect is CRuby-equivalent observable behavior with the backtrace cost concentrated on exactly the exceptions that are genuinely caught and inspected.