Skip to content

Suppress EDI delimiter for async frames in NativeAOT stack traces#130677

Open
steveisok wants to merge 1 commit into
dotnet:mainfrom
steveisok:steveisok-asyncv1-naot-stacktrace-noise
Open

Suppress EDI delimiter for async frames in NativeAOT stack traces#130677
steveisok wants to merge 1 commit into
dotnet:mainfrom
steveisok:steveisok-asyncv1-naot-stacktrace-noise

Conversation

@steveisok

Copy link
Copy Markdown
Member

Classic (state-machine) async methods that throw emitted an extraneous "--- End of stack trace from previous location ---" delimiter under NativeAOT, whereas CoreCLR suppresses it for async frames. NativeAOT has no reflection available when formatting a stack trace, so the "is async" information must be baked into the stack trace metadata at compile time.

Plumb a new IsAsync flag through the AOT stack trace metadata pipeline, mirroring the existing IsHidden flag: the emission policy detects runtime-async (V2) methods and the MoveNext method of a compiler-generated (V1) IAsyncStateMachine, the flag flows through MetadataManager and the stack trace mapping node into the runtime decode, and StackFrame honors it by skipping the delimiter for async frames.

Fixes #129155

Classic (state-machine) async methods that throw emitted an extraneous
"--- End of stack trace from previous location ---" delimiter under
NativeAOT, whereas CoreCLR suppresses it for async frames. NativeAOT has
no reflection available when formatting a stack trace, so the "is async"
information must be baked into the stack trace metadata at compile time.

Plumb a new IsAsync flag through the AOT stack trace metadata pipeline,
mirroring the existing IsHidden flag: the emission policy detects
runtime-async (V2) methods and the MoveNext method of a compiler-generated
(V1) IAsyncStateMachine, the flag flows through MetadataManager and the
stack trace mapping node into the runtime decode, and StackFrame honors it
by skipping the delimiter for async frames.

Fixes dotnet#129155

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 14, 2026 12:47
@steveisok
steveisok requested a review from a team July 14, 2026 12:47
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@steveisok

Copy link
Copy Markdown
Member Author

@rcj1 trying to get something started for you. Feel free to take it over.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 adds an IsAsync flag to NativeAOT stack trace metadata and uses it at runtime to suppress the --- End of stack trace from previous location --- delimiter for async frames, aligning formatting behavior with CoreCLR.

Changes:

  • Extend the NativeAOT stack trace metadata pipeline (emission policy → metadata records → mapping blob → runtime decode) with an IsAsync flag.
  • Update NativeAOT stack frame formatting to skip the EDI delimiter when the last “foreign stack trace” frame is async.
  • Enable the existing async stack trace test assertion on NativeAOT by removing the conditional skip.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.cs Removes the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.cs Adds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.cs Detects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.cs Plumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.cs Emits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.cs Decodes the new IsAsync bit from the mapping blob and exposes it through stack trace callbacks.
src/coreclr/nativeaot/System.Private.CoreLib/src/System/Diagnostics/StackFrame.NativeAot.cs Consumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.cs Extends callback contract to return isAsync alongside existing stack trace metadata.

Comment on lines 521 to +523
private const int IsHiddenFlag = 0x2;
private const int IsAsyncFlag = 0x1;
private const int FlagsMask = IsHiddenFlag | IsAsyncFlag;

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.

This concern is legitimate, I commented on this in Tom's PR.

@MichalStrehovsky

Copy link
Copy Markdown
Member

This is crossing streams with #125396 that is also adding an IsAsync bit to this and defines it differently. Cc @tommcdon

@steveisok

Copy link
Copy Markdown
Member Author

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
Member Author

@tommcdon I think we align on the plumbing in #125396. Outside of that, I think the change still has value.

Comment on lines +72 to +95
// Async state machines only expose their exception-throwing code through IAsyncStateMachine.MoveNext.
if (method.Name != "MoveNext"u8)
{
return false;
}

if (!_iAsyncStateMachineTypeComputed)
{
_iAsyncStateMachineType = method.Context.SystemModule.GetType("System.Runtime.CompilerServices"u8, "IAsyncStateMachine"u8, throwIfNotFound: false);
_iAsyncStateMachineTypeComputed = true;
}

if (_iAsyncStateMachineType == null)
{
return false;
}

foreach (DefType interfaceType in method.OwningType.RuntimeInterfaces)
{
if (interfaceType == _iAsyncStateMachineType)
{
return true;
}
}

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.

Does this meaningfully improve the stack trace experience in async v1? The EDI separator being present falls out from all of this reflection based code that we can't use with native AOT:

