Skip to content

Rescue CLR exceptions that derive from System.Exception directly - #73

Open
Largo wants to merge 3 commits into
masterfrom
fix/rescue-clr-exceptions
Open

Largo wants to merge 3 commits into
masterfrom
fix/rescue-clr-exceptions

Conversation

@Largo

@Largo Largo commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

Problem. In IronRuby, System::Exception is Ruby's Exception, and StandardError maps to System.SystemException. A CLR class that is not a Ruby builtin takes the Ruby class of its CLR base type as its superclass (RubyContext.GetOrCreateClassNoLock). So every CLR exception that derives from System.Exception directly had Exception as its superclass and was not a StandardError. That covers a custom C# exception (public class MyAppException : Exception, the base .NET recommends), AggregateException, ApplicationException and everything below them. begin; lib_call; rescue => e; end, rescue StandardError and the x rescue y modifier let these through, and the script died. The same code with a SystemException (NotSupportedException, InvalidOperationException) was rescued.

Fix. When the base class of a new CLR class is Ruby's Exception, GetOrCreateClassNoLock now gives it StandardError as its superclass. The fix is in the class hierarchy rather than a special case in the rescue test, so rescue, is_a?, ===, ancestors and superclass all agree. That includes Ruby subclasses such as class E2 < MyAppException and raise MyAppException.new("x") from Ruby. What stays as it was:

  • System::Exception itself is unchanged. raise Exception, raise System::Exception, "m", System::Exception.new and a plain new Exception() thrown from C# still get past a bare rescue, as in MRI.
  • Ruby's own exception classes get their superclasses from the library initializers and never take this path. ScriptError, SignalException, Interrupt, SystemExit, NoMemoryError, SecurityError and SystemStackError stay outside StandardError.
  • Two kinds of type keep Exception: the CLR types emitted for Ruby subclasses of Exception (class Foo < Exception), and IronRuby's StackUnwinders (throw, break, return, Thread#kill, fiber reclaim).
  • A library initializer that extends such a CLR exception without naming a superclass (ExtendClass(typeof(X), ..., null, ...)) goes through the same rule (GetClrSuperClassNoLock), so the class gets the same superclass whether the library loads before or after the type is first used.
  • RubyStruct's internal recursive-#hash signal unwinds through user #hash methods. It is now a StackUnwinder, so no user rescue sees it, which matches MRI's throw. Before, rescue Exception caught it.

