Skip to content

Conversation

@rcj1
Copy link
Contributor

@rcj1 rcj1 commented Dec 24, 2025

This PR implements exception stack trace collection in runtime-async stacks for both coreclr and nativeAOT.

  • Augmenting stack unwinding logic in AsyncHelpers.CoreCLR.cs to append frames from runtime-async async-await chain to exception stacktrace using DiagnosticIP of Continuation.

  • Implements ThrowExact helper to throw exception that has been stored in Continuation, while keeping its existing stack trace intact. Accomplished by creating throw helpers that set ExKind.RethrowFlag. Would be open to rename "RethrowFlag" and companions to "NoEraseFlag" or something similar, or leave as is.

  • ILC changes: Ensuring that stack trace records are emitted for runtime-async methods, and hidden for resumption stubs.

  • Testing: Would like input on where to put them. The current test location seems to be more about constructor and other basic tests. Does naot tests in src\tests\nativeaot\SmokeTests\UnitTests\StackTraces.cs and coreclr tests in src\tests\baseservices\exceptions\exceptionstacktrace\exceptionstacktrace.cs make sense?

  • There are some line attribution errors in native AOT; I believe these can be fixed by emitting additional sequence points.

Most of the test programs test some combination of runtime-async and asyncv1 chaining. Here are other test programs where there is parity between runtime-async and asyncv1 exception strings.

static class Program
{
	public static async Task Main()
	{
		try
		{
			await Run();
		}
		catch (Exception ex)
		{
			Console.WriteLine(ex);
		}
	}
	public static async Task Run()
	{
		TaskCompletionSource tcs = new TaskCompletionSource();
		Task t = BarCaller(tcs.Task);
		Task t2 = FooCaller(tcs);
		await Task.WhenAll(t, t2);
	}
	static async Task FooCaller(TaskCompletionSource tcs)
	{
		await Task.Yield();
		await Foo(tcs);
	}
	static async Task Foo(TaskCompletionSource tcs)
	{
		await Task.Yield();
		tcs.SetResult();
	}
	static async Task BarCaller(Task task)
	{
		await Task.Yield();
		await Bar(task);
	}
	static async Task Bar(Task task)
	{
		await Task.Yield();
		await task;
		throw new InvalidOperationException("Bar exception after awaiting tcs task.");
	}
}
public static class Program
{
    static void Main(string[] args)
    {
		try
		{
        Helper1();
		}
		catch (Exception ex)
		{
			Console.WriteLine(ex);
		}
	}

    static void Helper1()
    {
        AsyncHelper2().Wait();
    }

    static async Task AsyncHelper2()
    {
        // Console.WriteLine($"Marker 1");
        await Task.Yield();
        // Console.WriteLine($"Marker 2");
		throw new InvalidOperationException("Test exception from AsyncHelper2.");
    }
}

@dotnet/ilc-contrib

jakobbotsch and others added 30 commits October 1, 2025 14:54
- Add new JIT-EE API to report back debug information about the
  generated state machine and continuations
- Refactor debug info storage on VM side to be more easily extensible.
  The new format has either a thin or fat header. The fat header is used
  when we have either uninstrumented bounds, patchpoint info, rich debug
  info or async debug info, and stores the blob sizes of all of those
  components in addition to the bounds and vars.
- Add new async debug information to the storage on the VM side
- Set get target method desc for async resumption stubs, to be used for
  mapping from continuations back to the async IL function that it will
  resume.
@am11 am11 added area-ExceptionHandling-coreclr runtime-async and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Dec 24, 2025
@rcj1 rcj1 force-pushed the dev/rachel/asyncv2-exception-tostring branch from ed25f87 to d5f6337 Compare December 27, 2025 20:31
@rcj1 rcj1 changed the title [WIP] [Draft] runtime-async Exception.ToString() Runtime-async Exception.ToString() Dec 28, 2025
@rcj1 rcj1 marked this pull request as ready for review December 28, 2025 03:28
Copilot AI review requested due to automatic review settings December 28, 2025 03:28
@dotnet-policy-service
Copy link
Contributor

