Skip to content

[API Proposal]: Let accessing the instance of Exception (if any) on stack. #89724

Closed

Description

@voroninp

Background and motivation

From time to time there's a need to not only check whether stack contains a bubbling up exception, but also to get access to the exception itself.

For example, in Open Telemetry library ExceptionProcessor uses Marshal.GetExceptionPointers to check whether exception is thrown withing the scope of activity.

However, if I don't want `OperationCancelledException' to be treated as an error, I need to have access to the instance of the exception thrown. Currently, there's no such possibility.

API Proposal

namespace System.Runtime.ExceptionServices
{
    public static class ExceptionHandling
    {
        public Exception? CurrentException {get;}
    }
}

This API is managed equivalent of the existing ICorDebugThread::GetCurrentException unmanaged debugger API. throw should set this per-thread property (saving previous value), catch with matching exception should restore the previous value.

API Usage

try
{
    throw new Exception();
}
finally
{
    // Here it is not null;
      Console.WriteLine(AppDomain.CurrentDomain.CurrentException is not null);
}
try
{
    throw new Exception();
}
catch
{
    // Here it is null;
   Console.WriteLine(AppDomain.CurrentDomain.CurrentException is null);
}
finally
{
    // Here it is null;
   Console.WriteLine(AppDomain.CurrentDomain.CurrentException is null);
}
try
{
    throw new Exception();
}
catch
{
    // Here it is null;
   Console.WriteLine(AppDomain.CurrentDomain.CurrentException is null);

   throw;
}
finally
{
    // Here it is not null;
   Console.WriteLine(AppDomain.CurrentDomain.CurrentException is not null);
}

Not sure whether value should propagate with async.

Task CheckMe() => Task.Run(() => Console.WriteLine(AppDomain.CurrentDomain.CurrentException is not null));
try
{
    throw new Exception();
}
finally
{
    // Here it is not null;
    Console.WriteLine(AppDomain.CurrentDomain.CurrentException is not null);

    // will it output true or false? 
    await CheckMe();

    // Here it is not null;
    Console.WriteLine(AppDomain.CurrentDomain.CurrentException is not null);
}