Behaviour changes (release notes):

  • A bare rescue now catches AggregateException and TargetInvocationException whatever they wrap. That includes a SystemExit, an Interrupt or a throw from a Ruby callback run through Task.Wait or reflection, and CLR test-framework assertions that derive from Exception. On master these reached only rescue Exception.
  • A hosting-API Microsoft::Scripting::SyntaxErrorException (IronRuby.create_engine.execute('1+')) is caught by a bare rescue. eval, require and load still raise Ruby's SyntaxError, which a bare rescue does not catch.
  • System::SystemException === e and rescue System::SystemException now match such exceptions (StandardError is SystemException's Ruby class), while the CLR check SystemException.IsInstanceOfType(e) stays false. Code that used a rescue System::SystemException / rescue System::Exception pair to split runtime errors from application errors now lands everything in the first clause. A C# extension method declared on SystemException answers respond_to? true on them, but calling it raises TypeError (master: NoMethodError).
  • Reopening such a class with an explicit < Exception (e.g. class System::AggregateException < Exception) now raises "superclass mismatch". < StandardError works.
  • A C# library that throws a plain new System.Exception(...) still gets past a bare rescue: that is Ruby's Exception, like MRI's raise Exception.

Testing

New C# tests in Src/IronRuby.Tests/Runtime/ClrExceptionRescueTests.cs (ClrExceptionRescue1-7, fixtures are nested types):

  1. Bare rescue, rescue StandardError, the modifier and a method-level rescue catch C#-thrown custom, derived, ApplicationException-based, generic and AggregateException exceptions.
  2. superclass, ancestors, is_a?, === and < agree.
  3. Raising from Ruby, Ruby subclasses, and a Ruby exception raised through a C# delegate.
  4. MRI semantics for Exception and the classes outside StandardError.
  5. Exception's own subclasses that are not errors keep Exception: emitted Ruby subclasses and StackUnwinders. A struct's recursive #hash is not seen by a user rescue.
  6. Wrapper paths (Task.Wait, reflection, Thread#join) and the deliberate wrapper and hosting-SyntaxErrorException behaviour.
  7. A LibraryInitializer that extends a fixture exception gives it StandardError, both when it defines the class and when the type was used first. This test fails if DefineLibraryClass skips the rule.

Measured on this branch against a pristine master build:

  • Repro with a standalone C# library: on master, MyAppException, a subclass of it and AggregateException get past rescue => e, rescue StandardError and the modifier. On this branch all are caught. A plain System.Exception and Ruby's non-StandardError classes still get past on both.
  • IronRuby.Tests after a clean rebuild (bin/obj removed): 0 failures, exit 0. All seven new tests also pass individually.
  • spec/language: 80 files, 2934 examples, 5581 expectations, 0 failures, 0 errors on both branch and master.
  • Spot dirs, same example counts as master, 0F/0E on the branch:
    • core: exception 250, kernel 2230, systemexit 6, signal 53, process 365, threadgroup 8, fiber 188, struct 170, hash 562, marshal 502, class 54, module 1050, proc 247, method 199, enumerator 432, mutex 35 (master errored once on a known flaky example in this run).
    • library: timeout 7, monitor 12, yaml 61, zlib 148, openssl 171, delegate 74, socket 1821.
    • core/objectspace: 107 examples, 0F/20E on both.
  • core/thread (395 examples): 0F/0E on master in all 11 full runs, and on the branch in 9 of 12. The other three branch runs hit intermittent failures and hangs in Thread#kill specs. These are pre-existing: looping the kill/exit/terminate specs 40 times per build gave 6 failures and 1 hang on the branch, and 4 failures and 2 hangs on master, in the same examples.
  • CLR interop spec suite (Interop/net): summary and failing-example list identical to master.
  • Behaviour probes for throw/catch, break, non-local return, Thread#kill, Timeout, exit, Interrupt and the struct #hash recursion: output identical on branch, master and CRuby 3.3.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • CLR exception types that map to Ruby’s Exception are now generally treated as StandardError, so Ruby rescue clauses handle them consistently. Exceptions with special Ruby or runtime behavior retain their existing rescue semantics.
    • Recursive struct hashing no longer exposes its internal recursion signal to Ruby rescue clauses; hashing a recursive struct returns its class hash.

Largo and others added 3 commits October 31, 2026 01:10
System::Exception is Ruby's Exception, so the Ruby class of a CLR type
whose base is System.Exception had Exception as its superclass. That made
a custom C# exception (.NET recommends deriving from Exception),
AggregateException and ApplicationException and everything below it
invisible to a bare rescue, rescue StandardError and the rescue modifier:
a script calling such a library died where the same code with a
SystemException (NotSupportedException, InvalidOperationException) was
rescued.

RubyContext now gives such a class StandardError as its superclass, so
rescue, is_a?, === and ancestors agree, also for Ruby subclasses of it.
System::Exception itself stays Ruby's Exception and Ruby's own exception
classes keep their MRI superclasses: they are defined by the library
initializers and never take this path. The types emitted for Ruby
subclasses of Exception and IronRuby's StackUnwinders (throw, break,
Thread#kill) are excluded, as they are not errors.

The struct #hash recursion signal was the one internal exception that
crosses user code (a user #hash method) and would have become a
StandardError. It is a StackUnwinder now, so no rescue sees it, which is
what MRI's throw does; before, rescue Exception caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ined

A C# exception usually reaches Ruby inside a wrapper: a faulted task's
AggregateException, reflection's TargetInvocationException, or re-raised
by Thread#join. ClrExceptionRescue6 covers those paths. It also pins two
consequences of the class deciding, as for any exception:

- A wrapper is a StandardError whatever it carries, so a bare rescue now
  catches an AggregateException that holds a SystemExit, an Interrupt or
  a throw from a Ruby callback. A static hierarchy cannot look at the
  inner exception. On master such a wrapper reached only rescue
  Exception, and the exit or throw inside it was lost there as well.
- A hosting API's SyntaxErrorException (IronRuby.create_engine.execute)
  is a CLR exception like any other. eval, require and load still raise
  Ruby's SyntaxError, a ScriptError, as in MRI.

RubyContext asserted nothing when StandardError was not defined yet, so
a class made that early would have kept Exception for good without
anyone noticing. That cannot happen today, since the initializers define
StandardError first, but a Debug.Assert now catches a future ordering
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DefineLibraryClass chose a CLR type's superclass from its base type
directly when the library passed none, skipping the StandardError rule
in GetOrCreateClassNoLock. A library extending a System.Exception
subclass (ExtendClass with no Inherits) then produced an Exception
subclass if it loaded first and a StandardError subclass if Ruby had
touched the type first. Both paths now share GetClrSuperClassNoLock.

The test also pins that System::SystemException === an application
exception is true now (StandardError is SystemException's Ruby class),
while the CLR type check stays false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 30b750c3-c5da-45e9-a65d-c37dcdafaadc
Commits

Reviewing files that changed from the base of the PR and between 8739e99 and 1939b2f.

Files selected for processing: 4
  • Src/IronRuby.Tests/RubyTests.cs
  • Src/IronRuby.Tests/Runtime/ClrExceptionRescueTests.cs
  • Src/Ruby/Builtins/RubyStruct.cs
  • Src/Ruby/Runtime/RubyContext.cs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

CLR exception types now receive Ruby superclass mappings based on their CLR base types, with exceptions for Ruby types and internal unwinder types. New tests cover rescue behavior, exception hierarchy, and exceptions crossing runtime boundaries.

Changes: CLR exception rescue

Layer / File(s) Summary
Superclass mapping and unwinding
Src/Ruby/Runtime/RubyContext.cs, Src/Ruby/Builtins/RubyStruct.cs
CLR class creation and library class creation use a shared superclass mapping rule. RecursiveHashException now derives from StackUnwinder.
Rescue behavior tests
Src/IronRuby.Tests/Runtime/ClrExceptionRescueTests.cs, Src/IronRuby.Tests/RubyTests.cs
Added tests for Ruby rescue behavior, exception hierarchy, exceptions crossing C# and runtime boundaries, and library-defined CLR exception classes. Registered seven new tests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Possibly related PRs

  • Largo/ironruby#22: Changes exception processing for StackUnwinder, which is also used by the recursive hash signal in this PR.
  • Largo/ironruby#47: Adds a CLR exception type affected by the superclass mapping rule in this PR.
  • Largo/ironruby#66: Uses StackUnwinder for a fiber reclamation signal that bypasses Ruby rescue clauses.

Suggested reviewers: claude

Merge Risk: Minimal · up to 1939b

Direct CLR exception subclasses become catchable by bare rescue. No merge-blocking risk was identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: Ruby rescue behavior for CLR exceptions that directly derive from System.Exception.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 4 functions across 4 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

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