Tagging subscribers to this area: @agocke, @MichalStrehovsky, @jkotas
See info in area-owners.md if you want to be subscribed.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements exception stack trace collection for runtime-async (async v2) methods in both CoreCLR and NativeAOT. The implementation enables proper exception stack traces to include frames from runtime-async async-await chains, providing parity with AsyncV1 exception reporting.

Key changes:

  • Adds ThrowExact helper that preserves existing stack traces when rethrowing exceptions from continuations by setting ExKind.RethrowFlag
  • Augments stack unwinding to append continuation frames from runtime-async chains using DiagnosticIP
  • Updates ILC to emit stack trace records for runtime-async methods while hiding resumption stubs

Reviewed changes

Copilot reviewed 39 out of 39 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/libraries/System.Diagnostics.StackTrace/tests/System.Diagnostics.StackTrace.Tests.csproj Adds reference to new AsyncAssembly test project
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.cs Adds test infrastructure for validating runtime-async exception stack traces
src/libraries/System.Diagnostics.StackTrace/tests/AsyncV1Assembly/Program.cs New test assembly with AsyncV1 methods for testing V1/V2 interop scenarios
src/libraries/System.Diagnostics.StackTrace/tests/AsyncV1Assembly/AsyncV1Assembly.csproj Project file for AsyncV1 test assembly
src/libraries/System.Diagnostics.StackTrace/tests/AsyncAssembly/Program.cs New test assembly with runtime-async methods that chain with V1 methods
src/libraries/System.Diagnostics.StackTrace/tests/AsyncAssembly/AsyncAssembly.csproj Project file for runtime-async test assembly with preview features enabled
src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs Adds platform detection for runtime-async support
src/coreclr/vm/qcallentrypoints.cpp Registers new QCall entry point for adding continuations to exceptions
src/coreclr/vm/jithelpers.cpp Modifies IL_ThrowExact to use RethrowFlag instead of marking as foreign exception
src/coreclr/vm/exkind.h Extracts ExKind enum to separate header for reusability
src/coreclr/vm/exinfo.h Removes ExKind definition (moved to exkind.h)
src/coreclr/vm/exinfo.cpp Includes new exkind.h header
src/coreclr/vm/exceptionhandling.h Adds ExKind parameter to DispatchManagedException
src/coreclr/vm/exceptionhandling.cpp Implements ExKind parameter handling in exception dispatch
src/coreclr/vm/excep.cpp Adds STEF_CONTINUATION flag and guards Watson setup for continuation frames
src/coreclr/vm/debugdebugger.h Declares new AsyncHelpers_AddContinuationToExInternal QCall
src/coreclr/vm/debugdebugger.cpp Implements continuation frame appending for exceptions and special offset handling
src/coreclr/vm/common.h Adds required C++ standard library includes
src/coreclr/vm/clrex.h Adds STEF_CONTINUATION flag for stack trace elements
src/coreclr/tools/aot/ILCompiler.RyuJit/JitInterface/CorInfoImpl.RyuJit.cs Maps CORINFO_HELP_THROWEXACT to ReadyToRunHelper.ThrowExact
src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/ReadyToRunSignature.cs Adds ThrowExact helper name for signature parsing
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs Maps ThrowExact helper for ReadyToRun
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.cs Adds RuntimeAsync visibility flag for async resumption stubs
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.cs Emits stack trace records for runtime-async methods and hides resumption stubs
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/JitHelper.cs Maps ThrowExact to RhpThrowExact runtime helper
src/coreclr/tools/Common/TypeSystem/IL/Stubs/AsyncResumptionStub.cs Exposes TargetMethod property for accessing original async method
src/coreclr/tools/Common/Internal/Runtime/ReadyToRunConstants.cs Adds ThrowExact to ReadyToRunHelper enum
src/coreclr/nativeaot/System.Private.CoreLib/src/System/Exception.NativeAot.cs Adds RH_EH_RUNTIME_ASYNC_FRAME flag and handles runtime-async frames
src/coreclr/nativeaot/Runtime/riscv64/ExceptionHandling.S Implements RhpThrowExact with rethrow flag for RISC-V 64-bit
src/coreclr/nativeaot/Runtime/loongarch64/ExceptionHandling.S Implements RhpThrowExact with rethrow flag for LoongArch 64-bit
src/coreclr/nativeaot/Runtime/i386/ExceptionHandling.asm Implements RhpThrowExact with rethrow flag for x86
src/coreclr/nativeaot/Runtime/arm64/ExceptionHandling.asm Implements RhpThrowExact with rethrow flag for ARM64 (Windows)
src/coreclr/nativeaot/Runtime/arm64/ExceptionHandling.S Implements RhpThrowExact with rethrow flag for ARM64 (Unix)
src/coreclr/nativeaot/Runtime/arm/ExceptionHandling.S Implements RhpThrowExact with rethrow flag for ARM
src/coreclr/nativeaot/Runtime/amd64/ExceptionHandling.asm Implements RhpThrowExact with rethrow flag for AMD64 (Windows)
src/coreclr/nativeaot/Runtime/amd64/ExceptionHandling.S Implements RhpThrowExact with rethrow flag for AMD64 (Unix)
src/coreclr/inc/readytorun.h Adds READYTORUN_HELPER_ThrowExact constant
src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncHelpers.CoreCLR.cs Implements continuation stack trace appending during exception unwinding
docs/design/coreclr/botr/readytorun-format.md Documents ThrowExact helper in ReadyToRun format

