diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskMethodBuilderT.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskMethodBuilderT.cs index df45c388ae4a64..8af42563433235 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskMethodBuilderT.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskMethodBuilderT.cs @@ -337,6 +337,7 @@ private protected override void InstrumentedMoveNext(Thread? threadPoolThread, A base.InstrumentedMoveNext(threadPoolThread, flags); } + [MethodImpl(MethodImplOptions.NoInlining)] [StackTraceHidden] // Diagnostic tooling depends on this name when classifying async callstack frames. private unsafe void MoveNextAsDispatcher(Thread? threadPoolThread, AsyncInstrumentation.Flags flags) diff --git a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.cs b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.cs index c7eaa7c6b94a1d..ebc315254859a2 100644 --- a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.cs +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerTests.cs @@ -163,7 +163,7 @@ private static Type GetCoreLibNestedType(string declaringTypeFullName, string ne // Asserts the async dispatch method the stitcher recognizes by name still exists under that name. // Keys mirror the string constants in the stitcher's AsyncStitchBoundary classifier. - private static void AssertAsyncDispatchMethodExists(string key) + private static MethodInfo AssertAsyncDispatchMethodExists(string key) { MethodInfo? method = key switch { @@ -184,6 +184,16 @@ private static void AssertAsyncDispatchMethodExists(string key) Assert.True(method is not null, $"Async dispatch method for contract key '{key}' was not found; the CPU stitcher recognizes it by name."); + return method!; + } + + private static void AssertMethodImplementationFlags( + MethodInfo method, MethodImplAttributes requiredFlags, string contractKey) + { + MethodImplAttributes actualFlags = method.GetMethodImplementationFlags(); + Assert.True((actualFlags & requiredFlags) == requiredFlags, + $"Async dispatch method for contract key '{contractKey}' must have implementation flags " + + $"'{requiredFlags}' but had '{actualFlags}'."); } // ------------------------------------------------------------------------------------------------ @@ -223,20 +233,29 @@ _ when name.StartsWith("Continuation_Wrapper_", StringComparison.Ordinal) => "Wr // Captures the current thread's physical managed stack UNFILTERED and returns the stitcher-recognized // async boundary labels in leaf->root order. Must be called from inside a resumed async continuation // (while the async profiler is active) so the dispatch machinery is on the stack. - private static List CaptureAsyncBoundarySequence() => CaptureAsyncStack().Boundaries; + private static (List Boundaries, List DiagnosticFrames) CaptureAsyncBoundarySequence() + { + var capture = CaptureAsyncStack(); + return (capture.Boundaries, capture.DiagnosticFrames); + } - // Captures the physical stack once and returns, both leaf->root: the stitcher boundary labels, and the - // per-frame declaring type names. The declaring type names let inline-completion tests locate a - // still-completing child's state-machine frame (its method name is the compiler-generated MoveNext, so - // it is identified by its declaring state-machine type name) relative to the resuming parent frame. - private static (List Boundaries, List DeclaringTypeNames) CaptureAsyncStack() + // Captures the physical stack once and returns, all leaf->root: the stitcher boundary labels, the + // per-frame declaring type names, and diagnostic frame names. The declaring type names let + // inline-completion tests locate a still-completing child's state-machine frame (its method name is the + // compiler-generated MoveNext, so it is identified by its declaring state-machine type name) relative + // to the resuming parent frame. The diagnostic names preserve every captured frame for failure output. + private static (List Boundaries, List DeclaringTypeNames, List DiagnosticFrames) CaptureAsyncStack() { var boundaries = new List(); var typeNames = new List(); + var diagnosticFrames = new List(); foreach (StackFrame frame in new StackTrace(fNeedFileInfo: false).GetFrames()) { DiagnosticMethodInfo? info = DiagnosticMethodInfo.Create(frame); - typeNames.Add(info?.DeclaringTypeName ?? string.Empty); + string typeName = info?.DeclaringTypeName ?? ""; + string methodName = info?.Name ?? ""; + typeNames.Add(typeName); + diagnosticFrames.Add($"{typeName}::{methodName}"); string? label = ClassifyAsyncBoundaryFrame(info); if (label is not null) @@ -245,13 +264,16 @@ private static (List Boundaries, List DeclaringTypeNames) Captur } } - return (boundaries, typeNames); + return (boundaries, typeNames, diagnosticFrames); } // Asserts that expectedLeafToRoot appears as an ordered subsequence of the captured boundary labels // (leaf->root). Subsequence (not contiguous) matching tolerates unrelated frames between boundaries // and repeated boundaries from nested resumes, while still enforcing the required relative order. - private static void AssertBoundarySubsequence(List capturedLeafToRoot, params string[] expectedLeafToRoot) + private static void AssertBoundarySubsequence( + List capturedLeafToRoot, + List diagnosticFramesLeafToRoot, + params string[] expectedLeafToRoot) { int matched = 0; foreach (string label in capturedLeafToRoot) @@ -264,13 +286,17 @@ private static void AssertBoundarySubsequence(List capturedLeafToRoot, p Assert.True(matched == expectedLeafToRoot.Length, $"Expected async boundary frames [{string.Join(" -> ", expectedLeafToRoot)}] (leaf->root) were not all " + - $"present in order. Captured boundaries (leaf->root): [{string.Join(" -> ", capturedLeafToRoot)}]."); + $"present in order. Captured boundaries (leaf->root): [{string.Join(" -> ", capturedLeafToRoot)}]. " + + $"Captured diagnostic frames (leaf->root): [{string.Join(" -> ", diagnosticFramesLeafToRoot)}]."); } // Asserts each expected fragment appears (as a substring of a declaring type name) as an ordered // subsequence of the captured declaring type names (leaf->root), tolerating unrelated frames between. // Used to locate specific user state-machine frames by the method name embedded in their generated type. - private static void AssertFrameOrder(List capturedLeafToRoot, params string[] expectedFragmentsLeafToRoot) + private static void AssertFrameOrder( + List capturedLeafToRoot, + List diagnosticFramesLeafToRoot, + params string[] expectedFragmentsLeafToRoot) { int matched = 0; foreach (string typeName in capturedLeafToRoot) @@ -284,7 +310,8 @@ private static void AssertFrameOrder(List capturedLeafToRoot, params str Assert.True(matched == expectedFragmentsLeafToRoot.Length, $"Expected frames [{string.Join(" -> ", expectedFragmentsLeafToRoot)}] (leaf->root) were not all present " + - $"in order. Captured declaring types (leaf->root): [{string.Join(" -> ", capturedLeafToRoot)}]."); + $"in order. Captured declaring types (leaf->root): [{string.Join(" -> ", capturedLeafToRoot)}]. " + + $"Captured diagnostic frames (leaf->root): [{string.Join(" -> ", diagnosticFramesLeafToRoot)}]."); } private const string AsyncProfilerEventSourceName = "System.Runtime.CompilerServices.AsyncProfilerEventSource"; diff --git a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV1Tests.cs b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV1Tests.cs index c3edba4d46f66e..a64cdfdab07c09 100644 --- a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV1Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV1Tests.cs @@ -3384,7 +3384,8 @@ public async Task StateMachineAsync_PoolingValueTask_SingleThread_ChainEventsAnd [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task StateMachineAsync_ResumeDispatchStack_ContainsExpectedBoundaryFrames(StrongBox> capture) + private static async Task StateMachineAsync_ResumeDispatchStack_ContainsExpectedBoundaryFrames( + StrongBox<(List Boundaries, List DiagnosticFrames)> capture) { await Task.Yield(); capture.Value = CaptureAsyncBoundarySequence(); @@ -3393,7 +3394,7 @@ private static async Task StateMachineAsync_ResumeDispatchStack_ContainsExpected [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsStateMachineAsyncAndThreadingSupported))] public void StateMachineAsync_ResumeDispatchStack() { - var capture = new StrongBox>(); + var capture = new StrongBox<(List Boundaries, List DiagnosticFrames)>(); var events = CollectEvents(ResumeStateMachineAsyncCallstackKeyword | StateMachineAsyncCoreKeywords, () => { @@ -3402,17 +3403,21 @@ public void StateMachineAsync_ResumeDispatchStack() // DumpAllEvents(events); - Assert.True(capture.Value is not null, + Assert.True(capture.Value.Boundaries is not null, "The state-machine continuation did not run (no boundary sequence was captured)."); // A V1 leaf resume runs through MoveNextAsDispatcher. - AssertBoundarySubsequence(capture.Value!, "V1.MoveNextAsDispatcher"); + AssertBoundarySubsequence( + capture.Value.Boundaries, + capture.Value.DiagnosticFrames, + "V1.MoveNextAsDispatcher"); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] - private static async ValueTask StateMachineAsync_PoolingResumeDispatchStack_ContainsExpectedBoundaryFrames(StrongBox> capture) + private static async ValueTask StateMachineAsync_PoolingResumeDispatchStack_ContainsExpectedBoundaryFrames( + StrongBox<(List Boundaries, List DiagnosticFrames)> capture) { await Task.Yield(); capture.Value = CaptureAsyncBoundarySequence(); @@ -3421,7 +3426,7 @@ private static async ValueTask StateMachineAsync_PoolingResumeDispatchStack_Cont [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsStateMachineAsyncAndThreadingSupported))] public void StateMachineAsync_PoolingResumeDispatchStack() { - var capture = new StrongBox>(); + var capture = new StrongBox<(List Boundaries, List DiagnosticFrames)>(); var events = CollectEvents(ResumeStateMachineAsyncCallstackKeyword | StateMachineAsyncCoreKeywords, () => { @@ -3430,10 +3435,13 @@ public void StateMachineAsync_PoolingResumeDispatchStack() // DumpAllEvents(events); - Assert.True(capture.Value is not null, + Assert.True(capture.Value.Boundaries is not null, "The pooling state-machine continuation did not run (no boundary sequence was captured)."); - AssertBoundarySubsequence(capture.Value!, "V1.AsyncStateMachineDispatcher.MoveNext"); + AssertBoundarySubsequence( + capture.Value.Boundaries, + capture.Value.DiagnosticFrames, + "V1.AsyncStateMachineDispatcher.MoveNext"); } [RuntimeAsyncMethodGeneration(false)] @@ -3446,7 +3454,7 @@ private static async Task StateMachineAsync_InlineCompletionClimb_Child(Task chi [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] private static async Task StateMachineAsync_InlineCompletionClimb_ContainsExpectedBoundaryFrames( - Task childTask, StrongBox<(List Boundaries, List Types)> capture) + Task childTask, StrongBox<(List Boundaries, List Types, List DiagnosticFrames)> capture) { // This continuation is resumed inline as the child's continuation while the child is still // completing, so the child's frames (its dispatch boundary + state-machine MoveNext) remain on the @@ -3459,7 +3467,7 @@ private static async Task StateMachineAsync_InlineCompletionClimb_ContainsExpect [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsStateMachineAsyncAndThreadingSupported))] public void StateMachineAsync_InlineCompletionClimb() { - var capture = new StrongBox<(List Boundaries, List Types)>(); + var capture = new StrongBox<(List Boundaries, List Types, List DiagnosticFrames)>(); var events = CollectEvents(ResumeStateMachineAsyncCallstackKeyword | StateMachineAsyncCoreKeywords, () => { @@ -3487,19 +3495,30 @@ public void StateMachineAsync_InlineCompletionClimb() // The still-completing child's state-machine frame remains on the stack below the resuming parent // (leaf->root: parent above child), so a CPU sample here sees the completed child leaf frame. - AssertFrameOrder(capture.Value.Types, + AssertFrameOrder( + capture.Value.Types, + capture.Value.DiagnosticFrames, nameof(StateMachineAsync_InlineCompletionClimb_ContainsExpectedBoundaryFrames), nameof(StateMachineAsync_InlineCompletionClimb_Child)); // The child was resumed as a leaf through MoveNextAsDispatcher, and that boundary is still on the // stack beneath the parent, so the completed leaf frame is paired with its dispatch boundary. - Assert.Contains("V1.MoveNextAsDispatcher", capture.Value.Boundaries); + Assert.True(capture.Value.Boundaries.Contains("V1.MoveNextAsDispatcher"), + $"Expected boundary 'V1.MoveNextAsDispatcher' was not present. Captured boundaries (leaf->root): " + + $"[{string.Join(" -> ", capture.Value.Boundaries)}]. Captured diagnostic frames (leaf->root): " + + $"[{string.Join(" -> ", capture.Value.DiagnosticFrames)}]."); } [ConditionalTheory(nameof(IsStateMachineAsyncSupported))] - [InlineData("V1.MoveNextAsDispatcher")] - [InlineData("V1.AsyncStateMachineDispatcher.MoveNext")] - public void V1AsyncDispatchMethod_NameContract_IsStable(string key) => - AssertAsyncDispatchMethodExists(key); + [InlineData("V1.MoveNextAsDispatcher", true)] + [InlineData("V1.AsyncStateMachineDispatcher.MoveNext", false)] + public void V1AsyncDispatchMethod_NameContract_IsStable(string key, bool requiresNoInlining) + { + MethodInfo method = AssertAsyncDispatchMethodExists(key); + if (requiresNoInlining) + { + AssertMethodImplementationFlags(method, MethodImplAttributes.NoInlining, key); + } + } } } diff --git a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV2Tests.cs b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV2Tests.cs index bc18b926b45377..927a1341d05ce9 100644 --- a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV2Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV2Tests.cs @@ -2662,7 +2662,8 @@ public void V2AsyncDispatchMethod_NameContract_IsStable(string key) => [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task RuntimeAsync_ResumeDispatchStack_ContainsExpectedBoundaryFrames(StrongBox> capture) + private static async Task RuntimeAsync_ResumeDispatchStack_ContainsExpectedBoundaryFrames( + StrongBox<(List Boundaries, List DiagnosticFrames)> capture) { await Task.Yield(); capture.Value = CaptureAsyncBoundarySequence(); @@ -2671,7 +2672,7 @@ private static async Task RuntimeAsync_ResumeDispatchStack_ContainsExpectedBound [ConditionalFact(nameof(IsRuntimeAsyncAndThreadingSupported))] public void RuntimeAsync_ResumeDispatchStack() { - var capture = new StrongBox>(); + var capture = new StrongBox<(List Boundaries, List DiagnosticFrames)>(); var events = CollectEvents(AllRuntimeAsyncKeywords, () => { @@ -2680,10 +2681,12 @@ public void RuntimeAsync_ResumeDispatchStack() // DumpAllEvents(events); - Assert.True(capture.Value is not null, + Assert.True(capture.Value.Boundaries is not null, "The runtime-async continuation did not run (no boundary sequence was captured)."); - AssertBoundarySubsequence(capture.Value!, + AssertBoundarySubsequence( + capture.Value.Boundaries, + capture.Value.DiagnosticFrames, "Wrapper", "V2.InstrumentedDispatchContinuations", "V2.DispatchContinuations");