Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5e83373
Async v2 callstack continuations for Env.StackTrace
tommcdon Oct 7, 2025
bb68d1f
Add Environment.StackTrace test for async v2 continuation tracking
tommcdon Mar 7, 2026
066587a
NativeAOT: augment Environment.StackTrace with async v2 continuations
tommcdon Mar 7, 2026
49a704a
Fix async v2 continuation tracking bugs and update tests
tommcdon Mar 7, 2026
8373fa9
Use CoreLib binder and ContinuationObject for async continuation extr…
tommcdon Mar 17, 2026
0f9e93f
Address PR review feedback: use binder field offsets, remove dead par…
tommcdon Apr 2, 2026
7b8f464
Use DEFINE_CLASS_U/DEFINE_FIELD_U for AsyncDispatcherInfo layout vali…
tommcdon Apr 2, 2026
9d34bab
Fix DWORD truncation and redundant null checks in debugdebugger.cpp
tommcdon Apr 3, 2026
b6e06c9
Fix NativeAOT async stack trace stitching and add test
tommcdon Apr 3, 2026
c0285e0
Address PR feedback: restructure NativeAOT async stack stitching
tommcdon Apr 3, 2026
cd24c9c
Harden debugdebugger.cpp async continuation extraction
tommcdon Apr 3, 2026
7638105
Address PR feedback: use MethodDesc comparison and fix NativeAOT asyn…
tommcdon Apr 5, 2026
3a5cf88
Remove unused using Internal.Runtime.Augments
tommcdon Apr 5, 2026
1fcee72
Improve env-stacktrace test: use Task.Delay(1) and move diagnostics t…
tommcdon Apr 5, 2026
9e5c641
Use HasSameMethodDefAs for DispatchContinuations check
tommcdon Apr 6, 2026
eb7a632
Use direct OBJECTREF-to-CONTINUATIONREF cast in ExtractContinuationData
tommcdon Apr 6, 2026
3271f8f
Add async frame hiding for Environment.StackTrace with tri-state config
tommcdon Apr 9, 2026
a531119
Clarify mode 0 description: show all frames with async stitching active
tommcdon Apr 12, 2026
7ce029f
Change stacktrace comments to clarify terminology for runtime async
tommcdon Apr 13, 2026
2fa0716
Rename IsAsyncV2Method to IsAsyncMethod in NativeAOT StackFrame
tommcdon Apr 14, 2026
7c0a03e
Add DOTNET_HideAsyncDispatchFrames mode 3: physical-only stack traces
tommcdon Apr 14, 2026
3d3507a
Centralize async dispatch boundary method names in NativeAOT
tommcdon Apr 15, 2026
a490632
Add FlagsMask constant and document RVA bit packing safety
tommcdon Apr 15, 2026
b862e40
Move IsAsyncMethod to separate field to avoid ARM32 THUMB bit collision
tommcdon Apr 15, 2026
3906c83
Only collect continuations from the innermost async dispatcher
tommcdon Apr 15, 2026
008c77f
Avoid small allocation if no continuations are available
tommcdon Apr 16, 2026
7e21299
Pack IsAsyncMethod into RVA bit 0 with THUMB-safe alignment masking
tommcdon Apr 16, 2026
3a529c4
Clarify mode 0 description for HideAsyncDispatchFrames
tommcdon Apr 16, 2026
487a268
Update inline comments for mode 0 to match config description
tommcdon Apr 16, 2026
2d33385
Cache DispatchContinuations MethodDesc, eliminate duplicate env read,…
tommcdon Apr 16, 2026
96dd789
Address PR feedback: rename config, revert caching, remove null checks
tommcdon Apr 17, 2026
b5d9df4
Replace name-based async dispatch boundary detection with RVA/address…
tommcdon Apr 20, 2026
e8d00d1
Do not apply async frame hiding to exception stack traces
tommcdon Apr 20, 2026
7e2cfb7
Remove config mode 2 (truncate trailing non-async frames)
tommcdon Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/coreclr/inc/clrconfigvalues.h
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ RETAIL_CONFIG_DWORD_INFO(UNSUPPORTED_RichDebugInfo, W("RichDebugInfo"), 0, "If n

RETAIL_CONFIG_DWORD_INFO(EXTERNAL_OutOfProcessSetContext, W("OutOfProcessSetContext"), 0, "If enabled the debugger will not modify thread contexts in-process. Enabled by default when CET is enabled for the process.")

///
/// Async Stack Traces
///
RETAIL_CONFIG_DWORD_INFO(EXTERNAL_HideAsyncDispatchFrames, W("HideAsyncDispatchFrames"), 1, "Controls async stack trace behavior in Environment.StackTrace: 0 = show all frames above the dispatch boundary with async continuation stitching, 1 (default) = hide non-async frames below the first runtime async frame, 2 = keep non-async frames between runtime async frames but truncate trailing ones, 3 = disable async stitching entirely and show only physical call stack frames.")
Comment thread
tommcdon marked this conversation as resolved.
Outdated
Comment thread
tommcdon marked this conversation as resolved.
Outdated

///
/// Diagnostics (internal general-purpose)
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace Internal.Runtime.Augments
[CLSCompliant(false)]
public abstract class StackTraceMetadataCallbacks
{
public abstract string TryGetMethodStackFrameInfo(IntPtr methodStartAddress, int offset, bool needsFileInfo, out string owningType, out string genericArgs, out string methodSignature, out bool isStackTraceHidden, out string fileName, out int lineNumber);
public abstract string TryGetMethodStackFrameInfo(IntPtr methodStartAddress, int offset, bool needsFileInfo, out string owningType, out string genericArgs, out string methodSignature, out bool isStackTraceHidden, out bool isAsyncMethod, out string fileName, out int lineNumber);

public abstract DiagnosticMethodInfo TryGetDiagnosticMethodInfoFromStartAddress(IntPtr methodStartAddress);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public partial class StackFrame
private bool _noMethodBaseAvailable;

private bool _isStackTraceHidden;
private bool _isAsyncMethod;

// If stack trace metadata is available, _methodOwningType is the namespace-qualified name of the owning type,
// _methodName is the name of the method, _methodGenericArgs are generic arguments, and _methodSignature is the list of parameters
Expand All @@ -50,6 +51,11 @@ public partial class StackFrame
private string _methodGenericArgs;
private string _methodSignature;

// Well-known names for the async dispatch boundary method.
// Centralized here so they only need updating if the method is renamed.
private const string AsyncDispatchMethodName = "DispatchContinuations";
private const string AsyncDispatchOwningType = "System.Runtime.CompilerServices.AsyncHelpers.RuntimeAsyncTask`1";

/// <summary>
/// Returns the method the frame is executing
/// </summary>
Expand Down Expand Up @@ -122,7 +128,7 @@ private void InitializeForIpAddress(IntPtr ipAddress, bool needFileInfo)
StackTraceMetadataCallbacks stackTraceCallbacks = RuntimeAugments.StackTraceCallbacksIfAvailable;
if (stackTraceCallbacks != null)
{
_methodName = stackTraceCallbacks.TryGetMethodStackFrameInfo(methodStartAddress, _nativeOffset, needFileInfo, out _methodOwningType, out _methodGenericArgs, out _methodSignature, out _isStackTraceHidden, out _fileName, out _lineNumber);
_methodName = stackTraceCallbacks.TryGetMethodStackFrameInfo(methodStartAddress, _nativeOffset, needFileInfo, out _methodOwningType, out _methodGenericArgs, out _methodSignature, out _isStackTraceHidden, out _isAsyncMethod, out _fileName, out _lineNumber);
}

if (_methodName == null)
Expand Down Expand Up @@ -202,6 +208,22 @@ internal void SetIsLastFrameFromForeignExceptionStackTrace()
_isLastFrameFromForeignExceptionStackTrace = true;
}

/// <summary>
/// Returns true if this frame represents the async dispatch boundary
/// between user async frames and internal dispatch machinery.
/// </summary>
internal bool IsAsyncDispatchBoundary()
{
return _isStackTraceHidden &&
_methodName is AsyncDispatchMethodName &&
_methodOwningType is AsyncDispatchOwningType;
}

/// <summary>
/// Returns true if this frame represents a runtime async (v2) method.
/// </summary>
internal bool IsAsyncMethod => _isAsyncMethod;

/// <summary>
/// Builds a representation of the stack frame for use in the stack trace.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,62 @@ private void InitializeForCurrentThread(int skipFrames, bool needFileInfo)
IntPtr[] stackTrace = new IntPtr[frameCount];
int trueFrameCount = RuntimeImports.RhGetCurrentThreadStackTrace(stackTrace);
Debug.Assert(trueFrameCount == frameCount);
InitializeForIpAddressArray(stackTrace, skipFrames + SystemDiagnosticsStackDepth, frameCount, needFileInfo);

int adjustedSkip = skipFrames + SystemDiagnosticsStackDepth;

// Read the config to determine async behavior before collecting continuations.
string? envValue = Environment.GetEnvironmentVariable("DOTNET_HideAsyncDispatchFrames");
int hideMode = envValue switch
{
"0" => 0,
"2" => 2,
"3" => 3,
_ => 1,
};
Comment thread
tommcdon marked this conversation as resolved.

// Mode 3 (physical only): skip continuation collection entirely.
IntPtr[]? continuationIPs = hideMode == 3 ? null : CollectAsyncContinuationIPs();
InitializeForIpAddressArray(stackTrace, adjustedSkip, trueFrameCount, needFileInfo, continuationIPs, hideMode);
}