#if !NATIVEAOT
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode",
Justification = "ToString is best effort when it comes to available information.")]
internal void ToString(TraceFormat traceFormat, StringBuilder sb)
{
// Passing a default string for "at" in case SR.UsingResourceKeys() is true
// as this is a special case and we don't want to have "Word_At" on stack traces.
string word_At = SR.UsingResourceKeys() ? "at" : SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
string inFileLineNum = SR.UsingResourceKeys() ? "in {0}:line {1}" : SR.StackTrace_InFileLineNumber;
string inFileILOffset = SR.UsingResourceKeys() ? "in {0}:token 0x{1:x}+0x{2:x}" : SR.StackTrace_InFileILOffset;
bool fFirstFrame = true;
for (int iFrameIndex = 0; iFrameIndex < _numOfFrames; iFrameIndex++)
{
StackFrame? sf = GetFrame(iFrameIndex);
MethodBase? mb = sf?.GetMethod();
if (mb != null && (ShowInStackTrace(mb) ||
(iFrameIndex == _numOfFrames - 1))) // Don't filter last frame
{
// We want a newline at the end of every line except for the last
if (fFirstFrame)
fFirstFrame = false;
else
sb.AppendLine();
sb.Append(" ").Append(word_At).Append(' ');
bool isAsync = (mb.MethodImplementationFlags & MethodImplAttributes.Async) != 0;
Type? declaringType = mb.DeclaringType;
string methodName = mb.Name;
bool methodChanged = false;
if (!isAsync && declaringType != null && IsDefinedSafe(declaringType, typeof(CompilerGeneratedAttribute), inherit: false))
{
isAsync = declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if (isAsync || declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged = TryResolveStateMachineMethod(ref mb, out declaringType);
}
}
// if there is a type (non global method) print it
// ResolveStateMachineMethod may have set declaringType to null
if (declaringType != null)
{
// Append t.FullName, replacing '+' with '.'
string fullName = declaringType.FullName!;
for (int i = 0; i < fullName.Length; i++)
{
char ch = fullName[i];
sb.Append(ch == '+' ? '.' : ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if (mb is MethodInfo mi && mi.IsGenericMethod)
{
Type[] typars = mi.GetGenericArguments();
sb.Append('[');
int k = 0;
bool fFirstTyParam = true;
while (k < typars.Length)
{
if (!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam = false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo> pi = default;
bool appendParameters = true;
try
{
pi = mb.GetParametersAsSpan();
}
catch
{
// The parameter info cannot be loaded, so we don't
// append the parameter list.
appendParameters = false;
}
if (appendParameters)
{
// arguments printing
sb.Append('(');
bool fFirstParam = true;
for (int j = 0; j < pi.Length; j++)
{
if (!fFirstParam)
sb.Append(", ");
else
fFirstParam = false;
string typeName = "<UnknownType>";
if (pi[j].ParameterType != null)
typeName = pi[j].ParameterType.Name;
sb.Append(typeName);
string? parameterName = pi[j].Name;
if (parameterName != null)
{
sb.Append(' ');
sb.Append(parameterName);
}
}
sb.Append(')');
}
if (methodChanged)
{
// Append original method name e.g. +MoveNext()
sb.Append('+');
sb.Append(methodName);
sb.Append('(').Append(')');
}
// source location printing
if (sf!.GetILOffset() != -1)
{
// If we don't have a PDB or PDB-reading is disabled for the module,
// then the file name will be null.
string? fileName = sf.GetFileName();
if (fileName != null)
{
// tack on " in c:\tmp\MyFile.cs:line 5"
sb.Append(' ');
sb.AppendFormat(CultureInfo.InvariantCulture, inFileLineNum, fileName, sf.GetFileLineNumber());
}
else if (LocalAppContextSwitches.ShowILOffsets && mb.ReflectedType != null)
{
string assemblyName = mb.ReflectedType.Module.ScopeName;
try
{
int token = mb.MetadataToken;
sb.Append(' ');
sb.AppendFormat(CultureInfo.InvariantCulture, inFileILOffset, assemblyName, token, sf.GetILOffset());
}
catch (InvalidOperationException) { }
}
}
// Skip EDI boundary for async
if (sf.IsLastFrameFromForeignExceptionStackTrace && !isAsync)
{
sb.AppendLine();
// Passing default for Exception_EndStackTraceFromPreviousThrow in case SR.UsingResourceKeys is set.
sb.Append(SR.UsingResourceKeys() ? "--- End of stack trace from previous location ---" : SR.Exception_EndStackTraceFromPreviousThrow);
}
}
}
if (traceFormat == TraceFormat.TrailingNewLine)
sb.AppendLine();
}
#endif // !NATIVEAOT

It is likely that when/if we decide we want to fix asyncv1 stack traces, the EDI separators disappearing will also naturally fall out from all the extra metadata we'll need to emit and all of this special casing will be reverted (or... if we let copilot do it, the special casing will probably stay because copilot is usually not capable of identifying redundant code).

I'd delete this code.

Comment on lines 521 to +523
private const int IsHiddenFlag = 0x2;
private const int IsAsyncFlag = 0x1;
private const int FlagsMask = IsHiddenFlag | IsAsyncFlag;

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.

This concern is legitimate, I commented on this in Tom's PR.

tommcdon added a commit to tommcdon/runtime that referenced this pull request Jul 19, 2026
… ctor

Introduce async v2 (runtime-async) continuation stitching for stack traces
so logical async call chains remain visible across suspension points.

Public API:
- Environment.AsyncStackTrace: string property mirroring Environment.StackTrace
  that returns the current trace with runtime-async continuation frames spliced
  in and non-async infrastructure frames hidden.
- StackTrace(int skipFrames, bool fNeedFileInfo, bool fIncludeAsyncContinuationFrames):
  new public constructor exposing the same stitching over the StackTrace object
  model.

Implementation:
- CoreCLR native stitching in debugdebugger.cpp (ExtractContinuationData,
  fnUnwrapToRAT, fnFindRATWaiter), recognizing RuntimeAsyncTaskContinuation.
- NativeAOT managed stitching (continuation-IP collection, waiter-chain walk)
  with an IsAsync flag plumbed through the AOT stack-trace metadata pipeline.
- Mono accepts and ignores the stitching flag (runtime-async unsupported).

The NativeAOT IsAsync metadata flag is aligned with dotnet#130677:
shared naming, an emission policy covering runtime-async (v2) and v1 MoveNext
state machines, and EDI delimiter suppression for async frames.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d562c8ba-a68c-41e9-afc9-7807ed98098b
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Workflow state for the Holistic Review Orchestrator.

{
  "version": 5,
  "last_dispatched_commit": "6f621c01124d308649c947e6da9b51d98a32c6b9",
  "last_dispatched_base_ref": "main",
  "last_dispatched_base_sha": "ed84dd37d6728e9d0d50a37a323eddcc5c57cd71",
  "last_reviewed_commit": "6f621c01124d308649c947e6da9b51d98a32c6b9",
  "last_reviewed_base_ref": "main",
  "last_reviewed_base_sha": "ed84dd37d6728e9d0d50a37a323eddcc5c57cd71",
  "last_recorded_worker_run_id": "29684008416",
  "review_attempt_commit": "",
  "review_attempt_base_ref": "",
  "review_attempt_count": 0,
  "max_review_attempts": 5,
  "review_history_format": "holistic-review-disclosure-v1",
  "review_history": [
    {
      "commit": "6f621c01124d308649c947e6da9b51d98a32c6b9",
      "review_id": 4730636799
    }
  ]
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Holistic Review

Motivation: Classic (V1) state-machine async methods that throw were emitting an extraneous --- End of stack trace from previous location --- delimiter under NativeAOT, diverging from CoreCLR, which suppresses it for async frames. Because NativeAOT has no reflection when formatting a stack trace, the "is async" fact must be baked into stack-trace metadata at compile time. This is a legitimate, well-scoped correctness fix that also re-enables a previously ActiveIssue-quarantined test (#129155).

Approach: A new IsAsync flag is plumbed end-to-end, mirroring the existing IsHidden flag: EcmaMethodStackTraceEmissionPolicy detects runtime-async (V2) methods and the MoveNext method of a compiler-generated IAsyncStateMachine (V1); the flag flows through MethodStackTraceVisibilityFlagsStackTraceRecordFlags → the StackTraceDataCommand.IsAsync command byte → runtime decode in StackTraceMetadata, and finally StackFrame.AppendToStackTrace skips the delimiter when _isAsync is set. The layering mirrors the established pattern well and the emission-policy detection (interface-based IAsyncStateMachine check, cached type lookup, MoveNext name gate) is sound and appropriately narrow.

Summary: The design is correct and the fix is minimal and consistent with existing conventions. However, the runtime-side bit packing in StackTraceMetadata.StackTraceData introduces an ARM32 correctness bug: the new IsAsyncFlag = 0x1 reuses bit 0 of the packed RVA word, which on ARM32 carries the Thumb bit and is therefore always set for method entrypoints. This can trip the Rva init assert in checked builds, spuriously mark nearly all ARM32 frames async, and/or break the RVA binary search so stack-trace metadata is lost. See the inline comment on the flag definition. Aside from that one issue, the change looks good. Recommend addressing the bit-0 collision (use a bit the RVA never occupies, or store flags outside the RVA word) before merge.

Detailed Findings

  • [ARM32 Thumb-bit collision — blocking] IsAsyncFlag = 0x1 in StackTraceData overlaps the ARM32 Thumb bit that method entrypoint symbols carry (ObjectWriter sets thumbBit = 1 for ARM methods, and StackTraceMethodMappingNode emits RELPTR32 relocs to those entrypoints). The pre-existing code deliberately used only IsHiddenFlag = 0x2, leaving bit 0 as RVA payload. Details and remediation options in the inline comment.

  • [Non-blocking observation] The emission-policy IsAsyncFrame gates on method.Name != "MoveNext"u8 before the interface check, which correctly avoids misclassifying non-async MoveNext (e.g. IEnumerator) implementations because it further requires the owning type to implement IAsyncStateMachine. No change needed.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 149.4 AIC · ⌖ 11.1 AIC · ⊞ 10K

public struct StackTraceData : IComparable<StackTraceData>
{
private const int IsHiddenFlag = 0x2;
private const int IsAsyncFlag = 0x1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bit 0 collides with the ARM32 Thumb bit. The original code intentionally used IsHiddenFlag = 0x2 (bit 1) and reserved bit 0 as part of the RVA. On ARM32, method entrypoint symbols are defined with the Thumb bit set (+1) per the AAELF ABI — see ObjectWriter.EmitObjectFile where thumbBit = _nodeFactory.Target.Architecture == TargetArchitecture.ARM && isMethod ? 1 : 0. The StackTraceMethodMappingNode emits IMAGE_REL_BASED_RELPTR32 relocs against factory.MethodEntrypoint(...), so the decoded methodRva in PopulateRvaToTokenMap will have bit 0 set on ARM32.

Repurposing bit 0 as IsAsyncFlag breaks this on ARM32:

  • The Rva { init } Debug.Assert((value & FlagsMask) == 0) will fire in debug/checked builds because methodRva is odd.
  • IsAsync would be spuriously true for essentially every ARM32 method (bit 0 is always set).
  • Rva now masks off bit 0 on the stored side, so if the runtime lookup RVA still carries the Thumb bit, the Array.BinarySearch in TryGetStackTraceData mismatches and stack-trace metadata (method names, hidden/async flags) is lost.

Use a spare high bit instead of bit 0. StackTraceDataCommand.IsAsync = 0x20 is a distinct value in the emitted command byte and is unrelated to this packed-RVA field, so pick a flag bit here that does not overlap the RVA's low bit — e.g. keep the async flag in a bit that ARM32 RVAs never use, or store the flags outside the RVA word entirely.

tommcdon added a commit to tommcdon/runtime that referenced this pull request Jul 22, 2026
… ctor

Introduce async v2 (runtime-async) continuation stitching for stack traces
so logical async call chains remain visible across suspension points, and
extend it across the v2 -> v1 boundary so classic async framework frames
(e.g. an ASP.NET middleware pipeline) are recovered as well.

Public API:
- Environment.AsyncStackTrace: string property mirroring Environment.StackTrace
  that returns the current trace with runtime-async continuation frames spliced
  in and non-async infrastructure frames hidden.
- StackTrace(int skipFrames, bool fNeedFileInfo, bool fIncludeAsyncContinuationFrames):
  new public constructor exposing the same stitching over the StackTrace object
  model.

Implementation:
- CoreCLR native stitching in debugdebugger.cpp (ExtractContinuationData) walks
  the task waiter chain. At each hop the waiter is either a RuntimeAsyncTask (v2,
  whose stored continuation chain is appended) or an AsyncStateMachineBox (v1,
  whose state-machine MoveNext frame is recovered and mapped by the formatter
  back to the original async method). Resolution follows the RuntimeAsyncTask
  waiter field precisely to avoid callee back-references.
- NativeAOT managed stitching (continuation-IP collection, waiter-chain walk)
  with an IsAsync flag plumbed through the AOT stack-trace metadata pipeline.
- Mono accepts and ignores the stitching flag (runtime-async unsupported).

The NativeAOT IsAsync metadata flag is aligned with dotnet#130677:
shared naming, an emission policy covering runtime-async (v2) and v1 MoveNext
state machines, and EDI delimiter suppression for async frames.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d562c8ba-a68c-41e9-afc9-7807ed98098b
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.

AsyncV1 NAOT stacktrace noise

3 participants