{
method = ((ILCompiler.AsyncResumptionStub)method).TargetMethod;
}
MethodDesc methodToGenerateMetadataFor = method.GetTypicalMethodDefinition();
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an extra space after the equals sign on this line. The formatting should be 'MethodDesc methodToGenerateMetadataFor = method.GetTypicalMethodDefinition();' with a single space on each side of the equals sign, consistent with C# coding conventions and the rest of the codebase.

Suggested change
MethodDesc methodToGenerateMetadataFor = method.GetTypicalMethodDefinition();
MethodDesc methodToGenerateMetadataFor = method.GetTypicalMethodDefinition();

Copilot uses AI. Check for mistakes.
Comment on lines +543 to +560
{
IntPtr ip = (IntPtr)continuation.ResumeInfo->DiagnosticIP;
int flags = (int)RhEHFrameType.RH_EH_RUNTIME_ASYNC_FRAME;
IntPtr pAppendStackFrame = (IntPtr)InternalCalls.RhpGetClasslibFunctionFromCodeAddress(ip,
ClassLibFunctionId.AppendExceptionStackFrame);

if (pAppendStackFrame != IntPtr.Zero)
{
try
{
((delegate*<object, IntPtr, int, void>)pAppendStackFrame)(ex, ip, flags);
}
catch
{
// disallow all exceptions leaking out of callbacks
}
}
}
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation of the opening brace on line 543 and closing brace on line 560 in the #else block for NATIVEAOT is inconsistent with the rest of the code. These braces should be indented to align with the conditional compilation directive context, typically at the same level as the if statement on line 539.

Suggested change
{
IntPtr ip = (IntPtr)continuation.ResumeInfo->DiagnosticIP;
int flags = (int)RhEHFrameType.RH_EH_RUNTIME_ASYNC_FRAME;
IntPtr pAppendStackFrame = (IntPtr)InternalCalls.RhpGetClasslibFunctionFromCodeAddress(ip,
ClassLibFunctionId.AppendExceptionStackFrame);
if (pAppendStackFrame != IntPtr.Zero)
{
try
{
((delegate*<object, IntPtr, int, void>)pAppendStackFrame)(ex, ip, flags);
}
catch
{
// disallow all exceptions leaking out of callbacks
}
}
}
{
IntPtr ip = (IntPtr)continuation.ResumeInfo->DiagnosticIP;
int flags = (int)RhEHFrameType.RH_EH_RUNTIME_ASYNC_FRAME;
IntPtr pAppendStackFrame = (IntPtr)InternalCalls.RhpGetClasslibFunctionFromCodeAddress(ip,
ClassLibFunctionId.AppendExceptionStackFrame);
if (pAppendStackFrame != IntPtr.Zero)
{
try
{
((delegate*<object, IntPtr, int, void>)pAppendStackFrame)(ex, ip, flags);
}
catch
{
// disallow all exceptions leaking out of callbacks
}
}
}

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,25 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// RuntimeExceptionKind.h
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 3 refers to "RuntimeExceptionKind.h" but the actual file is named "exkind.h". This is likely a copy-paste artifact from when the enum was extracted from exinfo.h. The comment should be updated to match the actual filename.

Suggested change
// RuntimeExceptionKind.h
// exkind.h

Copilot uses AI. Check for mistakes.
Comment on lines +539 to +547
if (continuation != null && continuation.ResumeInfo != null && continuation.ResumeInfo->DiagnosticIP != null)
#if !NATIVEAOT
AddContinuationToExInternal(continuation.ResumeInfo->DiagnosticIP, ex);
#else
{
IntPtr ip = (IntPtr)continuation.ResumeInfo->DiagnosticIP;
int flags = (int)RhEHFrameType.RH_EH_RUNTIME_ASYNC_FRAME;
IntPtr pAppendStackFrame = (IntPtr)InternalCalls.RhpGetClasslibFunctionFromCodeAddress(ip,
ClassLibFunctionId.AppendExceptionStackFrame);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what these changes are necessary for. Isn't the original async stackwalk when the exception was first thrown sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are catching any exception in DispatchContinuations and unwinding the continuation chain here until we find a continuation that resumes inside a try block; this does not go through the normal exception unwind stack walk so would not update the stack trace here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code in DispatchContinuations handles propagating exceptions thrown in one async frame to the next async frame, but it is not the only time I would expect us to want a nice async stack trace. For example, you can imagine code like:

async Task Foo()
{
  await Task.Yield();
  await Bar();
}

async Task Bar()
{
  await Task.Yield();
  try
  {
    SyncCallThatThrows();
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex);
  }
}

The exception propagation in DispatchContinuations is not going to be called for the exception thrown by SyncCallThatThrows, but I still think we would want a stack trace that shows Foo -> Bar -> SyncCallThatThrows(). So I think we would want the async stack walking integrated at a slightly lower level, and if we do that I wouldn't expect the DispatchContinuations handling to be necessary.

Copy link
Member

@MichalStrehovsky MichalStrehovsky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes #122549. Fixes #122547.

writer.AdditionalRootRecords.Add(record.MethodName);
writer.AdditionalRootRecords.Add(record.MethodSignature);
writer.AdditionalRootRecords.Add(record.MethodInstantiationArgumentCollection);
// hide the resumption stubs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have a runtime-async method, we potentially have 3 method bodies:

  1. The task-callable variant of the method (this is the variant of the method that doesn't have async calling convention and simply returns a Task)
  2. The async-callable variant of the method (this is the actual method body; needs to be called with asynccall calling convention)
  3. The resumption thunk that calls the async-callable variant

Before this PR, since we don't special case async in any way right now, I assume we're generating stack trace metadata for the first flavor and no metadata for the others (not even instructions to hide them from StackTrace.ToString).

With what's in this PR, we're still going to generate metadata for first flavor, then we're going to generate a hidden stack trace record for the resumption thunk and a record for the async-callable flavor, but only if there was a resumption thunk.

I'm not sure if this is what we want (we definitely do not want the "only if there was a resumption thunk" part, and I'm not sure we want to see the Task-callable thunk either).