/// <summary>
/// When executing inside a runtime async (v2) continuation dispatch, collect
/// the DiagnosticIP values from the async continuation chain.
/// Returns null if not inside a dispatch or no valid continuation IPs exist.
/// </summary>
private static unsafe IntPtr[]? CollectAsyncContinuationIPs()
{
AsyncDispatcherInfo* pInfo = AsyncDispatcherInfo.t_current;
if (pInfo is null)
return null;

// Only collect continuations from the innermost (first) dispatcher in the chain.
// Outer dispatchers represent already-completed async scopes and are not displayed.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// Outer dispatchers represent already-completed async scopes and are not displayed.
// Outer dispatchers represent logically distinct async stacks and are not displayed.

I suspect in many cases the outer dispatchers aren't complete but it doesn't matter because they aren't (necessarily) part of the same async stack.

Continuation? cont = pInfo->NextContinuation;
if (cont is null)
return null;

IntPtr[] buffer = new IntPtr[16];
int count = 0;
while (cont is not null)
{
if (cont.ResumeInfo is not null && cont.ResumeInfo->DiagnosticIP is not null)
{
if (count == buffer.Length)
Array.Resize(ref buffer, buffer.Length * 2);

buffer[count++] = (IntPtr)cont.ResumeInfo->DiagnosticIP;
}
cont = cont.Next;
}

if (count == 0)
return null;

if (count < buffer.Length)
Array.Resize(ref buffer, count);

return buffer;
}
#endif

Expand All @@ -38,42 +93,90 @@ private void InitializeForException(Exception exception, int skipFrames, bool ne

/// <summary>
/// Initialize the stack trace based on a given array of IP addresses.
/// When continuationIPs is provided, detects the async dispatch boundary
/// during frame construction and splices in continuation frames.
/// </summary>
private void InitializeForIpAddressArray(IntPtr[] ipAddresses, int skipFrames, int endFrameIndex, bool needFileInfo)
private void InitializeForIpAddressArray(IntPtr[] ipAddresses, int skipFrames, int endFrameIndex, bool needFileInfo, IntPtr[]? continuationIPs = null, int hideAsyncDispatchMode = 0)
{
int frameCount = (skipFrames < endFrameIndex ? endFrameIndex - skipFrames : 0);
int continuationCount = continuationIPs?.Length ?? 0;

// 0 = show all above dispatch boundary (with async stitching), 1 = hide all non-async after first async,
Comment thread
tommcdon marked this conversation as resolved.
Outdated
// 2 = truncate trailing non-async, 3 = physical only (no stitching)

// Calculate true frame count upfront - we need to skip EdiSeparators which get
// collapsed onto boolean flags on the preceding stack frame
int outputFrameCount = 0;
// Count physical frames upfront — EdiSeparators are collapsed onto the
Comment thread
tommcdon marked this conversation as resolved.
Outdated
// preceding frame's boolean flag and don't produce output frames.
int physicalFrameCount = 0;
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
if (ipAddresses[frameIndex + skipFrames] != Exception.EdiSeparator)
{
outputFrameCount++;
}
physicalFrameCount++;
}

if (outputFrameCount > 0)
int totalCapacity = physicalFrameCount + continuationCount;
if (totalCapacity > 0)
{
_stackFrames = new StackFrame[outputFrameCount];
_stackFrames = new StackFrame[totalCapacity];
int outputFrameIndex = 0;
bool asyncFrameSeen = false;

for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
IntPtr ipAddress = ipAddresses[frameIndex + skipFrames];
if (ipAddress != Exception.EdiSeparator)
if (ipAddress == Exception.EdiSeparator)
{
if (outputFrameIndex > 0)
_stackFrames[outputFrameIndex - 1].SetIsLastFrameFromForeignExceptionStackTrace();
continue;
}

var frame = new StackFrame(ipAddress, needFileInfo);

if (frame.IsAsyncMethod)
{
_stackFrames[outputFrameIndex++] = new StackFrame(ipAddress, needFileInfo);
asyncFrameSeen = true;
}
else if (outputFrameIndex > 0)

// When inside a v2 async dispatch, the DispatchContinuations frame marks
// the boundary between user frames and internal dispatch machinery.
// Truncate there and append the continuation chain instead.
if (continuationIPs is not null && frame.IsAsyncDispatchBoundary())
{
_stackFrames[outputFrameIndex - 1].SetIsLastFrameFromForeignExceptionStackTrace();
for (int i = 0; i < continuationCount; i++)
_stackFrames[outputFrameIndex++] = new StackFrame(continuationIPs[i], needFileInfo);
break;
}

// Mode 1: hide all non-async frames once we've seen the first async frame.
if (hideAsyncDispatchMode == 1 && asyncFrameSeen && !frame.IsAsyncMethod)
continue;

_stackFrames[outputFrameIndex++] = frame;
}
Debug.Assert(outputFrameIndex == outputFrameCount);

// Mode 2: trim trailing non-async frames below the last async frame.
if (hideAsyncDispatchMode == 2 && asyncFrameSeen)
{
int lastAsyncIndex = -1;
for (int i = 0; i < outputFrameIndex; i++)
{
if (_stackFrames[i].IsAsyncMethod)
lastAsyncIndex = i;
}
if (lastAsyncIndex >= 0)
outputFrameIndex = lastAsyncIndex + 1;
}

if (outputFrameIndex < totalCapacity)
Array.Resize(ref _stackFrames, outputFrameIndex);

_numOfFrames = outputFrameIndex;
}
else
{
_numOfFrames = 0;
}