Activity

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Jul 31, 2023
  2. ghost added
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Jul 31, 2023
  3. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Jul 31, 2023
  4. added and removed
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Jul 31, 2023
  5. grbell-ms commented on Aug 2, 2023

    @grbell-ms
    Member

    Could Open Telemetry achieve the same result using a first-chance exception handler?
    Whenever an exception is thrown, it could be stored in a thread/task -local field. The ExceptionProcessor could then access the most recent exception using the field and determine whether that exception is bubbling up using fnGetExceptionPointers.

  6. voroninp commented on Aug 2, 2023

    @voroninp
    Author

    @grbell-ms

    If I am not missing something particularly important, it should work.

    async Task Main()
    {
        using var activity1 = new Activity();
        try
        {
            using var activity2 = new Activity();
    	await ThrowAsync();
        }
        catch
        {		
        }
    }
    
    public Task ThrowAsync() => Task.Run(() => throw new OperationCanceledException());
    
    public sealed class Activity : IDisposable
    {
        [ThreadStatic]
        private static GCHandle _currentExceptionHandle;
    	
        static Activity()
        {
            AppDomain.CurrentDomain.FirstChanceException += (s,e) =>
    	{
     	    if (_currentExceptionHandle.IsAllocated)
    	    {
    	        _currentExceptionHandle.Free();
                }
    	    _currentExceptionHandle = GCHandle.Alloc(e.Exception, GCHandleType.Weak);
    	};
        }
    	
        private readonly IntPtr _exceptionAddress;
    
        public Activity()
        {
            _exceptionAddress = Marshal.GetExceptionPointers();
        }
    
        public void Dispose()
        {
            var curentExceptionAddr = Marshal.GetExceptionPointers();
    	var isExceptionBubblingUp = _exceptionAddress != curentExceptionAddr && curentExceptionAddr != IntPtr.Zero;
    	if (isExceptionBubblingUp && _currentExceptionHandle.Target is Exception currentException)
    	{
    	    Console.WriteLine($"Thrown exception {currentException}");
    	}
    	else if (_currentExceptionHandle.IsAllocated)
    	{
    	    _currentExceptionHandle.Free();
    	}
        }
    }
    
  7. grbell-ms commented on Aug 2, 2023

    @grbell-ms
    Member

    @voroninp I would change GCHandle to WeakReference<Exception> for simplicity, but that's about what I was imagining. ☺️

    But I'm no expert, so no promises this works without extensive testing. 😅

  8. voroninp commented on Aug 2, 2023

    @voroninp
    Author

    I started with Weak reference, but decided, GCHandle will prevent memory allocations.

  9. adrianotr commented on May 4, 2024

    @adrianotr

    I ended up with the implementation below. Do you see any possible issues with it?

    public sealed class DetailedExceptionProcessor : BaseProcessor<Activity>
    {
        private const string EnabledKey = "DetailedExceptionProcessor.Enabled";
        private const string ExceptionKey = "DetailedExceptionProcessor.Exception";
    
        static DetailedExceptionProcessor()
        {
            AppDomain.CurrentDomain.FirstChanceException += (_, args) =>
            {
                if (Activity.Current is { } activity
                    && activity.GetCustomProperty(EnabledKey) is true)
                {
                    activity.SetCustomProperty(ExceptionKey, args.Exception);
                }
            };
        }
    
        public override void OnStart(Activity activity)
        {
            activity.SetCustomProperty(EnabledKey, true);
        }
    
        public override void OnEnd(Activity activity)
        {
            if (activity.GetCustomProperty(ExceptionKey) is Exception exception 
                && Marshal.GetExceptionPointers() != IntPtr.Zero)
            {
                activity.SetStatus(Status.Error);
                activity.RecordException(exception);
                activity.SetStatus(ActivityStatusCode.Error);
            }
            
            activity.SetCustomProperty(EnabledKey, null);
            activity.SetCustomProperty(ExceptionKey, null);
        }
    }
  10. voroninp commented on May 4, 2024

    @voroninp
    Author
  11. jkotas commented on May 4, 2024

    @jkotas
    Member

    Yep came up to similar one https://gist.github.com/voroninp/704c1cb556bd03697ef0c80524de1b24

    This will leak the GCHandles over time as threads come and go. There is nothing that would take care of cleaning up of the GCHandles - GCHandles are low-level "unmanaged" resources that must be freed manually.

  12. voroninp commented on May 4, 2024

    @voroninp
    Author

    @jkotas , despite it allocation with weak type?!

  13. jkotas commented on May 4, 2024

    @jkotas
    Member

    I ended up with the implementation below. Do you see any possible issues with it?

    This will capture the last thrown exception. It won't be the exception that the activity crashed with if there are exceptions thrown and handled during cleanup (finally blocks, using statements in C#, etc.). There is no good way to this 100% right today. It is what this new API proposal is about - introduce an API that allow you to do it properly.

  14. 21 remaining items

  15. jkotas commented on Jul 25, 2026

    @jkotas
    Member

    I have updated the proposal

  16. bartonjs commented on Sep 29, 2026

    @bartonjs
    Member

    Video

    • A property feels like it implies stability, let's use a Get method to show it's a point-in-time answer.
    namespace System.Runtime.ExceptionServices
    {
        public static class ExceptionHandling
        {
            public static Exception? GetCurrentException();
        }
    }
  17. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Sep 29, 2026
  18. added
    in-prThere is an active PR which will close this issue when it is merged
    on Sep 29, 2026
  19. linked a pull request that will close this issueAdd access to exception instance on stack #134883on Sep 29, 2026
  20. jkotas commented on Oct 10, 2026

    @jkotas
    Member

    catch
    {
    // Here it is null;
    Console.WriteLine(AppDomain.CurrentDomain.CurrentException is null);

    @voroninp Returning null inside the catch block is different from how other similar methods (like ICorDebugThread::GetCurrentException you mentioned) behave. Is this detail important to your use case, or do you think it would be reasonable to return the exception being handled inside the catch block too?

  21. pcshrosbree commented on Oct 10, 2026

    @pcshrosbree
    Contributor

    @jkotas, here are some measurements from our review of #134883 that bear on this question. To check that the tests cover each of the PR's guards, we built 68cf753 without the store that sets CatchHandlerRunningFlag in CallCatchFunclet. On CoreCLR, that build returns the newest exception on the thread that has not been superseded, including one a catch is currently handling, which is the alternative you describe. We ran our probe program on both builds (CoreCLR linux-x64 Checked).

    • 21 of the probe's 71 observation points change. All 21 run while a catch handler is executing: in the catch itself, in a finally or using that completes normally inside it, or in a callee. A separate comparison with Marshal.GetExceptionPointers(), at four further points, is described below.
    • Inside a catch, a finally that a throw; or a new exception passes through does not change. Neither do the filter and fault observations made outside a catch, nor an outer finally reached after a return leaves the catch: that finally returns null in both builds.
    • Nested exceptions stay ordered. With the mutation, inside the catch of A, a catch of a nested exception B returns B, and once that inner catch finishes the outer catch returns A again. In both builds, a finally that B unwinds through inside the catch of A returns B.
    • windows-x64: the same mutation turns the same 13 of 22 tests red as on Linux. The 22 are the PR's test class plus the tests we proposed in the review. We did not run the probe program on the Windows mutant.

    What follows from those observations:

    • Marshal.GetExceptionPointers(). We compared four points: outside any handler, in a filter, in a finally and in a catch. On both Linux and Windows, the PR's null/non-null result differs from its zero/non-zero result only in the catch. With the mutation, all four agree on Linux. We did not compare pointer identity with exception identity.
    • Code that runs inside a catch, with the mutation, can get the handled exception without the catch passing it in.
    • Normal completion versus rethrow inside a catch. This matters to scope-exit observers like the telemetry and using (var txn = ...) cases discussed above. Take our example, with no other exception in flight. Under the PR, a using that completes normally inside a catch sees null, while a finally around throw; in the same catch sees the caught exception. With the mutation, both see that exception, so a null check cannot tell them apart. Comparing against a reference taken when the scope starts cannot either, because a rethrow is the same object; that last point follows from the observations and was not probed separately. Separately, in the probes for normal completion during an unwind, a callee's finally and a using inside a finally unwinding A both see A under either rule.

    If the rule changes, it would help for the remarks to say that code running inside a catch handler sees the handled exception even when that code completes normally.

    The 21 observation points that change (CoreCLR linux-x64 Checked, based on 68cf753)
    probe / observation point PR head catch flag removed
    PrOwnCases: InFinallyAndCatch: catch null A
    PrOwnCases: Rethrow: catch null A
    PrOwnCases: NestedCaughtInFinally: inner catch A B
    PrOwnCases: EscapingFinally: catch null B
    FinallyInsideCatch: finally lexically inside catch null A
    FinallyInsideCatch: using inside catch null A
    FinallyInsideCatch: callee finally called from catch null A
    NestedCatchInsideCatch: inner catch null B
    NestedCatchInsideCatch: after inner catch, still in outer catch null A
    RethrowInsideTryInsideCatch: outer catch null A
    ThrowNewFromCatch: catch of the new exception null B
    ExceptionCaughtInsideFilter: filter callee: catch of B A B
    ExceptionCaughtInsideFilter: handler selected by that filter null A
    FilterThrowsUncaught: outer handler null A
    FilterThenSecondPassFinally: handler null A
    EdiThrowFromCatch: catch null A
    EdiThrowFromFinally: catch null B
    SecondPassFinallyInCallerFrames: catch two frames up null A
    SupersededWithFrameBetween: catch of B (A superseded) null B
    ReversePInvokeDuringFinally: callback with nothing in flight: catch null B
    ReversePInvokeDuringFinally: callback during finally of A: catch of B A B

    A and B are the exceptions each probe throws, in order.

    Note

    This comment was drafted with AI assistance (Anthropic Claude, reviewed by OpenAI Codex). The probe runs it cites used builds based on commit 68cf753, including the catch-flag mutation described above.

  22. voroninp commented on Oct 10, 2026

    @voroninp
    Author

    @jkotas , in my mental model catch "removes" exception from the context unless it's explicitly rethrown. But as I can imagine it's not critical for my case.

  23. added a commit that references this issue on Oct 13, 2026
    ef3e080
  24. voroninp commented on Oct 13, 2026

    @voroninp
    Author

    @jkotas , is it going to be available in .NET 11 RC2?

  25. jkotas commented on Oct 13, 2026

    @jkotas
    Member

    This PR is for .NET 12. .NET 11 is feature complete for a while now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-System.Runtimein-prThere is an active PR which will close this issue when it is merged

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions