Skip to content

Complete Exception#backtrace: raise-time stack, qualified names, memoization#896

Merged
sisshiki1969 merged 1 commit into
masterfrom
claude/cruby-4-0-2-specs-olt61c
Jul 12, 2026
Merged

Complete Exception#backtrace: raise-time stack, qualified names, memoization#896
sisshiki1969 merged 1 commit into
masterfrom
claude/cruby-4-0-2-specs-olt61c

Conversation

@sisshiki1969

Copy link
Copy Markdown
Owner

Summary

CRuby's Exception#backtrace reports 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 from handle_error's rescue branch, just before take_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 as Kernel#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, not Cx.foo), special-casing Object so top-level methods still read as Object#foo.

  • #backtrace memoization (exception.rs): the built Array is memoized in the /backtrace hidden ivar, so repeated calls return the same mutable object (e.backtrace.unshift(x) is visible on the next call; e.dup.backtrace is distinct). set_backtrace writes the same ivar, unifying the explicit store with the memo.

  • #backtrace_locations decoupled from the string backtrace via a new __raise_backtrace intrinsic that returns only the raise-time capture. This makes set_backtrace(strings) on a never-raised exception keep #backtrace_locations nil, and an Array of Locations sets both (CRuby 3.4+).

Test plan

  • Fixes core/exception backtrace_spec, backtrace_locations_spec, and set_backtrace_spec (all now 0 failures against CRuby 4.0.2).
  • Adds 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.
  • Full lib suite regression: 1830 passed, 0 failed.

Remaining core/exception failures are out of scope: interrupt_spec (3, real SIGINT delivery / signaled exit status) and top_level_spec (2, cause-chain printed to STDERR + custom backtrace via the 3rd raise argument).

🤖 Generated with Claude Code


Generated by Claude Code

…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

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.91525% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.49%. Comparing base (2423ce0) to head (fc295dc).

Files with missing lines Patch % Lines
monoruby/src/executor.rs 93.93% 2 Missing ⚠️
monoruby/src/builtins/exception.rs 94.44% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sisshiki1969 sisshiki1969 merged commit 8753ca6 into master Jul 12, 2026
4 checks passed
@sisshiki1969 sisshiki1969 deleted the claude/cruby-4-0-2-specs-olt61c branch July 12, 2026 23:50
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>
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