_numOfFrames = outputFrameCount;
_methodsToSkip = 0;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,22 @@ internal static void Initialize()
[AnalysisCharacteristic]
internal static extern bool StackTraceHiddenMetadataPresent();

/// <summary>
/// Compute the aligned method RVA by masking off the low two bits.
/// On ARM32, RhFindMethodStartAddress sets bit 0 (THUMB bit) in returned addresses.
/// MinimumFunctionAlignment = 4 for all architectures guarantees bits 0-1 are always
/// zero for actual method RVAs, so we can safely use them for flag packing.
/// </summary>
private static unsafe int GetAlignedMethodRva(nint methodStart, nint moduleBase)
=> (int)((methodStart - moduleBase) & ~0x3);

/// <summary>
/// Locate the containing module for a method and try to resolve its name based on start address.
/// </summary>
public static unsafe string GetMethodNameFromStartAddressIfAvailable(IntPtr methodStartAddress, out string owningTypeName, out string genericArgs, out string methodSignature, out bool isStackTraceHidden, out int hashCodeForLineInfo)
public static unsafe string GetMethodNameFromStartAddressIfAvailable(IntPtr methodStartAddress, out string owningTypeName, out string genericArgs, out string methodSignature, out bool isStackTraceHidden, out bool isAsyncMethod, out int hashCodeForLineInfo)
{
IntPtr moduleStartAddress = RuntimeAugments.GetOSModuleFromPointer(methodStartAddress);
int rva = (int)((byte*)methodStartAddress - (byte*)moduleStartAddress);
int rva = GetAlignedMethodRva((nint)methodStartAddress, (nint)moduleStartAddress);
foreach (NativeFormatModuleInfo moduleInfo in ModuleList.EnumerateModules())
{
if (moduleInfo.Handle.OsModuleBase == moduleStartAddress)
Expand All @@ -62,6 +71,7 @@ public static unsafe string GetMethodNameFromStartAddressIfAvailable(IntPtr meth
if (resolver.TryGetStackTraceData(rva, out var data))
{
isStackTraceHidden = data.IsHidden;
isAsyncMethod = data.IsAsyncMethod;
if (data.OwningType.IsNil)
{
Debug.Assert(data.Name.IsNil && data.Signature.IsNil);
Expand All @@ -80,6 +90,7 @@ public static unsafe string GetMethodNameFromStartAddressIfAvailable(IntPtr meth
}

isStackTraceHidden = false;
isAsyncMethod = false;

// We haven't found information in the stack trace metadata tables, but maybe reflection will have this
if (ReflectionExecution.TryGetMethodMetadataFromStartAddress(methodStartAddress,
Expand Down Expand Up @@ -186,7 +197,7 @@ private static unsafe (string, int) GetLineNumberInfo(IntPtr methodStartAddress,
public static unsafe DiagnosticMethodInfo? GetDiagnosticMethodInfoFromStartAddressIfAvailable(IntPtr methodStartAddress)
{
IntPtr moduleStartAddress = RuntimeAugments.GetOSModuleFromPointer(methodStartAddress);
int rva = (int)((byte*)methodStartAddress - (byte*)moduleStartAddress);
int rva = GetAlignedMethodRva((nint)methodStartAddress, (nint)moduleStartAddress);
foreach (NativeFormatModuleInfo moduleInfo in ModuleList.EnumerateModules())
{
if (moduleInfo.Handle.OsModuleBase == moduleStartAddress)
Expand Down Expand Up @@ -331,9 +342,9 @@ public override DiagnosticMethodInfo TryGetDiagnosticMethodInfoFromStartAddress(
return GetDiagnosticMethodInfoFromStartAddressIfAvailable(methodStartAddress);
}

public override string TryGetMethodStackFrameInfo(IntPtr methodStartAddress, int offset, bool needsFileInfo, out string owningType, out string genericArgs, out string methodSignature, out bool isStackTraceHidden, out string fileName, out int lineNumber)
public override string TryGetMethodStackFrameInfo(IntPtr methodStartAddress, int offset, bool needsFileInfo, out string owningType, out string genericArgs, out string methodSignature, out bool isStackTraceHidden, out bool isAsyncMethod, out string fileName, out int lineNumber)
{
string methodName = GetMethodNameFromStartAddressIfAvailable(methodStartAddress, out owningType, out genericArgs, out methodSignature, out isStackTraceHidden, out int hashCode);
string methodName = GetMethodNameFromStartAddressIfAvailable(methodStartAddress, out owningType, out genericArgs, out methodSignature, out isStackTraceHidden, out isAsyncMethod, out int hashCode);

if (needsFileInfo)
{
Expand Down Expand Up @@ -468,12 +479,13 @@ private unsafe void PopulateRvaToTokenMap(TypeManagerHandle handle, byte* pMap,
pCurrent += sizeof(int);

Debug.Assert((nint)pMethod > handle.OsModuleBase);
int methodRva = (int)((nint)pMethod - handle.OsModuleBase);
int methodRva = GetAlignedMethodRva((nint)pMethod, handle.OsModuleBase);

_stacktraceDatas[current++] = new StackTraceData
{
Rva = methodRva,
IsHidden = (command & StackTraceDataCommand.IsStackTraceHidden) != 0,
IsAsyncMethod = (command & StackTraceDataCommand.IsAsyncMethod) != 0,
OwningType = currentOwningType,
Name = currentName,
Signature = currentSignature,
Expand Down Expand Up @@ -515,26 +527,41 @@ public bool TryGetStackTraceData(int rva, out StackTraceData data)

public struct StackTraceData : IComparable<StackTraceData>
{
// Flags are packed into the low bits of the RVA. MinimumFunctionAlignment = 4
// for all architectures guarantees bits 0-1 are always zero for aligned method
// RVAs. On ARM32 the THUMB bit (bit 0) is present in raw addresses but is
// stripped by GetAlignedMethodRva before storage.
private const int IsAsyncMethodFlag = 0x1;
private const int IsHiddenFlag = 0x2;
private const int FlagsMask = IsAsyncMethodFlag | IsHiddenFlag;

private readonly int _rvaAndIsHiddenBit;
private readonly int _rvaAndFlags;

public int Rva
{
get => _rvaAndIsHiddenBit & ~IsHiddenFlag;
get => _rvaAndFlags & ~FlagsMask;
init
{
Debug.Assert((value & IsHiddenFlag) == 0);
_rvaAndIsHiddenBit = value | (_rvaAndIsHiddenBit & IsHiddenFlag);
Debug.Assert((value & FlagsMask) == 0);
_rvaAndFlags = value | (_rvaAndFlags & FlagsMask);
}
Comment thread
tommcdon marked this conversation as resolved.
Comment thread
tommcdon marked this conversation as resolved.
Comment thread
tommcdon marked this conversation as resolved.
}
public bool IsHidden
{
get => (_rvaAndIsHiddenBit & IsHiddenFlag) != 0;
get => (_rvaAndFlags & IsHiddenFlag) != 0;
init
{
if (value)
_rvaAndFlags |= IsHiddenFlag;
}
}
public bool IsAsyncMethod
{
get => (_rvaAndFlags & IsAsyncMethodFlag) != 0;
init
{
if (value)
_rvaAndIsHiddenBit |= IsHiddenFlag;
_rvaAndFlags |= IsAsyncMethodFlag;
}
}
public Handle OwningType { get; init; }
Expand Down
Loading
Loading