I think what we want instead of the changes in this file is:

  • Update EcmaMethodStackTraceEmissionPolicy.GetMethodVisibility to check for the resumption thunk case. If it's a resumption thunk, return MethodStackTraceVisibilityFlags.IsHidden
  • In the same method, add special casing when method.IsAsync is true: make sure that if method.IsAsync is true and method.IsAsyncCall() is false (i.e. this is the Task-callable thunk), report .IsHidden. If both method.IsAsync and method.IsAsyncCall() is true, report .HasMetadata.

This should ensure that flavor 1 is hidden, flavor 2 is visible, and flavor 3 is hidden.

I assume that's what we want.


public static class RuntimeAsyncAndRemoteExecutor
{
public static bool IsSupported => RemoteExecutor.IsSupported && PlatformDetection.IsRuntimeAsyncSupported;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RemoteExecutor.IsSupported is false for native AOT so this test will be currently skipped on native AOT (and some other platforms). Is RemoteExecutor still needed? I believe as of #121732 it should no longer be needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we would be able to remove the assemblies as per your suggestions; do you think this is the best place to put these tests?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think this is the best place to put these tests?

Yes, I think so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add a test where we have the Task-returning variant of the method on the stack. It will be on the stack if a runtime-async method is not awaited (i.e. we take the Task that was returned from it and e.g. store it in a static field).

ContinueOnCapturedTaskScheduler = 64,
}

internal enum RhEHFrameType
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be needed if you take my other feedback.

{
IntPtr ip = (IntPtr)continuation.ResumeInfo->DiagnosticIP;
int flags = (int)RhEHFrameType.RH_EH_RUNTIME_ASYNC_FRAME;
IntPtr pAppendStackFrame = (IntPtr)InternalCalls.RhpGetClasslibFunctionFromCodeAddress(ip,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to go through the RhpGetClasslibFunctionFromCodeAddress indirection. That requirement to go through indirection only applies to code under Runtime.Base subdirectory (that is not technically in Corelib, but we compile it as such right now).

RhpGetClasslibFunctionFromCodeAddress will eventually return the address of Exception.AppendExceptionStackFrame (defined in Exception.NativeAot.cs). This can call Exception.AppendExceptionStackFrame directly. Or better yet, add a new method to Exception.NativeAot.cs (AppendContinuationStackFrame?) that doesn't require passing flags and call that so that we don't have to deal with RhEHFrameType here.


<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net11.0</TargetFramework>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either do this, or target $(NetCoreAppCurrent) and leverage RuntimeAsyncMethodGenerationAttribute attributes to selectively force disable runtime async (then we don't even need the extra assembly). The latter is what we do in src/tests/async.

Suggested change
<TargetFramework>net11.0</TargetFramework>
<!-- We need a TFM that doesn't support runtime-async! -->
<TargetFramework>netstandard2.0</TargetFramework>

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Targeting hardcoded TFMs gets complicated a couple years down the line because a few years from now this is going to generate warnings about unsupported frameworks and there are other engineering challenges as well.

Suggested change
<TargetFramework>net11.0</TargetFramework>
<TargetFramework>$(NetCoreAppCurrent)</TargetFramework>

}
}

public static bool IsRuntimeAsyncSupported => !IsCoreClrInterpreter && !IsMonoRuntime && !IsMonoAOT && !IsMonoInterpreter;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incorrect. The coreclr interpreter DOES support runtime async, and any issues you encounter should be fixed as you work, or issues should be filed. (For async stuff, please tag me in particular).


#ifdef FEATURE_EH_FUNCLETS
DispatchManagedException(oref, exceptionFrame.GetContext());
DispatchManagedException(oref, exceptionFrame.GetContext(), NULL, ExKind::RethrowFlag);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetThread()->GetExceptionState()->SetRaisingForeignException();
is the equivalent of the ThrowExact helper for the interpreter. Could you update that logic as well?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants