Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Suppress EDI delimiter for async frames in NativeAOT stack traces by steveisok · Pull Request #130677 · dotnet/runtime · GitHub
Skip to content

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

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

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.
Fixesdotnet#129155
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
MemberAuthor

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

CopilotAI 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
FileDescription
src/libraries/System.Diagnostics.StackTrace/tests/StackTraceTests.csRemoves the NativeAOT-specific conditional so the async stack trace delimiter assertion runs everywhere.
src/coreclr/tools/Common/Internal/Runtime/StackTraceData.csAdds a new stack trace command bit (IsAsync) used in the emitted mapping blob.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/StackTraceEmissionPolicy.csDetects async frames (runtime-async and IAsyncStateMachine.MoveNext) and sets MethodStackTraceVisibilityFlags.IsAsync.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/MetadataManager.csPlumbs async visibility into StackTraceRecordFlags.IsAsync for stack trace records.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/DependencyAnalysis/StackTraceMethodMappingNode.csEmits the IsAsync command bit into the stack trace mapping blob.
src/coreclr/nativeaot/System.Private.StackTraceMetadata/src/Internal/StackTraceMetadata/StackTraceMetadata.csDecodes 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.csConsumes isAsync from callbacks and suppresses the EDI delimiter for async frames.
src/coreclr/nativeaot/System.Private.CoreLib/src/Internal/Runtime/Augments/StackTraceMetadataCallbacks.csExtends 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
MemberAuthor

Ah! I forgot to look there

@steveisok

Copy link
Copy Markdown
MemberAuthor

@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.")]
internalvoidToString(TraceFormattraceFormat,StringBuildersb)
{
// 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.
stringword_At=SR.UsingResourceKeys()?"at":SR.Word_At;
// We also want to pass in a default for inFileLineNumber.
stringinFileLineNum=SR.UsingResourceKeys()?"in {0}:line {1}":SR.StackTrace_InFileLineNumber;
stringinFileILOffset=SR.UsingResourceKeys()?"in {0}:token 0x{1:x}+0x{2:x}":SR.StackTrace_InFileILOffset;
boolfFirstFrame=true;
for(intiFrameIndex=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(' ');
boolisAsync=(mb.MethodImplementationFlags&MethodImplAttributes.Async)!=0;
Type?declaringType=mb.DeclaringType;
stringmethodName=mb.Name;
boolmethodChanged=false;
if(!isAsync&&declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false))
{
isAsync=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
if(isAsync||declaringType.IsAssignableTo(typeof(IEnumerator)))
{
methodChanged=TryResolveStateMachineMethod(refmb,outdeclaringType);
}
}
// 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 '.'
stringfullName=declaringType.FullName!;
for(inti=0;i<fullName.Length;i++)
{
charch=fullName[i];
sb.Append(ch=='+'?'.':ch);
}
sb.Append('.');
}
sb.Append(mb.Name);
// deal with the generic portion of the method
if(mbisMethodInfomi&&mi.IsGenericMethod)
{
Type[]typars=mi.GetGenericArguments();
sb.Append('[');
intk=0;
boolfFirstTyParam=true;
while(k<typars.Length)
{
if(!fFirstTyParam)
sb.Append(',');
else
fFirstTyParam=false;
sb.Append(typars[k].Name);
k++;
}
sb.Append(']');
}
ReadOnlySpan<ParameterInfo>pi=default;
boolappendParameters=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('(');
boolfFirstParam=true;
for(intj=0;j<pi.Length;j++)
{
if(!fFirstParam)
sb.Append(", ");
else
fFirstParam=false;
stringtypeName="<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());
}
elseif(LocalAppContextSwitches.ShowILOffsets&&mb.ReflectedType!=null)
{
stringassemblyName=mb.ReflectedType.Module.ScopeName;
try
{
inttoken=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-actionsBot 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-actionsgithub-actionsBot 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 freeto 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

@steveisok@MichalStrehovsky