Complete Exception#backtrace: raise-time stack, qualified names, memoization#896
Merged
Merged
Conversation
…ization CRuby's `Exception#backtrace` reports the full live stack at raise time. monoruby only recorded the frames the exception actually unwound through (raise site down to the rescuing frame), so a backtrace for an exception raised and rescued in the same method was missing that frame's callers. - Add `Executor::complete_backtrace_for_rescue`, called once at the catch point (`handle_error`'s rescue branch, before `take_ex_obj`). It walks the still-live caller frames of the rescuing frame via each inner frame's saved call-site pc (the same mechanism as `Kernel#caller`) and appends the cheap `(loc, sourceinfo, fid)` tuples. No string formatting happens here — that stays lazy in `#backtrace`. Control-flow pseudo-errors (MethodReturn / BlockBreak / Throw) never reach this path, so they pay nothing, keeping raise cheap. - Render backtrace frame owners with their fully-qualified name in `func_description` (`Ns::Cx.foo`, not `Cx.foo`), special-casing Object so plain top-level methods still read as `Object#foo`. - Memoize `#backtrace` into the `/backtrace` hidden ivar so repeated calls return the same mutable Array (`e.backtrace.unshift(x)` is visible next call; `e.dup.backtrace` is a distinct object). `set_backtrace` writes the same ivar, unifying the explicit store with the memo. - Decouple `#backtrace_locations` from the string backtrace via a new `__raise_backtrace` intrinsic (raise-time capture only), so `set_backtrace(strings)` on a never-raised exception keeps `#backtrace_locations` nil, and an Array of Locations sets both. Fixes core/exception backtrace_spec, backtrace_locations_spec, and set_backtrace_spec. Adds tests/backtrace.rs (6 differential tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #896 +/- ##
=======================================
Coverage 90.49% 90.49%
=======================================
Files 189 189
Lines 123170 123227 +57
=======================================
+ Hits 111461 111515 +54
- Misses 11709 11712 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
sisshiki1969
pushed a commit
that referenced
this pull request
Jul 13, 2026
Document monoruby's raise/unwind/rescue pipeline and how it differs from CRuby, centered on the laziness that keeps the raise path cheap: - MonorubyErr (in-flight Rust error) vs the lazily-materialized Ruby exception object (take_ex_obj). - The two families of MonorubyErrKind: real exceptions vs control-flow pseudo-exceptions (return/break/throw/retry/redo/fatal), and why the latter are dispatched before any trace capture. - handle_error as the per-frame unwinder, the per-method exception table, ensure deferral, and $! restoration. - Backtrace construction: incremental capture on unwind, the catch-time caller walk (complete_backtrace_for_rescue) as the last coherent snapshot point, lazy string formatting + memoization, and why loop's StopIteration pays almost nothing. - Point-by-point CRuby contrast (eager-at-raise snapshot vs deferred/incremental) and a hot-path cost table. Captures the findings from the Exception#backtrace work (#896). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
sisshiki1969
added a commit
that referenced
this pull request
Jul 13, 2026
Document monoruby's raise/unwind/rescue pipeline and how it differs from CRuby, centered on the laziness that keeps the raise path cheap: - MonorubyErr (in-flight Rust error) vs the lazily-materialized Ruby exception object (take_ex_obj). - The two families of MonorubyErrKind: real exceptions vs control-flow pseudo-exceptions (return/break/throw/retry/redo/fatal), and why the latter are dispatched before any trace capture. - handle_error as the per-frame unwinder, the per-method exception table, ensure deferral, and $! restoration. - Backtrace construction: incremental capture on unwind, the catch-time caller walk (complete_backtrace_for_rescue) as the last coherent snapshot point, lazy string formatting + memoization, and why loop's StopIteration pays almost nothing. - Point-by-point CRuby contrast (eager-at-raise snapshot vs deferred/incremental) and a hot-path cost table. Captures the findings from the Exception#backtrace work (#896). Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK Co-authored-by: Claude <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.
Summary
CRuby's
Exception#backtracereports the full live stack at raise time. monoruby only recorded the frames the exception actually unwound through (from the raise site down to the rescuing frame), so the backtrace of an exception raised and rescued in the same method was missing that frame's callers. This PR completes the backtrace to match CRuby while keeping the raise path cheap, per the design constraint that control-flow pseudo-exceptions (StopIteration, method-return,break) must not pay a backtrace-construction cost on every raise.Approach
Rather than building a full backtrace eagerly at every raise, the exception keeps recording only the cheap
(loc, sourceinfo, fid)tuples it unwinds through. The rest of the live stack is filled in once, lazily, at the catch point:Executor::complete_backtrace_for_rescue(executor.rs) is called fromhandle_error's rescue branch, just beforetake_ex_obj, while the caller frames are still live. It walks the rescuing frame's callers via each inner frame's saved call-site pc — the same mechanism asKernel#caller— and appends their tuples. No string formatting happens here; that stays lazy in#backtrace. Control-flow pseudo-errors (MethodReturn/BlockBreak/Throw) never reach this path, so they pay nothing.Fully-qualified frame owners (store.rs
func_description): frames now render the owner's qualified name (Ns::Cx.foo, notCx.foo), special-casingObjectso top-level methods still read asObject#foo.#backtracememoization (exception.rs): the built Array is memoized in the/backtracehidden ivar, so repeated calls return the same mutable object (e.backtrace.unshift(x)is visible on the next call;e.dup.backtraceis distinct).set_backtracewrites the same ivar, unifying the explicit store with the memo.#backtrace_locationsdecoupled from the string backtrace via a new__raise_backtraceintrinsic that returns only the raise-time capture. This makesset_backtrace(strings)on a never-raised exception keep#backtrace_locationsnil, and an Array ofLocations sets both (CRuby 3.4+).Test plan
core/exceptionbacktrace_spec,backtrace_locations_spec, andset_backtrace_spec(all now 0 failures against CRuby 4.0.2).tests/backtrace.rs— 6 differential tests: rescued-in-same-method, propagated chain, qualified class name, memoized/mutable array,set_backtrace(strings)leaving locations nil, and unraised → nil.Remaining
core/exceptionfailures are out of scope:interrupt_spec(3, real SIGINT delivery / signaled exit status) andtop_level_spec(2, cause-chain printed to STDERR + custom backtrace via the 3rdraiseargument).🤖 Generated with Claude Code
Generated by Claude Code