From 424b8205aafcb4f14240a8a6d56602a51545e75b Mon Sep 17 00:00:00 2001 From: lateralusX Date: Tue, 19 May 2026 15:54:54 +0200 Subject: [PATCH 01/19] First set of instrumentation of asyncv1, completing all 24 scenarios. --- .../System.Private.CoreLib.Shared.projitems | 1 + .../CompilerServices/AsyncTaskDispatcher.cs | 260 ++++++++++++++++++ .../AsyncTaskMethodBuilderT.cs | 45 +++ .../ConfiguredValueTaskAwaitable.cs | 6 + .../PoolingAsyncValueTaskMethodBuilderT.cs | 4 + .../Runtime/CompilerServices/TaskAwaiter.cs | 2 + .../CompilerServices/ValueTaskAwaiter.cs | 10 + .../CompilerServices/YieldAwaitable.cs | 8 + .../src/System/Threading/Tasks/Task.cs | 24 ++ .../Threading/Tasks/TaskContinuation.cs | 6 + 10 files changed, 366 insertions(+) create mode 100644 src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs diff --git a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems index 3668b72acffbf9..ec978091d6ee1b 100644 --- a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems +++ b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems @@ -945,6 +945,7 @@ + diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs new file mode 100644 index 00000000000000..d06abc56ff0dd1 --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -0,0 +1,260 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Runtime.CompilerServices +{ + /// + /// Stack-allocated TLS frame for V1 (Task-based) async profiler dispatch. + /// Mirrors AsyncDispatcherInfo used by V2 (RuntimeAsync) dispatch. + /// Pushed/popped by during chain execution. + /// + [StructLayout(LayoutKind.Explicit)] + internal unsafe ref struct AsyncTaskDispatcherInfo + { + /// Linked list pointer to the parent dispatcher info on the stack, or null if this is the outermost. + [FieldOffset(0)] + public AsyncTaskDispatcherInfo* Next; + + /// The inner async state machine box being dispatched. Used by debugger/SOS to identify the async method. +#if TARGET_64BIT + [FieldOffset(8)] +#else + [FieldOffset(4)] +#endif + public IAsyncStateMachineBox? InnerBox; + + /// The dispatcher wrapping this chain. Used to identify the current dispatcher context. +#if TARGET_64BIT + [FieldOffset(16)] +#else + [FieldOffset(8)] +#endif + public AsyncTaskDispatcher? Dispatcher; + + /// Set to true when a method yields during dispatch (Create fires inside active frame). +#if TARGET_64BIT + [FieldOffset(24)] +#else + [FieldOffset(12)] +#endif + public bool Suspended; + + /// Set to true by SetException when an async method faults. Consumed by the next MoveNext to emit an unwind event. +#if TARGET_64BIT + [FieldOffset(25)] +#else + [FieldOffset(13)] +#endif + public bool PendingUnwind; + + /// Async profiler bulk buffer and continuation wrapper state. +#if TARGET_64BIT + [FieldOffset(32)] +#else + [FieldOffset(16)] +#endif + public AsyncProfiler.Info AsyncProfilerInfo; + + /// TLS linked list head for V1 async dispatch tracking. + [ThreadStatic] + internal static unsafe AsyncTaskDispatcherInfo* t_current; + + /// + /// Marks a pending unwind on the current TLS frame. Called by SetException + /// so the next MoveNext in the chain emits the unwind event between methods. + /// + internal static unsafe void MarkPendingUnwind() + { + AsyncTaskDispatcherInfo* current = t_current; + if (current != null) + { + current->PendingUnwind = true; + } + } + + /// + /// Checks and consumes a pending unwind on the current TLS frame. + /// Called at the top of MoveNext before the state machine runs. + /// + /// True if an unwind was pending and has been consumed. + internal static unsafe bool ConsumePendingUnwind() + { + AsyncTaskDispatcherInfo* current = t_current; + if (current != null && current->PendingUnwind) + { + current->PendingUnwind = false; + return true; + } + + return false; + } + + /// + /// Checks and consumes the Suspended flag on the current TLS frame. + /// Called by CompleteAsyncMethod — if the frame was suspended but a method is completing, + /// the chain has resumed execution past the yield point. + /// + /// True if the frame was suspended and has been cleared. + internal static unsafe bool ConsumeSuspended() + { + AsyncTaskDispatcherInfo* current = t_current; + if (current != null && current->Suspended) + { + current->Suspended = false; + return true; + } + + return false; + } + + /// + /// Returns true if a dispatcher context is active on the current thread. + /// Used to guard method-level events so they only emit inside a dispatch context. + /// + internal static unsafe bool IsActive => t_current != null; + } + + /// + /// V1 (Task-based) async profiler dispatch node. Wraps an + /// to manage TLS context frame push/pop and emit context-level profiler events. + /// Each yield point creates a new dispatcher; the chain is linked via Suspend/Resume events. + /// + /// + /// Injected at UnsafeOnCompletedInternal (when awaiting a non-async task) + /// and at YieldAwaiter.AwaitUnsafeOnCompleted (yield is always a root). + /// Only allocated when the async profiler is active. A new dispatcher is created + /// per yield; each runs independently with no shared mutable state. + /// + internal sealed class AsyncTaskDispatcher : Task, IAsyncStateMachineBox + { + private IAsyncStateMachineBox? _inner; + private Action? _moveNextAction; + private readonly bool _resumesFromSuspension; + + internal AsyncTaskDispatcher(IAsyncStateMachineBox inner, bool resumesFromSuspension = false) : base() + { + _inner = inner; + _resumesFromSuspension = resumesFromSuspension; + } + + /// + /// Creates a new dispatcher for the given box. If a dispatcher is already active on the + /// current thread (mid-chain yield), marks the current frame as suspended and emits + /// a Suspend event. The new dispatcher is flagged to emit a Resume on its first PUSH. + /// + internal static unsafe AsyncTaskDispatcher Create(IAsyncStateMachineBox box) + { + AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; + if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) + { + // Chain is yielding — mark current frame and emit Suspend inline + current->Suspended = true; + Debug.WriteLine($"[AsyncProfiler:V1] SuspendAsyncContext dispatcher={activeDispatcher.Id} thread={Environment.CurrentManagedThreadId}"); + + // New dispatcher continues the suspended chain + var dispatcher = new AsyncTaskDispatcher(box, resumesFromSuspension: true); + return dispatcher; + } + + var newDispatcher = new AsyncTaskDispatcher(box); + Debug.WriteLine($"[AsyncProfiler:V1] CreateAsyncContext dispatcher={newDispatcher.Id} innerBox={((Task)box).Id} thread={Environment.CurrentManagedThreadId}"); + return newDispatcher; + } + + /// + /// Creates a new dispatcher for a continuation being queued (not inlined). + /// If a dispatcher is active, marks the frame as suspended and creates a new dispatcher + /// that will resume the chain. Otherwise returns the box unchanged (no dispatcher needed). + /// + internal static unsafe IAsyncStateMachineBox ReuseOrPassthrough(IAsyncStateMachineBox box) + { + AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; + if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) + { + // Chain is yielding — mark current frame and emit Suspend inline + current->Suspended = true; + Debug.WriteLine($"[AsyncProfiler:V1] SuspendAsyncContext dispatcher={activeDispatcher.Id} thread={Environment.CurrentManagedThreadId}"); + + // New dispatcher continues the suspended chain + var dispatcher = new AsyncTaskDispatcher(box, resumesFromSuspension: true); + return dispatcher; + } + + return box; + } + + internal sealed override void ExecuteFromThreadPool(Thread threadPoolThread) + { + MoveNext(); + } + + public void MoveNext() + { + IAsyncStateMachineBox? inner = _inner; + if (inner is null) + return; + + unsafe + { + AsyncTaskDispatcherInfo dispatcherInfo; + ref AsyncTaskDispatcherInfo* refCurrent = ref AsyncTaskDispatcherInfo.t_current; + AsyncTaskDispatcherInfo* previous = refCurrent; + refCurrent = &dispatcherInfo; + dispatcherInfo.Next = previous; + dispatcherInfo.InnerBox = inner; + dispatcherInfo.Dispatcher = this; + dispatcherInfo.Suspended = false; + AsyncProfiler.InitInfo(ref dispatcherInfo.AsyncProfilerInfo); + + if (_resumesFromSuspension) + { + Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncContext dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); + } + else + { + Debug.WriteLine($"[AsyncProfiler:V1] AsyncTaskDispatcher.MoveNext PUSH dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); + } + + try + { + inner.MoveNext(); + } + finally + { + if (dispatcherInfo.PendingUnwind) + { + dispatcherInfo.PendingUnwind = false; + Debug.WriteLine($"[AsyncProfiler:V1] UnwindAsyncException (escaped) dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); + } + + if (!dispatcherInfo.Suspended) + { + Debug.WriteLine($"[AsyncProfiler:V1] CompleteAsyncContext dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); + } + // If Suspended, the Suspend event was already emitted inline by Create + + refCurrent = dispatcherInfo.Next; + } + } + } + + public Action MoveNextAction => _moveNextAction ??= MoveNext; + + public IAsyncStateMachine GetStateMachineObject() + { + IAsyncStateMachineBox? inner = _inner; + return inner is not null ? inner.GetStateMachineObject() : null!; + } + + public void ClearStateUponCompletion() + { + _inner?.ClearStateUponCompletion(); + _inner = null; + } + } +} 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 8d3e233865c558..c64104c4eea56d 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 @@ -353,6 +353,28 @@ private void MoveNext(Thread? threadPoolThread) { Debug.Assert(!IsCompleted); + bool asyncProfilerActive = AsyncTaskDispatcherInfo.IsActive; + string? methodName = null; + + if (asyncProfilerActive) + { + methodName = typeof(TStateMachine).Name; + + if (AsyncTaskDispatcherInfo.ConsumePendingUnwind()) + { + Debug.WriteLine($"[AsyncProfiler:V1] UnwindAsyncException box={Id} method={methodName} thread={Environment.CurrentManagedThreadId}"); + } + + // If the frame was suspended (a sub-chain was spawned) but we're now resuming + // a method on the same frame, the chain has resumed from suspension. + if (AsyncTaskDispatcherInfo.ConsumeSuspended()) + { + Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncContext (inline) box={Id} method={methodName} thread={Environment.CurrentManagedThreadId}"); + } + + Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncMethod box={Id} method={methodName} thread={Environment.CurrentManagedThreadId}"); + } + bool loggingOn = TplEventSource.Log.IsEnabled(); if (loggingOn) { @@ -381,6 +403,10 @@ private void MoveNext(Thread? threadPoolThread) { ClearStateUponCompletion(); } + else if (asyncProfilerActive) + { + Debug.WriteLine($"[AsyncProfiler:V1] YieldAsyncMethod box={Id} method={methodName}"); + } if (loggingOn) { @@ -486,6 +512,23 @@ internal static void SetExistingTaskResult(Task task, TResult? result) { Debug.Assert(task != null, "Expected non-null task"); + if (AsyncTaskDispatcherInfo.IsActive) + { + if (AsyncTaskDispatcherInfo.ConsumePendingUnwind()) + { + Debug.WriteLine($"[AsyncProfiler:V1] UnwindAsyncException (caught) box={task.Id} thread={Environment.CurrentManagedThreadId}"); + } + + // If the frame was suspended (a sub-chain was spawned via GetOrCreate) but we're + // now completing a method, the chain has resumed — emit Resume and clear the flag. + if (AsyncTaskDispatcherInfo.ConsumeSuspended()) + { + Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncContext (inline) thread={Environment.CurrentManagedThreadId}"); + } + + Debug.WriteLine($"[AsyncProfiler:V1] CompleteAsyncMethod box={task.Id} thread={Environment.CurrentManagedThreadId}"); + } + if (TplEventSource.Log.IsEnabled()) { TplEventSource.Log.TraceOperationEnd(task.Id, AsyncCausalityStatus.Completed); @@ -516,6 +559,8 @@ internal static void SetException(Exception exception, ref Task? taskFi // Get the task, forcing initialization if it hasn't already been initialized. Task task = (taskField ??= new Task()); + AsyncTaskDispatcherInfo.MarkPendingUnwind(); + // If the exception represents cancellation, cancel the task. Otherwise, fault the task. bool successfullySet = exception is OperationCanceledException oce ? task.TrySetCanceled(oce.CancellationToken, oce) : diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs index 3ed0a443e72d17..bafdbc392d6895 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs @@ -102,6 +102,9 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { +#if !MONO + box = AsyncTaskDispatcher.Create(box); +#endif Unsafe.As(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, _value._continueOnCapturedContext ? ValueTaskSourceOnCompletedFlags.UseSchedulingContext : ValueTaskSourceOnCompletedFlags.None); } @@ -207,6 +210,9 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { +#if !MONO + box = AsyncTaskDispatcher.Create(box); +#endif Unsafe.As>(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, _value._continueOnCapturedContext ? ValueTaskSourceOnCompletedFlags.UseSchedulingContext : ValueTaskSourceOnCompletedFlags.None); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs index 2c68e487d93c73..fe9bf41e417cff 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs @@ -399,6 +399,10 @@ private static void ExecutionContextCallback(object? s) void IThreadPoolWorkItem.Execute() => MoveNext(); /// Calls MoveNext on + // TODO-AsyncProfiler: This MoveNext lacks profiler instrumentation (Resume/Complete/Yield events). + // The pooling builder is opt-in and uses ManualResetValueTaskSourceCore for completion instead of + // Task.TrySetResult, so SetExistingTaskResult events don't fire here either. Needs dedicated + // instrumentation similar to AsyncTaskMethodBuilder's AsyncStateMachineBox.MoveNext. public void MoveNext() { ExecutionContext? context = Context; diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs index 4a07ab7eed5b99..6c975435a12a3a 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs @@ -198,6 +198,8 @@ internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox // and set up an ending event to be traced when the asynchronous await completes. if (TplEventSource.Log.IsEnabled() || Task.s_asyncDebuggingEnabled) { + // TODO: AsyncProfiler — the ETW path bypasses UnsafeSetContinuationForAwait and + // uses the Action-based SetContinuationForAwait. Dispatcher wrapping is not applied here. task.SetContinuationForAwait(OutputWaitEtwEvents(task, stateMachineBox.MoveNextAction), continueOnCapturedContext, flowExecutionContext: false); } else diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs index c242ad3f2ad203..80e31fbad451b6 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs @@ -94,6 +94,13 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { +#if !MONO + // TODO-AsyncProfiler: Optimize by using a static delegate + original box as state instead of + // allocating a full AsyncTaskDispatcher (Task-derived). The IValueTaskSource.OnCompleted API + // already takes Action + state separately, so we can use a lightweight static callback + // that performs PUSH/MoveNext/POP inline without the Task overhead. + box = AsyncTaskDispatcher.Create(box); +#endif Unsafe.As(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, ValueTaskSourceOnCompletedFlags.UseSchedulingContext); } else @@ -176,6 +183,9 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { +#if !MONO + box = AsyncTaskDispatcher.Create(box); +#endif Unsafe.As>(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, ValueTaskSourceOnCompletedFlags.UseSchedulingContext); } else diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs index d9bea6410babbb..54041006f644de 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs @@ -116,6 +116,14 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b { Debug.Assert(box != null); +#if !MONO + // Yield is always a root — wrap or reuse a dispatch box when the async profiler is active. + if (true /* TODO: restore: AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.ActiveFlags) */) + { + box = AsyncTaskDispatcher.Create(box); + } +#endif + // If tracing is enabled, delegate the Action-based implementation. if (TplEventSource.Log.IsEnabled()) { diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs index 5381d2d4418d0c..83bd1936f25628 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs @@ -2721,17 +2721,41 @@ internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBo { if (SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext)) { +#if !MONO + // Continuation will be queued to sync context — wrap in dispatcher to + // maintain the async chain context across the scheduling boundary. + Debug.WriteLine($"[AsyncProfiler:V1] USING none default Sync context!"); + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); +#endif tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false); goto HaveTaskContinuation; } if (TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default) { +#if !MONO + // Continuation will be queued to custom scheduler — wrap in dispatcher to + // maintain the async chain context across the scheduling boundary. + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + Debug.WriteLine($"[AsyncProfiler:V1] USING none default task scheduler!"); +#endif tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false); goto HaveTaskContinuation; } } +#if !MONO + // If we're awaiting a non-async task (I/O, Timer, TCS, etc.), + // this box is the root of a V1 async chain. Wrap or reuse a dispatch box. + // For mid-chain boxes (awaiting another async method), the continuation will + // be inlined via RunContinuations, so no dispatcher wrapping is needed here. + if (this is not IAsyncStateMachineBox) + { + Debug.WriteLine($"[AsyncProfiler:V1] WAITING on non-async task!"); + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } +#endif + // Otherwise, add the state machine box directly as the continuation. // If we're unable to because the task has already completed, queue it. if (!AddTaskContinuation(stateMachineBox, addBeforeOthers: false)) diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs index a99b3425f9b4d9..fd484a9c6cc936 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs @@ -768,6 +768,12 @@ internal static void RunOrScheduleAction(IAsyncStateMachineBox box, bool allowIn // If we're not allowed to run here, schedule the action if (!allowInlining || !IsValidLocationForInlining) { +#if !MONO + // If an async profiler dispatcher is active, reuse it so the queued + // continuation runs inside the same dispatcher context (preserving the chain). + box = AsyncTaskDispatcher.ReuseOrPassthrough(box); +#endif + // If logging is disabled, we can simply queue the box itself as a custom work // item, and its work item execution will just invoke its MoveNext. However, if // logging is enabled, there is pre/post-work we need to do around logging to From d1122248fc084386c3d5d19478635697e1fd3f90 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Fri, 22 May 2026 10:50:48 +0200 Subject: [PATCH 02/19] Instrument most async profiler events except callstacks. --- .../Runtime/CompilerServices/AsyncProfiler.cs | 61 +++ .../CompilerServices/AsyncTaskDispatcher.cs | 238 +++++------ .../AsyncTaskMethodBuilderT.cs | 53 +-- .../ConfiguredValueTaskAwaitable.cs | 16 +- .../Runtime/CompilerServices/TaskAwaiter.cs | 27 +- .../CompilerServices/ValueTaskAwaiter.cs | 16 +- .../CompilerServices/YieldAwaitable.cs | 5 +- .../src/System/Threading/Tasks/Task.cs | 24 -- .../Threading/Tasks/TaskContinuation.cs | 9 +- .../AsyncProfilerTests.cs | 18 +- .../AsyncProfilerV1Tests.cs | 390 ++++++++++++++++++ .../System.Threading.Tasks.Tests.csproj | 1 + 12 files changed, 635 insertions(+), 223 deletions(-) create mode 100644 src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV1Tests.cs diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 151365ecf071a2..8b85ec9a4b21b3 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -7,6 +7,7 @@ using System.Diagnostics.Tracing; using System.Runtime.InteropServices; using System.Threading; +using static System.Runtime.CompilerServices.AsyncProfiler; using static System.Runtime.CompilerServices.AsyncProfilerEventSource; using Serializer = System.Runtime.CompilerServices.AsyncProfiler.EventBuffer.Serializer; @@ -696,6 +697,21 @@ private static AsyncThreadContext CreateAsyncThreadContext() internal static partial class CreateAsyncContext { + public static void Create(ulong id) + { + Info info = default; + AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); + + SyncPoint.Check(context); + + if (IsEnabled.CreateAsyncContextEvent(context.ActiveEventKeywords)) + { + EmitEvent(context, Stopwatch.GetTimestamp(), id); + } + + AsyncThreadContext.Release(context); + } + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, ulong id) { const int MaxEventPayloadSize = Serializer.MaxCompressedUInt64Size; @@ -710,6 +726,32 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class ResumeAsyncContext { + public static ulong GetId(ref AsyncTaskDispatcherInfo info) + { + if (info.Dispatcher != null) + { + return info.Dispatcher.ContextId; + } + + return 0; + } + + public static void Resume(ref AsyncTaskDispatcherInfo info) + { + AsyncThreadContext context = AsyncThreadContext.Acquire(ref info.AsyncProfilerInfo); + + // TODO: SyncPoint.Check needs to re-emit V1 contexts (like V2 does). + // Temporarily bypass so Resume always fires. + SyncPoint.Check(context); + + if (IsEnabled.ResumeAsyncContextEvent(context.ActiveEventKeywords)) + { + EmitEvent(context, Stopwatch.GetTimestamp(), GetId(ref info)); + } + + AsyncThreadContext.Release(context); + } + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, ulong id) { const int MaxEventPayloadSize = Serializer.MaxCompressedUInt64Size; @@ -724,6 +766,20 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class SuspendAsyncContext { + public static void Suspend(ref Info info) + { + AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); + + SyncPoint.Check(context); + + if (IsEnabled.SuspendAsyncContextEvent(context.ActiveEventKeywords)) + { + EmitEvent(context, Stopwatch.GetTimestamp()); + } + + AsyncThreadContext.Release(context); + } + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp) { Serializer.AsyncEventHeader(context, ref context.EventBuffer, currentTimestamp, AsyncEventID.SuspendAsyncContext, 0); @@ -779,6 +835,11 @@ public static void Unhandled(ref Info info, uint unwindedFrames) } public static void Handled(ref Info info, uint unwindedFrames) + { + UnwindFrames(ref info, unwindedFrames); + } + + public static void UnwindFrames(ref Info info, uint unwindedFrames) { AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index d06abc56ff0dd1..032eb3b8fbde88 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -5,22 +5,16 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using static System.Runtime.CompilerServices.AsyncProfiler; namespace System.Runtime.CompilerServices { - /// - /// Stack-allocated TLS frame for V1 (Task-based) async profiler dispatch. - /// Mirrors AsyncDispatcherInfo used by V2 (RuntimeAsync) dispatch. - /// Pushed/popped by during chain execution. - /// [StructLayout(LayoutKind.Explicit)] internal unsafe ref struct AsyncTaskDispatcherInfo { - /// Linked list pointer to the parent dispatcher info on the stack, or null if this is the outermost. [FieldOffset(0)] public AsyncTaskDispatcherInfo* Next; - /// The inner async state machine box being dispatched. Used by debugger/SOS to identify the async method. #if TARGET_64BIT [FieldOffset(8)] #else @@ -28,7 +22,6 @@ internal unsafe ref struct AsyncTaskDispatcherInfo #endif public IAsyncStateMachineBox? InnerBox; - /// The dispatcher wrapping this chain. Used to identify the current dispatcher context. #if TARGET_64BIT [FieldOffset(16)] #else @@ -36,7 +29,6 @@ internal unsafe ref struct AsyncTaskDispatcherInfo #endif public AsyncTaskDispatcher? Dispatcher; - /// Set to true when a method yields during dispatch (Create fires inside active frame). #if TARGET_64BIT [FieldOffset(24)] #else @@ -44,15 +36,6 @@ internal unsafe ref struct AsyncTaskDispatcherInfo #endif public bool Suspended; - /// Set to true by SetException when an async method faults. Consumed by the next MoveNext to emit an unwind event. -#if TARGET_64BIT - [FieldOffset(25)] -#else - [FieldOffset(13)] -#endif - public bool PendingUnwind; - - /// Async profiler bulk buffer and continuation wrapper state. #if TARGET_64BIT [FieldOffset(32)] #else @@ -60,187 +43,160 @@ internal unsafe ref struct AsyncTaskDispatcherInfo #endif public AsyncProfiler.Info AsyncProfilerInfo; - /// TLS linked list head for V1 async dispatch tracking. [ThreadStatic] internal static unsafe AsyncTaskDispatcherInfo* t_current; - /// - /// Marks a pending unwind on the current TLS frame. Called by SetException - /// so the next MoveNext in the chain emits the unwind event between methods. - /// - internal static unsafe void MarkPendingUnwind() + internal static bool IsSuspended => t_current != null && t_current->Suspended; + + internal static unsafe AsyncTaskDispatcher? SuspendAsyncContext() + { + AsyncTaskDispatcherInfo* info = AsyncTaskDispatcherInfo.t_current; + if (info != null && info->Dispatcher is AsyncTaskDispatcher activeDispatcher) + { + Debug.Assert(!info->Suspended); + info->Suspended = true; + if (AsyncInstrumentation.IsEnabled.SuspendAsyncContext(AsyncInstrumentation.SyncActiveFlags())) + { + AsyncProfiler.SuspendAsyncContext.Suspend(ref info->AsyncProfilerInfo); + } + + return activeDispatcher; + } + + return null; + } + + internal static unsafe void UnwindAsyncFrame() { AsyncTaskDispatcherInfo* current = t_current; if (current != null) { - current->PendingUnwind = true; + AsyncProfiler.AsyncMethodException.UnwindFrames(ref current->AsyncProfilerInfo, 1); } } - /// - /// Checks and consumes a pending unwind on the current TLS frame. - /// Called at the top of MoveNext before the state machine runs. - /// - /// True if an unwind was pending and has been consumed. - internal static unsafe bool ConsumePendingUnwind() + internal static unsafe void ResumeAsyncMethod() { AsyncTaskDispatcherInfo* current = t_current; - if (current != null && current->PendingUnwind) + if (current != null) { - current->PendingUnwind = false; - return true; + AsyncProfiler.ResumeAsyncMethod.Resume(ref current->AsyncProfilerInfo); } - - return false; } - /// - /// Checks and consumes the Suspended flag on the current TLS frame. - /// Called by CompleteAsyncMethod — if the frame was suspended but a method is completing, - /// the chain has resumed execution past the yield point. - /// - /// True if the frame was suspended and has been cleared. - internal static unsafe bool ConsumeSuspended() + internal static unsafe void CompleteAsyncMethod() { AsyncTaskDispatcherInfo* current = t_current; - if (current != null && current->Suspended) + if (current != null) { - current->Suspended = false; - return true; + AsyncProfiler.CompleteAsyncMethod.Complete(ref current->AsyncProfilerInfo); } - - return false; } - - /// - /// Returns true if a dispatcher context is active on the current thread. - /// Used to guard method-level events so they only emit inside a dispatch context. - /// - internal static unsafe bool IsActive => t_current != null; } - /// - /// V1 (Task-based) async profiler dispatch node. Wraps an - /// to manage TLS context frame push/pop and emit context-level profiler events. - /// Each yield point creates a new dispatcher; the chain is linked via Suspend/Resume events. - /// - /// - /// Injected at UnsafeOnCompletedInternal (when awaiting a non-async task) - /// and at YieldAwaiter.AwaitUnsafeOnCompleted (yield is always a root). - /// Only allocated when the async profiler is active. A new dispatcher is created - /// per yield; each runs independently with no shared mutable state. - /// internal sealed class AsyncTaskDispatcher : Task, IAsyncStateMachineBox { private IAsyncStateMachineBox? _inner; private Action? _moveNextAction; - private readonly bool _resumesFromSuspension; + private ulong _contextId; - internal AsyncTaskDispatcher(IAsyncStateMachineBox inner, bool resumesFromSuspension = false) : base() + internal AsyncTaskDispatcher(IAsyncStateMachineBox inner) : base() { _inner = inner; - _resumesFromSuspension = resumesFromSuspension; + _contextId = 0; } - /// - /// Creates a new dispatcher for the given box. If a dispatcher is already active on the - /// current thread (mid-chain yield), marks the current frame as suspended and emits - /// a Suspend event. The new dispatcher is flagged to emit a Resume on its first PUSH. - /// - internal static unsafe AsyncTaskDispatcher Create(IAsyncStateMachineBox box) + internal AsyncTaskDispatcher(IAsyncStateMachineBox inner, AsyncTaskDispatcher suspended) : base() { - AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; - if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) + _inner = inner; + _contextId = suspended.ContextId; + } + + internal ulong ContextId + { + get { - // Chain is yielding — mark current frame and emit Suspend inline - current->Suspended = true; - Debug.WriteLine($"[AsyncProfiler:V1] SuspendAsyncContext dispatcher={activeDispatcher.Id} thread={Environment.CurrentManagedThreadId}"); + if (_contextId == 0) + { + return (ulong)this.Id; + } - // New dispatcher continues the suspended chain - var dispatcher = new AsyncTaskDispatcher(box, resumesFromSuspension: true); - return dispatcher; + return _contextId; } - - var newDispatcher = new AsyncTaskDispatcher(box); - Debug.WriteLine($"[AsyncProfiler:V1] CreateAsyncContext dispatcher={newDispatcher.Id} innerBox={((Task)box).Id} thread={Environment.CurrentManagedThreadId}"); - return newDispatcher; } /// - /// Creates a new dispatcher for a continuation being queued (not inlined). - /// If a dispatcher is active, marks the frame as suspended and creates a new dispatcher - /// that will resume the chain. Otherwise returns the box unchanged (no dispatcher needed). + /// Creates a new dispatcher for the given box. If a dispatcher is already active on the + /// info thread (mid-chain yield), marks the info frame as suspended and emit a suspend event. /// - internal static unsafe IAsyncStateMachineBox ReuseOrPassthrough(IAsyncStateMachineBox box) + internal static AsyncTaskDispatcher Create(IAsyncStateMachineBox box) { - AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; - if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) + Debug.WriteLine($"[AsyncTaskDispatcher.Create] Thread={Environment.CurrentManagedThreadId}, box={box.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); + + AsyncTaskDispatcher? activeDispatcher = AsyncTaskDispatcherInfo.SuspendAsyncContext(); + if (activeDispatcher != null) { - // Chain is yielding — mark current frame and emit Suspend inline - current->Suspended = true; - Debug.WriteLine($"[AsyncProfiler:V1] SuspendAsyncContext dispatcher={activeDispatcher.Id} thread={Environment.CurrentManagedThreadId}"); + Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Suspended Id={activeDispatcher.ContextId}, tid={Environment.CurrentManagedThreadId}"); + return new AsyncTaskDispatcher(box, activeDispatcher); + } - // New dispatcher continues the suspended chain - var dispatcher = new AsyncTaskDispatcher(box, resumesFromSuspension: true); - return dispatcher; + AsyncTaskDispatcher newDispatcher = new AsyncTaskDispatcher(box); + Debug.WriteLine($"[AsyncTaskDispatcher.Create] New dispatcher Id={newDispatcher.ContextId}, tid={Environment.CurrentManagedThreadId}"); + if (AsyncInstrumentation.IsEnabled.CreateAsyncContext(AsyncInstrumentation.SyncActiveFlags())) + { + AsyncProfiler.CreateAsyncContext.Create((ulong)newDispatcher.ContextId); } - return box; + return newDispatcher; } - internal sealed override void ExecuteFromThreadPool(Thread threadPoolThread) - { - MoveNext(); - } + internal sealed override void ExecuteDirectly(Thread? threadPoolThread) => MoveNext(); - public void MoveNext() + public unsafe void MoveNext() { IAsyncStateMachineBox? inner = _inner; if (inner is null) + { return; + } + + Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Thread={Environment.CurrentManagedThreadId}, Id={ContextId}, inner={inner.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); - unsafe + AsyncTaskDispatcherInfo dispatcherInfo; + ref AsyncTaskDispatcherInfo* refCurrent = ref AsyncTaskDispatcherInfo.t_current; + AsyncTaskDispatcherInfo* previous = refCurrent; + refCurrent = &dispatcherInfo; + dispatcherInfo.Next = previous; + + dispatcherInfo.InnerBox = inner; + dispatcherInfo.Dispatcher = this; + dispatcherInfo.Suspended = false; + + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + AsyncProfiler.InitInfo(ref dispatcherInfo.AsyncProfilerInfo); + + if (AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags)) { - AsyncTaskDispatcherInfo dispatcherInfo; - ref AsyncTaskDispatcherInfo* refCurrent = ref AsyncTaskDispatcherInfo.t_current; - AsyncTaskDispatcherInfo* previous = refCurrent; - refCurrent = &dispatcherInfo; - dispatcherInfo.Next = previous; - dispatcherInfo.InnerBox = inner; - dispatcherInfo.Dispatcher = this; - dispatcherInfo.Suspended = false; - AsyncProfiler.InitInfo(ref dispatcherInfo.AsyncProfilerInfo); - - if (_resumesFromSuspension) - { - Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncContext dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); - } - else - { - Debug.WriteLine($"[AsyncProfiler:V1] AsyncTaskDispatcher.MoveNext PUSH dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); - } + Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Resuming Id={ContextId}, tid={Environment.CurrentManagedThreadId}"); + AsyncProfiler.ResumeAsyncContext.Resume(ref dispatcherInfo); + } - try - { - inner.MoveNext(); - } - finally + try + { + inner.MoveNext(); + } + finally + { + if (!dispatcherInfo.Suspended && AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) { - if (dispatcherInfo.PendingUnwind) - { - dispatcherInfo.PendingUnwind = false; - Debug.WriteLine($"[AsyncProfiler:V1] UnwindAsyncException (escaped) dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); - } - - if (!dispatcherInfo.Suspended) - { - Debug.WriteLine($"[AsyncProfiler:V1] CompleteAsyncContext dispatcher={Id} innerBox={((Task)inner).Id} thread={Environment.CurrentManagedThreadId}"); - } - // If Suspended, the Suspend event was already emitted inline by Create - - refCurrent = dispatcherInfo.Next; + Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Completed Id={ContextId}, tid={Environment.CurrentManagedThreadId}"); + AsyncProfiler.CompleteAsyncContext.Complete(ref dispatcherInfo.AsyncProfilerInfo); } + + // If Suspended, the Suspend event was already emitted inline by Create. } + + refCurrent = dispatcherInfo.Next; } public Action MoveNextAction => _moveNextAction ??= MoveNext; 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 c64104c4eea56d..89031bc55fc9bd 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 @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -353,28 +354,18 @@ private void MoveNext(Thread? threadPoolThread) { Debug.Assert(!IsCompleted); - bool asyncProfilerActive = AsyncTaskDispatcherInfo.IsActive; - string? methodName = null; - - if (asyncProfilerActive) + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - methodName = typeof(TStateMachine).Name; - - if (AsyncTaskDispatcherInfo.ConsumePendingUnwind()) - { - Debug.WriteLine($"[AsyncProfiler:V1] UnwindAsyncException box={Id} method={methodName} thread={Environment.CurrentManagedThreadId}"); - } - - // If the frame was suspended (a sub-chain was spawned) but we're now resuming - // a method on the same frame, the chain has resumed from suspension. - if (AsyncTaskDispatcherInfo.ConsumeSuspended()) + if (AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) { - Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncContext (inline) box={Id} method={methodName} thread={Environment.CurrentManagedThreadId}"); + Debug.WriteLine($"[AsyncTaskMethodBuilder.MoveNext] method={GetType().Name}, tid={Environment.CurrentManagedThreadId}"); + AsyncTaskDispatcherInfo.ResumeAsyncMethod(); } - - Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncMethod box={Id} method={methodName} thread={Environment.CurrentManagedThreadId}"); } + Debug.Assert(!AsyncTaskDispatcherInfo.IsSuspended); + bool loggingOn = TplEventSource.Log.IsEnabled(); if (loggingOn) { @@ -403,10 +394,6 @@ private void MoveNext(Thread? threadPoolThread) { ClearStateUponCompletion(); } - else if (asyncProfilerActive) - { - Debug.WriteLine($"[AsyncProfiler:V1] YieldAsyncMethod box={Id} method={methodName}"); - } if (loggingOn) { @@ -512,21 +499,14 @@ internal static void SetExistingTaskResult(Task task, TResult? result) { Debug.Assert(task != null, "Expected non-null task"); - if (AsyncTaskDispatcherInfo.IsActive) + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - if (AsyncTaskDispatcherInfo.ConsumePendingUnwind()) - { - Debug.WriteLine($"[AsyncProfiler:V1] UnwindAsyncException (caught) box={task.Id} thread={Environment.CurrentManagedThreadId}"); - } - - // If the frame was suspended (a sub-chain was spawned via GetOrCreate) but we're - // now completing a method, the chain has resumed — emit Resume and clear the flag. - if (AsyncTaskDispatcherInfo.ConsumeSuspended()) + if (AsyncInstrumentation.IsEnabled.CompleteAsyncMethod(flags)) { - Debug.WriteLine($"[AsyncProfiler:V1] ResumeAsyncContext (inline) thread={Environment.CurrentManagedThreadId}"); + Debug.WriteLine($"[AsyncTaskMethodBuilder.SetExistingTaskResult] method={task.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); + AsyncTaskDispatcherInfo.CompleteAsyncMethod(); } - - Debug.WriteLine($"[AsyncProfiler:V1] CompleteAsyncMethod box={task.Id} thread={Environment.CurrentManagedThreadId}"); } if (TplEventSource.Log.IsEnabled()) @@ -559,7 +539,12 @@ internal static void SetException(Exception exception, ref Task? taskFi // Get the task, forcing initialization if it hasn't already been initialized. Task task = (taskField ??= new Task()); - AsyncTaskDispatcherInfo.MarkPendingUnwind(); + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags) && AsyncInstrumentation.IsEnabled.UnwindAsyncException(flags)) + { + Debug.WriteLine($"[AsyncTaskMethodBuilder.SetException] method={taskField.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); + AsyncTaskDispatcherInfo.UnwindAsyncFrame(); + } // If the exception represents cancellation, cancel the task. Otherwise, fault the task. bool successfullySet = exception is OperationCanceledException oce ? diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs index bafdbc392d6895..e65205299401a5 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs @@ -102,9 +102,11 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { -#if !MONO - box = AsyncTaskDispatcher.Create(box); -#endif + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + { + box = AsyncTaskDispatcher.Create(box); + } + Unsafe.As(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, _value._continueOnCapturedContext ? ValueTaskSourceOnCompletedFlags.UseSchedulingContext : ValueTaskSourceOnCompletedFlags.None); } @@ -210,9 +212,11 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { -#if !MONO - box = AsyncTaskDispatcher.Create(box); -#endif + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + { + box = AsyncTaskDispatcher.Create(box); + } + Unsafe.As>(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, _value._continueOnCapturedContext ? ValueTaskSourceOnCompletedFlags.UseSchedulingContext : ValueTaskSourceOnCompletedFlags.None); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs index 6c975435a12a3a..09e26decb6de49 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs @@ -194,12 +194,35 @@ internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox { Debug.Assert(stateMachineBox != null); + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) + { + if (continueOnCapturedContext) + { + if (SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext)) + { + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } + else if (TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default) + { + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } + } + + // If we're awaiting a non-async task (I/O, Timer, TCS, etc.), + // this box is the root of a V1 async chain. Wrap or reuse a dispatch box. + // For mid-chain boxes (awaiting another async method), the continuation will + // be inlined via RunContinuations, so no dispatcher wrapping is needed here. + if (task is not IAsyncStateMachineBox) + { + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } + } + // If TaskWait* ETW events are enabled, trace a beginning event for this await // and set up an ending event to be traced when the asynchronous await completes. if (TplEventSource.Log.IsEnabled() || Task.s_asyncDebuggingEnabled) { - // TODO: AsyncProfiler — the ETW path bypasses UnsafeSetContinuationForAwait and - // uses the Action-based SetContinuationForAwait. Dispatcher wrapping is not applied here. task.SetContinuationForAwait(OutputWaitEtwEvents(task, stateMachineBox.MoveNextAction), continueOnCapturedContext, flowExecutionContext: false); } else diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs index 80e31fbad451b6..32317a435b0cdc 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs @@ -94,13 +94,15 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { -#if !MONO // TODO-AsyncProfiler: Optimize by using a static delegate + original box as state instead of // allocating a full AsyncTaskDispatcher (Task-derived). The IValueTaskSource.OnCompleted API // already takes Action + state separately, so we can use a lightweight static callback // that performs PUSH/MoveNext/POP inline without the Task overhead. - box = AsyncTaskDispatcher.Create(box); -#endif + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + { + box = AsyncTaskDispatcher.Create(box); + } + Unsafe.As(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, ValueTaskSourceOnCompletedFlags.UseSchedulingContext); } else @@ -183,9 +185,11 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { -#if !MONO - box = AsyncTaskDispatcher.Create(box); -#endif + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + { + box = AsyncTaskDispatcher.Create(box); + } + Unsafe.As>(obj).OnCompleted(ThreadPool.s_invokeAsyncStateMachineBox, box, _value._token, ValueTaskSourceOnCompletedFlags.UseSchedulingContext); } else diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs index 54041006f644de..e26d290139a32b 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs @@ -116,13 +116,10 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b { Debug.Assert(box != null); -#if !MONO - // Yield is always a root — wrap or reuse a dispatch box when the async profiler is active. - if (true /* TODO: restore: AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.ActiveFlags) */) + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } -#endif // If tracing is enabled, delegate the Action-based implementation. if (TplEventSource.Log.IsEnabled()) diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs index 83bd1936f25628..5381d2d4418d0c 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs @@ -2721,41 +2721,17 @@ internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBo { if (SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext)) { -#if !MONO - // Continuation will be queued to sync context — wrap in dispatcher to - // maintain the async chain context across the scheduling boundary. - Debug.WriteLine($"[AsyncProfiler:V1] USING none default Sync context!"); - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); -#endif tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false); goto HaveTaskContinuation; } if (TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default) { -#if !MONO - // Continuation will be queued to custom scheduler — wrap in dispatcher to - // maintain the async chain context across the scheduling boundary. - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); - Debug.WriteLine($"[AsyncProfiler:V1] USING none default task scheduler!"); -#endif tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false); goto HaveTaskContinuation; } } -#if !MONO - // If we're awaiting a non-async task (I/O, Timer, TCS, etc.), - // this box is the root of a V1 async chain. Wrap or reuse a dispatch box. - // For mid-chain boxes (awaiting another async method), the continuation will - // be inlined via RunContinuations, so no dispatcher wrapping is needed here. - if (this is not IAsyncStateMachineBox) - { - Debug.WriteLine($"[AsyncProfiler:V1] WAITING on non-async task!"); - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); - } -#endif - // Otherwise, add the state machine box directly as the continuation. // If we're unable to because the task has already completed, queue it. if (!AddTaskContinuation(stateMachineBox, addBeforeOthers: false)) diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs index fd484a9c6cc936..86b543ab9fc3e9 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs @@ -768,11 +768,10 @@ internal static void RunOrScheduleAction(IAsyncStateMachineBox box, bool allowIn // If we're not allowed to run here, schedule the action if (!allowInlining || !IsValidLocationForInlining) { -#if !MONO - // If an async profiler dispatcher is active, reuse it so the queued - // continuation runs inside the same dispatcher context (preserving the chain). - box = AsyncTaskDispatcher.ReuseOrPassthrough(box); -#endif + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + { + box = AsyncTaskDispatcher.Create(box); + } // If logging is disabled, we can simply queue the box itself as a custom work // item, and its work item execution will just invoke its MoveNext. However, if 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 65e376c0282e48..613df9af679ad6 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 @@ -33,7 +33,7 @@ public enum AsyncEventID : byte } [ActiveIssue("https://github.com/dotnet/runtime/issues/127951", TestPlatforms.Android | TestPlatforms.iOS | TestPlatforms.tvOS | TestPlatforms.MacCatalyst)] - public class AsyncProfilerTests + public partial class AsyncProfilerTests { // The test scenarios drive async work via Task.Run(...).GetAwaiter().GetResult() (see // RunScenarioAndFlush / RunScenario), which requires synchronous blocking waits. On @@ -2376,6 +2376,7 @@ public static int OutputEventBuffer(ReadOnlySpan buffer) AsyncEventID.ResetAsyncThreadContext => OutputResetAsyncThreadContextEvent(), AsyncEventID.ResetAsyncContinuationWrapperIndex => OutputResetAsyncContinuationWrapperIndexEvent(), AsyncEventID.AsyncProfilerMetadata => OutputAsyncProfilerMetadataEvent(buffer.Slice(index)), + AsyncEventID.AsyncProfilerSyncClock => OutputAsyncProfilerSyncClockEvent(buffer.Slice(index)), _ => throw new InvalidOperationException($"Unknown eventId {eventId}."), }; } @@ -2499,6 +2500,21 @@ private static int OutputAsyncProfilerMetadataEvent(ReadOnlySpan buffer) return index; } + private static int OutputAsyncProfilerSyncClockEvent(ReadOnlySpan buffer) + { + int index = 0; + Console.WriteLine("--- AsyncProfilerSyncClock ---"); + + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcSync); + Console.WriteLine($" QPCSync: {qpcSync}"); + + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong utcSync); + Console.WriteLine($" UTCSync: {utcSync}"); + + Console.WriteLine("----------------------------"); + return index; + } + private static int OutputAsyncCallstackEvent(string eventName, ReadOnlySpan buffer) { ulong id; 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 new file mode 100644 index 00000000000000..9de838b41e88c1 --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV1Tests.cs @@ -0,0 +1,390 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace System.Threading.Tasks.Tests +{ + /// + /// Tests for V1 (Task-based AsyncStateMachineBox) async profiler event emission. + /// All scenario methods use [RuntimeAsyncMethodGeneration(false)] to ensure they + /// exercise the legacy Task-based async path even if the default changes in the future. + /// Tests use sync CollectEvents with RunScenarioAndFlush to isolate the V1 chain + /// on a threadpool thread, ensuring dispatcher finally blocks complete before flush. + /// Requires threading support. + /// + public partial class AsyncProfilerTests + { + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_SingleYield() + { + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_DeepChain() + { + await TaskAsync_Level1(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_Level1() + { + await TaskAsync_Level2(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_Level2() + { + await TaskAsync_Level3(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_Level3() + { + await Task.Delay(100); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_MultiYield() + { + await Task.Yield(); + await Task.Yield(); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_ExceptionHandled() + { + try + { + await TaskAsync_InnerThrows(); + } + catch (InvalidOperationException) + { + } + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_InnerThrows() + { + await Task.Delay(100); + throw new InvalidOperationException("v1 inner"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_UnhandledExceptionOuter() + { + await TaskAsync_UnhandledExceptionInner(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_UnhandledExceptionInner() + { + await Task.Delay(100); + throw new InvalidOperationException("v1 unhandled inner"); + } + + private static ulong AssertCompleteContextChain(ParsedEventStream stream, params AsyncEventID[] expectedSequence) + { + var byTask = stream.ByTaskId(); + var candidates = new List(); + + foreach (var (taskId, events) in byTask) + { + var contextEvents = events + .Where(e => e.EventId is AsyncEventID.CreateAsyncContext + or AsyncEventID.ResumeAsyncContext + or AsyncEventID.SuspendAsyncContext + or AsyncEventID.CompleteAsyncContext + or AsyncEventID.UnwindAsyncException) + .Select(e => e.EventId) + .ToList(); + + candidates.Add($" TaskId={taskId}: [{string.Join(", ", contextEvents)}]"); + + if (contextEvents.Count != expectedSequence.Length) + continue; + + bool match = true; + for (int i = 0; i < expectedSequence.Length; i++) + { + if (contextEvents[i] != expectedSequence[i]) + { + match = false; + break; + } + } + + if (match) + return taskId; + } + + string expected = string.Join(", ", expectedSequence); + string found = string.Join(Environment.NewLine, candidates); + Assert.Fail($"No context found with expected chain [{expected}].\nContexts found:\n{found}"); + return 0; // unreachable + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_EventsEmitted() + { + var events = CollectEvents(CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_SingleYield()); + }); + + // DumpAllEvents(events); + + Assert.True(events.Events.Count > 0, "Expected at least one AsyncEvents event to be emitted"); + Assert.Contains(events.Events, e => e.EventId == AsyncEventsId); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CreateAsyncContextEmittedOnFirstAwait() + { + var events = CollectEvents(CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_SingleYield()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var creates = stream.OfType(AsyncEventID.CreateAsyncContext).ToList(); + Assert.True(creates.Count >= 1, $"Expected at least 1 CreateAsyncContext, got {creates.Count}"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_EventSequenceOrder() + { + var events = CollectEvents(CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_SingleYield()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + AssertCompleteContextChain(stream, + AsyncEventID.CreateAsyncContext, + AsyncEventID.ResumeAsyncContext, + AsyncEventID.CompleteAsyncContext); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_SuspendResumeCompleteEvents() + { + var events = CollectEvents(CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_MultiYield()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + ulong taskId = AssertCompleteContextChain(stream, + AsyncEventID.CreateAsyncContext, + AsyncEventID.ResumeAsyncContext, + AsyncEventID.SuspendAsyncContext, + AsyncEventID.ResumeAsyncContext, + AsyncEventID.SuspendAsyncContext, + AsyncEventID.ResumeAsyncContext, + AsyncEventID.CompleteAsyncContext); + + var taskEvents = stream.ForTask(taskId); + foreach (var evt in taskEvents) + { + if (evt.EventId is AsyncEventID.ResumeAsyncContext or AsyncEventID.CreateAsyncContext) + Assert.Equal(taskId, evt.TaskId); + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_ResumeCompleteMethodEvents() + { + var events = CollectEvents(MethodKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_SingleYield()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var ids = stream.EventIds; + + Assert.Contains(AsyncEventID.ResumeAsyncMethod, ids); + Assert.Contains(AsyncEventID.CompleteAsyncMethod, ids); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_HandledException_EmitsUnwindAndComplete() + { + var events = CollectEvents(CoreKeywords | UnwindAsyncExceptionKeyword, () => + { + RunScenarioAndFlush(() => TaskAsync_ExceptionHandled()); + }); + + DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + AssertCompleteContextChain(stream, + AsyncEventID.CreateAsyncContext, + AsyncEventID.ResumeAsyncContext, + AsyncEventID.UnwindAsyncException, + AsyncEventID.CompleteAsyncContext); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_UnhandledException_EmitsUnwindAndComplete() + { + var events = CollectEvents(CoreKeywords | UnwindAsyncExceptionKeyword, () => + { + try + { + RunScenario(() => TaskAsync_UnhandledExceptionOuter()); + } + catch (InvalidOperationException) + { + } + SendFlushCommand(); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + AssertCompleteContextChain(stream, + AsyncEventID.CreateAsyncContext, + AsyncEventID.ResumeAsyncContext, + AsyncEventID.UnwindAsyncException, + AsyncEventID.UnwindAsyncException, + AsyncEventID.CompleteAsyncContext); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_MethodEventCountMatchesChainDepth() + { + var events = CollectEvents(MethodKeywords | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_DeepChain()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // DeepChain → Level1 → Level2 → Level3 = 4 methods + // Level3 uses Task.Delay to ensure full chain is built before resume. + // On resume: Level3, Level2, Level1, DeepChain each resume and complete = 4 pairs. + var methodEvents = stream.All + .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod) + .Select(e => e.EventId) + .ToList(); + + int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); + int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); + + Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events, got {resumeCount}"); + Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events, got {completeCount}"); + + // Verify interleaved ordering: each Resume is followed by its matching Complete + // Check the last 8 events (4 Resume/Complete pairs from the inner chain) + var tail = methodEvents.Skip(methodEvents.Count - 8).ToList(); + for (int i = 0; i < tail.Count; i += 2) + { + Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[i]); + Assert.Equal(AsyncEventID.CompleteAsyncMethod, tail[i + 1]); + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_HandledException_MethodEventsWithUnwind() + { + var events = CollectEvents(MethodKeywords | CoreKeywords | UnwindAsyncExceptionKeyword, () => + { + RunScenarioAndFlush(() => TaskAsync_ExceptionHandled()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // ExceptionHandled → InnerThrows (2 methods) + // InnerThrows resumes, throws → Unwind + // ExceptionHandled resumes (catches), completes + // Expected method events: Resume, Unwind, Resume, Complete + var methodEvents = stream.All + .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod + or AsyncEventID.CompleteAsyncMethod + or AsyncEventID.UnwindAsyncException) + .Select(e => e.EventId) + .ToList(); + + var tail = methodEvents.Skip(methodEvents.Count - 4).ToList(); + Assert.Equal(4, tail.Count); + Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[0]); + Assert.Equal(AsyncEventID.UnwindAsyncException, tail[1]); + Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[2]); + Assert.Equal(AsyncEventID.CompleteAsyncMethod, tail[3]); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_UnhandledException_MethodEventsWithUnwind() + { + var events = CollectEvents(MethodKeywords | CoreKeywords | UnwindAsyncExceptionKeyword, () => + { + try + { + RunScenario(() => TaskAsync_UnhandledExceptionOuter()); + } + catch (InvalidOperationException) + { + } + SendFlushCommand(); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // UnhandledExceptionOuter → UnhandledExceptionInner (2 methods, neither catches) + // Inner resumes, throws → Unwind + // Outer resumes (continuation), propagates → Unwind + // No CompleteAsyncMethod for either — both unwind + // Expected method events: Resume, Unwind, Resume, Unwind + var methodEvents = stream.All + .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod + or AsyncEventID.CompleteAsyncMethod + or AsyncEventID.UnwindAsyncException) + .Select(e => e.EventId) + .ToList(); + + var tail = methodEvents.Skip(methodEvents.Count - 4).ToList(); + Assert.Equal(4, tail.Count); + Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[0]); + Assert.Equal(AsyncEventID.UnwindAsyncException, tail[1]); + Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[2]); + Assert.Equal(AsyncEventID.UnwindAsyncException, tail[3]); + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj index 84894a3857d957..d7f3c76fcd2b44 100644 --- a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj @@ -61,6 +61,7 @@ + From 3400b20380e23d0d1bb5b956a4b99f41e9098efa Mon Sep 17 00:00:00 2001 From: lateralusX Date: Thu, 28 May 2026 15:56:50 +0200 Subject: [PATCH 03/19] Implementing asyncv1 callstack support. --- .../CompilerServices/AsyncProfiler.CoreCLR.cs | 166 ++------ .../AsyncMethodBuilderCore.cs | 14 + .../Runtime/CompilerServices/AsyncProfiler.cs | 386 +++++++++++++++++- .../CompilerServices/AsyncTaskDispatcher.cs | 62 +-- .../AsyncTaskMethodBuilderT.cs | 100 ++++- .../CompilerServices/IAsyncStateMachineBox.cs | 3 + .../PoolingAsyncValueTaskMethodBuilderT.cs | 10 +- .../src/System/Threading/Tasks/Task.cs | 3 + .../AsyncProfilerTests.cs | 204 ++++++--- 9 files changed, 716 insertions(+), 232 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs index ce2c1251f9da81..95c705f22cd160 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs @@ -93,15 +93,16 @@ public static void Suspend(ref AsyncDispatcherInfo info, Continuation nextContin if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) { long currentTimestamp = Stopwatch.GetTimestamp(); - if (IsEnabled.SuspendAsyncContextEvent(activeEventKeywords)) - { - EmitEvent(context, currentTimestamp); - } if (IsEnabled.SuspendAsyncCallstackEvent(activeEventKeywords)) { AsyncCallstack.EmitEvent(context, currentTimestamp, AsyncEventID.SuspendAsyncCallstack, GetId(ref info), nextContinuation); } + + if (IsEnabled.SuspendAsyncContextEvent(activeEventKeywords)) + { + EmitEvent(context, currentTimestamp); + } } AsyncThreadContext.Release(context); @@ -433,23 +434,48 @@ private static unsafe void ResumeRuntimeAsyncCallstacks(AsyncDispatcherInfo* inf private static partial class AsyncCallstack { - private const int MaxAsyncMethodFrameSize = Serializer.MaxCompressedUInt64Size + Serializer.MaxCompressedUInt32Size; + private const int MaxRuntimeAsyncMethodFrameSize = Serializer.MaxCompressedUInt64Size + Serializer.MaxCompressedUInt32Size; - public ref struct CaptureRuntimeAsyncCallstackState + private ref struct CaptureRuntimeAsyncCallstackState : ICaptureAsyncCallstack { public Continuation? Continuation; public ulong LastNativeIP; public byte Count; + + public bool Capture(byte[] buffer, ref int index, out byte count) + { + bool result = CaptureRuntimeAsyncCallstack(buffer, ref index, ref this); + count = Count; + return result; + } + + public static AsyncCallstackType CallstackType => AsyncCallstackType.Runtime; + public static int MaxAsyncMethodFrameSize => MaxRuntimeAsyncMethodFrameSize; + } + + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, ulong id, Continuation? asyncCallstack) + { + EmitEvent(context, currentTimestamp, AsyncEventID.ResumeAsyncCallstack, id, asyncCallstack); + } + + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, AsyncEventID eventID, ulong id, Continuation? asyncCallstack) + { + if (asyncCallstack != null) + { + CaptureRuntimeAsyncCallstackState state = default; + state.Continuation = asyncCallstack; + EmitAsyncCallstack(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, state); + } } - public static bool CaptureRuntimeAsyncCallstack(byte[] buffer, ref int index, ref CaptureRuntimeAsyncCallstackState state) + private static bool CaptureRuntimeAsyncCallstack(byte[] buffer, ref int index, ref CaptureRuntimeAsyncCallstackState state) { if (index > buffer.Length || state.Continuation == null) { return false; } - byte maxAsyncCallstackFrames = (byte)Math.Min(byte.MaxValue, (buffer.Length - index) / MaxAsyncMethodFrameSize); + byte maxAsyncCallstackFrames = (byte)Math.Min(byte.MaxValue, (buffer.Length - index) / MaxRuntimeAsyncMethodFrameSize); if (maxAsyncCallstackFrames == 0) { return false; @@ -505,130 +531,6 @@ public static bool CaptureRuntimeAsyncCallstack(byte[] buffer, ref int index, re return state.Continuation == null || state.Count == byte.MaxValue; } - - public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, ulong id, Continuation? asyncCallstack) - { - EmitEvent(context, currentTimestamp, AsyncEventID.ResumeAsyncCallstack, id, AsyncCallstackType.Runtime, asyncCallstack); - } - - public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, AsyncEventID eventID, ulong id, Continuation? asyncCallstack) - { - EmitEvent(context, currentTimestamp, eventID, id, AsyncCallstackType.Runtime, asyncCallstack); - } - - public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, AsyncEventID eventID, ulong id, AsyncCallstackType type, Continuation? asyncCallstack) - { - EmitEvent(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, type, asyncCallstack); - } - - public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, long delta, AsyncEventID eventID, ulong id, AsyncCallstackType type, Continuation? asyncCallstack) - { - if (asyncCallstack != null) - { - ref EventBuffer eventBuffer = ref context.EventBuffer; - - // Max callstack data that can fit in the buffer after flush. - int maxCallstackBytes = Math.Min( - byte.MaxValue * MaxAsyncMethodFrameSize, - eventBuffer.Data.Length); - - CaptureRuntimeAsyncCallstackState state = default; - state.Continuation = asyncCallstack; - - // Static callstack payload: type (1) + callstackId (1) + frameCount (1) + id (max 10 bytes compressed). - const int MaxStaticEventPayloadSize = sizeof(byte) + sizeof(byte) + sizeof(byte) + Serializer.MaxCompressedUInt64Size; - - if (Serializer.AsyncEventHeader(context, ref eventBuffer, currentTimestamp, delta, eventID, MaxStaticEventPayloadSize, out Serializer.AsyncEventHeaderRollbackData rollbackData)) - { - int frameCountOffset = CallstackHeader(ref eventBuffer, id, type, 0); - - byte[] buffer = eventBuffer.Data; - int startIndex = eventBuffer.Index; - int currentIndex = startIndex; - - if (!CaptureRuntimeAsyncCallstack(buffer, ref currentIndex, ref state)) - { - byte[]? rentedArray = RentArray(maxCallstackBytes); - if (rentedArray != null) - { - int length = currentIndex - startIndex; - int index = length; - - Buffer.BlockCopy(buffer, startIndex, rentedArray, 0, length); - CaptureRuntimeAsyncCallstack(rentedArray, ref index, ref state); - - // Rollback async event header before flushing. - Serializer.RollbackAsyncEventHeader(context, in rollbackData); - context.Flush(); - - // Write the callstack again. - if (Serializer.AsyncEventHeader(context, ref eventBuffer, context.LastEventTimestamp, 0, eventID, MaxStaticEventPayloadSize + index)) - { - CallstackHeader(ref eventBuffer, id, type, state.Count); - CallstackData(ref eventBuffer, rentedArray, index); - } - - ArrayPool.Shared.Return(rentedArray); - } - else - { - // Rollback async event header since we can't write the callstack. - Serializer.RollbackAsyncEventHeader(context, in rollbackData); - } - } - else - { - // Patch frame count in the event buffer using the offset from CallstackHeader. - eventBuffer.Data[frameCountOffset] = state.Count; - eventBuffer.Index += currentIndex - startIndex; - } - } - } - } - - private static int CallstackHeader(ref EventBuffer eventBuffer, ulong id, AsyncCallstackType type, byte callstackFrameCount) - { - // Callstack header layout: type (1 byte) + callstackId (1 byte, reserved for future use) + frameCount (1 byte) + id (max 10 bytes compressed). - const int MaxCallstackHeaderSize = sizeof(byte) + sizeof(byte) + sizeof(byte) + Serializer.MaxCompressedUInt64Size; - - ref int index = ref eventBuffer.Index; - - Span callstackHeaderSpan = eventBuffer.Data.AsSpan(index, MaxCallstackHeaderSize); - int spanIndex = 0; - - callstackHeaderSpan[spanIndex++] = (byte)type; - callstackHeaderSpan[spanIndex++] = 0; // Reserved callstack ID for future callstack interning. - - int frameCountOffset = index + spanIndex; - callstackHeaderSpan[spanIndex++] = callstackFrameCount; - - spanIndex += Serializer.WriteCompressedUInt64(callstackHeaderSpan.Slice(spanIndex), id); - eventBuffer.Index += spanIndex; - - return frameCountOffset; - } - - private static void CallstackData(ref EventBuffer eventBuffer, byte[] callstackData, int callstackDataByteCount) - { - ref int index = ref eventBuffer.Index; - Buffer.BlockCopy(callstackData, 0, eventBuffer.Data, index, callstackDataByteCount); - index += callstackDataByteCount; - } - - private static byte[]? RentArray(int minimumLength) - { - byte[]? rentedArray = null; - try - { - rentedArray = ArrayPool.Shared.Rent(minimumLength); - } - catch - { - //AsyncProfiler can't throw, return null if renting fails. - } - - return rentedArray; - } } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs index 7cb85c290c7555..3c066512eac256 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs @@ -127,6 +127,20 @@ internal static Action TryGetStateMachineForDebugger(Action action) // debugger wrapper._innerTask : // A wrapped continuation, created by an awaiter continuation.Target as Task; // The continuation targets a task directly, such as with AsyncStateMachineBox + internal static IAsyncStateMachineBox? TryGetStateMachineBox(Action continuation) + { + object? target = continuation.Target; + if (target is IAsyncStateMachineBox box) + { + return box; + } + if (target is ContinuationWrapper cw) + { + return TryGetStateMachineBox(cw._continuation); + } + return null; + } + /// /// Logically we pass just an Action (delegate) to a task for its action to 'ContinueWith' when it completes. /// However debuggers and profilers need more information about what that action is. (In particular what diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 8b85ec9a4b21b3..13601022425456 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -1,12 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Tracing; using System.Runtime.InteropServices; using System.Threading; +using System.Threading.Tasks; +using static System.Runtime.CompilerServices.AsyncInstrumentation; using static System.Runtime.CompilerServices.AsyncProfiler; using static System.Runtime.CompilerServices.AsyncProfilerEventSource; using Serializer = System.Runtime.CompilerServices.AsyncProfiler.EventBuffer.Serializer; @@ -38,7 +41,8 @@ internal enum AsyncEventID : byte ResetAsyncThreadContext = 11, ResetAsyncContinuationWrapperIndex = 12, AsyncProfilerMetadata = 13, - AsyncProfilerSyncClock = 14 + AsyncProfilerSyncClock = 14, + AppendAsyncCallstack = 15 } internal ref struct Info @@ -726,6 +730,8 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class ResumeAsyncContext { + public static ulong GetId(AsyncTaskDispatcher dispatcher) => dispatcher.ContextId; + public static ulong GetId(ref AsyncTaskDispatcherInfo info) { if (info.Dispatcher != null) @@ -744,14 +750,41 @@ public static void Resume(ref AsyncTaskDispatcherInfo info) // Temporarily bypass so Resume always fires. SyncPoint.Check(context); - if (IsEnabled.ResumeAsyncContextEvent(context.ActiveEventKeywords)) + EventKeywords activeEventKeywords = context.ActiveEventKeywords; + if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) { - EmitEvent(context, Stopwatch.GetTimestamp(), GetId(ref info)); + long currentTimestamp = Stopwatch.GetTimestamp(); + ulong id = GetId(ref info); + if (IsEnabled.ResumeAsyncContextEvent(activeEventKeywords)) + { + EmitEvent(context, currentTimestamp, id); + } + + if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords) && info.Dispatcher != null) + { + AsyncCallstack.EmitEvent(info.Dispatcher, context, currentTimestamp, id); + } } AsyncThreadContext.Release(context); } + public static void Append(AsyncTaskDispatcher dispatcher, AsyncThreadContext context, long currentTimestamp) + { + if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords)) + { + Task? lastTask = dispatcher.LastContinuation; + if (lastTask != null) + { + object? newContinuation = lastTask.DiagnosticContinuationObject; + if (newContinuation != null) + { + AsyncCallstack.EmitEvent(dispatcher, context, newContinuation, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); + } + } + } + } + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, ulong id) { const int MaxEventPayloadSize = Serializer.MaxCompressedUInt64Size; @@ -766,15 +799,25 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class SuspendAsyncContext { - public static void Suspend(ref Info info) + public static void Suspend(AsyncTaskDispatcher dispatcher, ref Info info) { AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); SyncPoint.Check(context); - if (IsEnabled.SuspendAsyncContextEvent(context.ActiveEventKeywords)) + EventKeywords activeEventKeywords = context.ActiveEventKeywords; + if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) { - EmitEvent(context, Stopwatch.GetTimestamp()); + long currentTimestamp = Stopwatch.GetTimestamp(); + if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords)) + { + ResumeAsyncContext.Append(dispatcher, context, currentTimestamp); + } + + if (IsEnabled.SuspendAsyncContextEvent(activeEventKeywords)) + { + EmitEvent(context, currentTimestamp); + } } AsyncThreadContext.Release(context); @@ -788,6 +831,30 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp) internal static partial class CompleteAsyncContext { + public static void Complete(AsyncTaskDispatcher dispatcher, ref Info info) + { + AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); + + SyncPoint.Check(context); + + EventKeywords activeEventKeywords = context.ActiveEventKeywords; + if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) + { + long currentTimestamp = Stopwatch.GetTimestamp(); + if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords)) + { + ResumeAsyncContext.Append(dispatcher, context, currentTimestamp); + } + + if (IsEnabled.CompleteAsyncContextEvent(activeEventKeywords)) + { + EmitEvent(context, currentTimestamp); + } + } + + AsyncThreadContext.Release(context); + } + public static void Complete(ref Info info) { AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); @@ -867,6 +934,30 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class ResumeAsyncMethod { + public static void Resume(AsyncTaskDispatcher dispatcher, ref Info info) + { + AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); + + SyncPoint.Check(context); + + EventKeywords activeEventKeywords = context.ActiveEventKeywords; + if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) + { + long currentTimestamp = Stopwatch.GetTimestamp(); + if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords)) + { + ResumeAsyncContext.Append(dispatcher, context, currentTimestamp); + } + + if (IsEnabled.ResumeAsyncMethodEvent(activeEventKeywords)) + { + EmitEvent(context, currentTimestamp); + } + } + + AsyncThreadContext.Release(context); + } + public static void Resume(ref Info info) { AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); @@ -884,6 +975,11 @@ public static void EmitEvent(AsyncThreadContext context) { Serializer.AsyncEventHeader(context, ref context.EventBuffer, AsyncEventID.ResumeAsyncMethod, 0); } + + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp) + { + Serializer.AsyncEventHeader(context, ref context.EventBuffer, currentTimestamp, AsyncEventID.ResumeAsyncMethod, 0); + } } internal static partial class CompleteAsyncMethod @@ -1189,5 +1285,283 @@ public AsyncThreadContextHolder(AsyncThreadContext context, Thread ownerThread) private static List s_cache = new List(); } + + private static partial class AsyncCallstack + { + private const int MaxTaskAsyncMethodFrameSize = Serializer.MaxCompressedUInt64Size + Serializer.MaxCompressedUInt32Size; + + private interface ICaptureAsyncCallstack + { + bool Capture(byte[] buffer, ref int index, out byte count); + + static abstract AsyncCallstackType CallstackType { get; } + static abstract int MaxAsyncMethodFrameSize { get; } + } + + private ref struct CaptureTaskAsyncCallstackState : ICaptureAsyncCallstack + { + public object? Continuation; + public object? LastContinuation; + public ulong LastMethodId; + public byte Count; + + public bool Capture(byte[] buffer, ref int index, out byte count) + { + bool result = CaptureTaskAsyncCallstack(buffer, ref index, ref this); + count = Count; + return result; + } + + public static AsyncCallstackType CallstackType => AsyncCallstackType.Compiler; + public static int MaxAsyncMethodFrameSize => MaxTaskAsyncMethodFrameSize; + } + + public static void EmitEvent(AsyncTaskDispatcher dispatcher, AsyncThreadContext context, long currentTimestamp, ulong id) + { + EmitEvent(dispatcher, context, dispatcher.InnerBox, currentTimestamp, AsyncEventID.ResumeAsyncCallstack, id); + } + + public static void EmitEvent(AsyncTaskDispatcher dispatcher, AsyncThreadContext context, object? continuation, long currentTimestamp, AsyncEventID eventID, ulong id) + { + if (continuation != null) + { + IAsyncStateMachineBox? box = ResolveAsyncStateMachineBox(continuation); + if (box != null) + { + CaptureTaskAsyncCallstackState state = default; + state.Continuation = box; + + EmitAsyncCallstack(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, state); + + box = ResolveAsyncStateMachineBox(state.LastContinuation); + if (box != null) + { + Debug.Assert(box is Task); + dispatcher.LastContinuation = Unsafe.As(box); + } + else + { + dispatcher.LastContinuation = null; + } + } + else + { + dispatcher.LastContinuation = null; + } + } + } + + private static bool CaptureTaskAsyncCallstack(byte[] buffer, ref int index, ref CaptureTaskAsyncCallstackState state) + { + if (index > buffer.Length || state.Continuation == null) + { + return false; + } + + byte maxAsyncCallstackFrames = (byte)Math.Min(byte.MaxValue, (buffer.Length - index) / MaxTaskAsyncMethodFrameSize); + if (maxAsyncCallstackFrames == 0) + { + return false; + } + + Span callstackSpan = buffer.AsSpan(index); + int callstackSpanIndex = 0; + ulong previousMethodId = state.LastMethodId; + + state.LastContinuation = state.Continuation; + + if (!GetFrameDiagnosticsData(state.Continuation, out ulong currentMethodId, out int methodState, out object? nextContinuation)) + { + return false; + } + + state.Continuation = nextContinuation; + + if (state.Count == 0) + { + callstackSpanIndex += Serializer.WriteCompressedUInt64(callstackSpan.Slice(callstackSpanIndex, Serializer.MaxCompressedUInt64Size), currentMethodId); + } + else + { + callstackSpanIndex += Serializer.WriteCompressedInt64(callstackSpan.Slice(callstackSpanIndex, Serializer.MaxCompressedInt64Size), (long)(currentMethodId - previousMethodId)); + } + + callstackSpanIndex += Serializer.WriteCompressedInt32(callstackSpan.Slice(callstackSpanIndex, Serializer.MaxCompressedInt32Size), methodState); + state.Count++; + + while (state.Count < maxAsyncCallstackFrames && state.Continuation != null) + { + previousMethodId = currentMethodId; + state.LastContinuation = state.Continuation; + + if (!GetFrameDiagnosticsData(state.Continuation, out currentMethodId, out methodState, out nextContinuation)) + { + state.Continuation = nextContinuation; + continue; + } + + callstackSpanIndex += Serializer.WriteCompressedInt64(callstackSpan.Slice(callstackSpanIndex, Serializer.MaxCompressedInt64Size), (long)(currentMethodId - previousMethodId)); + callstackSpanIndex += Serializer.WriteCompressedInt32(callstackSpan.Slice(callstackSpanIndex, Serializer.MaxCompressedInt32Size), methodState); + + state.Count++; + state.Continuation = nextContinuation; + } + + state.LastMethodId = currentMethodId; + index += callstackSpanIndex; + + return state.Continuation == null || state.Count == byte.MaxValue; + } + + private static bool GetFrameDiagnosticsData(object? continuationObject, out ulong methodId, out int state, out object? nextContinuation) + { + IAsyncStateMachineBox? box = ResolveAsyncStateMachineBox(continuationObject); + if (box != null) + { + box.GetDiagnosticData(out methodId, out state, out nextContinuation); + return true; + } + + methodId = 0; + state = -1; + nextContinuation = null; + + return false; + } + + private static IAsyncStateMachineBox? ResolveAsyncStateMachineBox(object? continuationObject) + { + if (continuationObject == null) + { + return null; + } + + if (continuationObject is IAsyncStateMachineBox box) + { + return box; + } + + if (continuationObject is Action action) + { + return AsyncMethodBuilderCore.TryGetStateMachineBox(action); + } + + if (continuationObject is List list) + { + for (int i = 0; i < list.Count; i++) + { + IAsyncStateMachineBox? resolved = ResolveAsyncStateMachineBox(list[i]); + if (resolved is not null) + { + return resolved; + } + } + } + + return null; + } + + private static void EmitAsyncCallstack(AsyncThreadContext context, long currentTimestamp, long delta, AsyncEventID eventID, ulong id, T captureCallstack) + where T : ICaptureAsyncCallstack, allows ref struct + { + ref EventBuffer eventBuffer = ref context.EventBuffer; + + // Max callstack data that can fit in the buffer after flush. + int maxCallstackBytes = Math.Min(byte.MaxValue * T.MaxAsyncMethodFrameSize, eventBuffer.Data.Length); + + // Static callstack payload: type (1) + callstackId (1) + frameCount (1) + id (max 10 bytes compressed). + const int MaxStaticEventPayloadSize = sizeof(byte) + sizeof(byte) + sizeof(byte) + Serializer.MaxCompressedUInt64Size; + + if (Serializer.AsyncEventHeader(context, ref eventBuffer, currentTimestamp, delta, eventID, MaxStaticEventPayloadSize, out Serializer.AsyncEventHeaderRollbackData rollbackData)) + { + int frameCountOffset = CallstackHeader(ref eventBuffer, id, T.CallstackType, 0); + + byte[] buffer = eventBuffer.Data; + int startIndex = eventBuffer.Index; + int currentIndex = startIndex; + + if (!captureCallstack.Capture(buffer, ref currentIndex, out byte count)) + { + byte[]? rentedArray = RentArray(maxCallstackBytes); + if (rentedArray != null) + { + int length = currentIndex - startIndex; + int index = length; + + Buffer.BlockCopy(buffer, startIndex, rentedArray, 0, length); + captureCallstack.Capture(rentedArray, ref index, out count); + + // Rollback async event header before flushing. + Serializer.RollbackAsyncEventHeader(context, in rollbackData); + context.Flush(); + + // Write the callstack again. + if (Serializer.AsyncEventHeader(context, ref eventBuffer, context.LastEventTimestamp, 0, eventID, MaxStaticEventPayloadSize + index)) + { + CallstackHeader(ref eventBuffer, id, T.CallstackType, count); + CallstackData(ref eventBuffer, rentedArray, index); + } + + ArrayPool.Shared.Return(rentedArray); + } + else + { + // Rollback async event header since we can't write the callstack. + Serializer.RollbackAsyncEventHeader(context, in rollbackData); + } + } + else + { + // Patch frame count in the event buffer using the offset from CallstackHeader. + eventBuffer.Data[frameCountOffset] = count; + eventBuffer.Index += currentIndex - startIndex; + } + } + } + + private static int CallstackHeader(ref EventBuffer eventBuffer, ulong id, AsyncCallstackType type, byte callstackFrameCount) + { + // Callstack header layout: type (1 byte) + callstackId (1 byte, reserved for future use) + frameCount (1 byte) + id (max 10 bytes compressed). + const int MaxCallstackHeaderSize = sizeof(byte) + sizeof(byte) + sizeof(byte) + Serializer.MaxCompressedUInt64Size; + + ref int index = ref eventBuffer.Index; + + Span callstackHeaderSpan = eventBuffer.Data.AsSpan(index, MaxCallstackHeaderSize); + int spanIndex = 0; + + callstackHeaderSpan[spanIndex++] = (byte)type; + callstackHeaderSpan[spanIndex++] = 0; // Reserved callstack ID for future callstack interning. + + int frameCountOffset = index + spanIndex; + callstackHeaderSpan[spanIndex++] = callstackFrameCount; + + spanIndex += Serializer.WriteCompressedUInt64(callstackHeaderSpan.Slice(spanIndex), id); + eventBuffer.Index += spanIndex; + + return frameCountOffset; + } + + private static void CallstackData(ref EventBuffer eventBuffer, byte[] callstackData, int callstackDataByteCount) + { + ref int index = ref eventBuffer.Index; + Buffer.BlockCopy(callstackData, 0, eventBuffer.Data, index, callstackDataByteCount); + index += callstackDataByteCount; + } + + private static byte[]? RentArray(int minimumLength) + { + byte[]? rentedArray = null; + try + { + rentedArray = ArrayPool.Shared.Rent(minimumLength); + } + catch + { + //AsyncProfiler can't throw, return null if renting fails. + } + + return rentedArray; + } + } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index 032eb3b8fbde88..d93004bb3e3606 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -20,44 +20,30 @@ internal unsafe ref struct AsyncTaskDispatcherInfo #else [FieldOffset(4)] #endif - public IAsyncStateMachineBox? InnerBox; + public AsyncTaskDispatcher? Dispatcher; #if TARGET_64BIT [FieldOffset(16)] #else [FieldOffset(8)] -#endif - public AsyncTaskDispatcher? Dispatcher; - -#if TARGET_64BIT - [FieldOffset(24)] -#else - [FieldOffset(12)] -#endif - public bool Suspended; - -#if TARGET_64BIT - [FieldOffset(32)] -#else - [FieldOffset(16)] #endif public AsyncProfiler.Info AsyncProfilerInfo; [ThreadStatic] internal static unsafe AsyncTaskDispatcherInfo* t_current; - internal static bool IsSuspended => t_current != null && t_current->Suspended; + internal static bool IsSuspended => t_current != null && t_current->Dispatcher is { Suspended: true }; internal static unsafe AsyncTaskDispatcher? SuspendAsyncContext() { - AsyncTaskDispatcherInfo* info = AsyncTaskDispatcherInfo.t_current; - if (info != null && info->Dispatcher is AsyncTaskDispatcher activeDispatcher) + AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; + if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) { - Debug.Assert(!info->Suspended); - info->Suspended = true; + Debug.Assert(!activeDispatcher.Suspended); + activeDispatcher.Suspended = true; if (AsyncInstrumentation.IsEnabled.SuspendAsyncContext(AsyncInstrumentation.SyncActiveFlags())) { - AsyncProfiler.SuspendAsyncContext.Suspend(ref info->AsyncProfilerInfo); + AsyncProfiler.SuspendAsyncContext.Suspend(activeDispatcher, ref current->AsyncProfilerInfo); } return activeDispatcher; @@ -78,9 +64,9 @@ internal static unsafe void UnwindAsyncFrame() internal static unsafe void ResumeAsyncMethod() { AsyncTaskDispatcherInfo* current = t_current; - if (current != null) + if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) { - AsyncProfiler.ResumeAsyncMethod.Resume(ref current->AsyncProfilerInfo); + AsyncProfiler.ResumeAsyncMethod.Resume(activeDispatcher, ref current->AsyncProfilerInfo); } } @@ -100,6 +86,14 @@ internal sealed class AsyncTaskDispatcher : Task, IAsyncStateMac private Action? _moveNextAction; private ulong _contextId; + internal IAsyncStateMachineBox? InnerBox => _inner; + + // Set by SuspendAsyncContext when this dispatcher's box yields during its single MoveNext. + // Dispatcher instances are one-shot (one MoveNext call per dispatcher), so default-false is sufficient. + internal bool Suspended; + + internal Task? LastContinuation; + internal AsyncTaskDispatcher(IAsyncStateMachineBox inner) : base() { _inner = inner; @@ -127,7 +121,7 @@ internal ulong ContextId /// /// Creates a new dispatcher for the given box. If a dispatcher is already active on the - /// info thread (mid-chain yield), marks the info frame as suspended and emit a suspend event. + /// current thread (mid-chain yield), marks the current frame as suspended and emit a suspend event. /// internal static AsyncTaskDispatcher Create(IAsyncStateMachineBox box) { @@ -168,9 +162,7 @@ public unsafe void MoveNext() refCurrent = &dispatcherInfo; dispatcherInfo.Next = previous; - dispatcherInfo.InnerBox = inner; dispatcherInfo.Dispatcher = this; - dispatcherInfo.Suspended = false; AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); AsyncProfiler.InitInfo(ref dispatcherInfo.AsyncProfilerInfo); @@ -187,10 +179,10 @@ public unsafe void MoveNext() } finally { - if (!dispatcherInfo.Suspended && AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) + if (!Suspended && AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) { Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Completed Id={ContextId}, tid={Environment.CurrentManagedThreadId}"); - AsyncProfiler.CompleteAsyncContext.Complete(ref dispatcherInfo.AsyncProfilerInfo); + AsyncProfiler.CompleteAsyncContext.Complete(this, ref dispatcherInfo.AsyncProfilerInfo); } // If Suspended, the Suspend event was already emitted inline by Create. @@ -212,5 +204,19 @@ public void ClearStateUponCompletion() _inner?.ClearStateUponCompletion(); _inner = null; } + + public void GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) + { + IAsyncStateMachineBox? inner = _inner; + if (inner != null) + { + inner.GetDiagnosticData(out methodId, out state, out nextContinuation); + return; + } + + methodId = 0; + state = -1; + nextContinuation = null; + } } } 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 89031bc55fc9bd..4f0ba9409793d1 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 @@ -357,7 +357,7 @@ private void MoveNext(Thread? threadPoolThread) AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - if (AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) + if (AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags) || AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) { Debug.WriteLine($"[AsyncTaskMethodBuilder.MoveNext] method={GetType().Name}, tid={Environment.CurrentManagedThreadId}"); AsyncTaskDispatcherInfo.ResumeAsyncMethod(); @@ -434,6 +434,104 @@ public void ClearStateUponCompletion() /// Gets the state machine as a boxed object. This should only be used for debugging purposes. IAsyncStateMachine IAsyncStateMachineBox.GetStateMachineObject() => StateMachine!; // likely boxes, only use for debugging + + void IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) + { + methodId = TStateMachineDiagnosticData.MethodId; + state = TStateMachineDiagnosticData.GetState(ref StateMachine); + nextContinuation = this.DiagnosticContinuationObject; + } + + private static class TStateMachineDiagnosticData + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetState(ref TStateMachine? stateMachine) + { + if (typeof(TStateMachine).IsValueType) + { + // Struct: state field is inline at offset within the struct + return Unsafe.As(ref Unsafe.AddByteOffset(ref Unsafe.As(ref stateMachine), (nint)s_resolveStateFieldOffset)); + } + else + { + // Class (debug builds): StateMachine is a reference, dereference to get object data + if (stateMachine != null) + { + return Unsafe.As(ref Unsafe.AddByteOffset(ref RuntimeHelpers.GetRawData(stateMachine), (nint)s_resolveStateFieldOffset)); + } + } + + return -1; + } + + public static ulong MethodId => s_methodId; + + private static readonly ulong s_methodId = ResolveMethodId(); + private static readonly int s_resolveStateFieldOffset = ResolveStateFieldOffset(); + +#if NATIVEAOT + private static ulong ResolveMethodId() + { + unsafe + { + MethodTable* instanceType = (MethodTable*)typeof(TStateMachine).TypeHandle.Value; + MethodTable* interfaceType = (MethodTable*)typeof(IAsyncStateMachine).TypeHandle.Value; + if (instanceType != null && interfaceType != null) + { + return (ulong)RuntimeImports.RhResolveDispatchOnType(instanceType, interfaceType, slot: 0); + } + return 0; + } + } +#else + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] + private static ulong ResolveMethodId() + { + MethodInfo? methodInfo = typeof(TStateMachine).GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (methodInfo != null) + { + return (ulong)methodInfo.MethodHandle.Value; + } + + return 0; + } +#endif + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087", Justification = "Only reachable for class state machines (debug builds) where Roslyn always generates constructors. The type is guaranteed preserved because AsyncStateMachineBox directly instantiates and uses it.")] + private static int ResolveStateFieldOffset() + { + FieldInfo? stateField = typeof(TStateMachine).GetField("<>1__state", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (stateField != null) + { +#if NATIVEAOT + const int Sentinel = 0x7F345678; + + object instance = typeof(TStateMachine).IsValueType ? (object)default(TStateMachine)! : RuntimeHelpers.GetUninitializedObject(typeof(TStateMachine)); + stateField.SetValue(instance, Sentinel); + + int size = (int)RuntimeHelpers.GetRawObjectDataSize(instance); + Debug.Assert(size >= sizeof(int), "TStateMachine object is too small to contain a state field."); + + ref byte data = ref RuntimeHelpers.GetRawData(instance); + + for (int i = 0; i < size; i += sizeof(int)) + { + if (Unsafe.As(ref Unsafe.AddByteOffset(ref data, i)) == Sentinel) + { + return i; + } + } +#else + Debug.Assert(stateField is RtFieldInfo, $"Expected RtFieldInfo but got {stateField.GetType().Name}"); + return RuntimeFieldHandle.GetInstanceFieldOffset((RtFieldInfo)stateField); +#endif + } + + return 0; + } + } } /// Gets the for this builder. diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs index b42401600c91b0..cd6eccf668b1cd 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs @@ -22,5 +22,8 @@ internal interface IAsyncStateMachineBox /// Clears the state of the box. void ClearStateUponCompletion(); + + /// Gets the state machine diagnostic data. + void GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation); } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs index fe9bf41e417cff..916a45bc5c7d87 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs @@ -399,7 +399,7 @@ private static void ExecutionContextCallback(object? s) void IThreadPoolWorkItem.Execute() => MoveNext(); /// Calls MoveNext on - // TODO-AsyncProfiler: This MoveNext lacks profiler instrumentation (Resume/Complete/Yield events). + // TODO-AsyncProfiler: This MoveNext lacks profiler instrumentation (Resume/Complete events). // The pooling builder is opt-in and uses ManualResetValueTaskSourceCore for completion instead of // Task.TrySetResult, so SetExistingTaskResult events don't fire here either. Needs dedicated // instrumentation similar to AsyncTaskMethodBuilder's AsyncStateMachineBox.MoveNext. @@ -446,6 +446,14 @@ void IValueTaskSource.GetResult(short token) /// Gets the state machine as a boxed object. This should only be used for debugging purposes. IAsyncStateMachine IAsyncStateMachineBox.GetStateMachineObject() => StateMachine!; // likely boxes, only use for debugging + + void IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) + { + // TODO-AsyncProfiler: Implement when pooling async builders are fully supported in AsyncProfiler. For now, return default values that won't cause confusion in the profiler. + methodId = 0; + state = -1; + nextContinuation = null; + } } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs index 5381d2d4418d0c..0601c366e3034b 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs @@ -7224,6 +7224,9 @@ internal static Task CreateUnwrapPromise(Task outerTask, bool return new UnwrapPromise(outerTask, lookForOce); } + /// Gets the continuation object for async callstack diagnostics. + internal object? DiagnosticContinuationObject => m_continuationObject; + internal virtual Delegate[]? GetDelegateContinuationsForDebugger() { // Avoid an infinite loop by making sure the continuation object is not a reference to istelf. 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 613df9af679ad6..ced7577d38ae0c 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 @@ -30,6 +30,15 @@ public enum AsyncEventID : byte ResetAsyncContinuationWrapperIndex = 12, AsyncProfilerMetadata = 13, AsyncProfilerSyncClock = 14, + AppendAsyncCallstack = 15, + } + + //Mirrors AsyncProfiler.AsyncCallstackType from the runtime (which is internal and inaccessible from tests). + public enum AsyncCallstackType : byte + { + Compiler = 0x1, + Runtime = 0x2, + Cached = 0x80 } [ActiveIssue("https://github.com/dotnet/runtime/issues/127951", TestPlatforms.Android | TestPlatforms.iOS | TestPlatforms.tvOS | TestPlatforms.MacCatalyst)] @@ -95,19 +104,45 @@ s_getMethodFromNativeIPMethod is null ? typeof(StackFrame).GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, new[] { typeof(IntPtr), typeof(bool) }, null) : null; - internal static string? GetMethodNameFromNativeIP(ulong nativeIP) + internal static string? GetMethodNameFromMethodId(AsyncCallstackType callstackType, ulong methodId) { - if (s_getMethodFromNativeIPMethod is not null) + if (methodId != 0) { - var method = (MethodBase?)s_getMethodFromNativeIPMethod.Invoke(null, new object[] { (IntPtr)nativeIP }); - return method?.Name; - } + if (callstackType == AsyncCallstackType.Runtime) + { + if (s_getMethodFromNativeIPMethod is not null) + { + var method = (MethodBase?)s_getMethodFromNativeIPMethod.Invoke(null, new object[] { (IntPtr)methodId }); + return method?.Name; + } - if (s_stackFrameFromIPCtor is not null) - { - var frame = (StackFrame)s_stackFrameFromIPCtor.Invoke(new object[] { (IntPtr)nativeIP, false })!; - var diagInfo = DiagnosticMethodInfo.Create(frame); - return diagInfo?.Name; + if (s_stackFrameFromIPCtor is not null) + { + var frame = (StackFrame)s_stackFrameFromIPCtor.Invoke(new object[] { (IntPtr)methodId, false })!; + var diagInfo = DiagnosticMethodInfo.Create(frame); + return diagInfo?.Name; + } + } + else if (callstackType == AsyncCallstackType.Compiler) + { + System.RuntimeMethodHandle handle = RuntimeMethodHandle.FromIntPtr((IntPtr)methodId); + MethodBase? method = MethodBase.GetMethodFromHandle(handle); + if (method != null) + { + string methodName = method.DeclaringType.Name; + + int start = methodName.IndexOf('<'); + int end = methodName.IndexOf('>'); + + start++; + if (start > 0 && end > start) + { + methodName = methodName.Substring(start, end - start); + } + + return methodName; + } + } } return null; @@ -370,6 +405,7 @@ private static bool SkipEventPayload(AsyncEventID eventId, ReadOnlySpan bu case AsyncEventID.CreateAsyncCallstack: case AsyncEventID.ResumeAsyncCallstack: case AsyncEventID.SuspendAsyncCallstack: + case AsyncEventID.AppendAsyncCallstack: SkipCallstackPayload(buffer, ref index); return true; default: @@ -395,15 +431,15 @@ private static void SkipCallstackPayload(ReadOnlySpan buffer, ref int inde } private static void ReadCallstackPayload(ReadOnlySpan buffer, ref int index, - out byte frameCount, out List<(ulong NativeIP, int State)> frames) + out byte frameCount, out List<(ulong MethodId, int State)> frames) { - ReadCallstackPayload(buffer, ref index, out _, out frameCount, out frames); + ReadCallstackPayload(buffer, ref index, out _, out _, out frameCount, out frames); } private static void ReadCallstackPayload(ReadOnlySpan buffer, ref int index, - out ulong taskId, out byte frameCount, out List<(ulong NativeIP, int State)> frames) + out ulong taskId, out AsyncCallstackType callstackType, out byte frameCount, out List<(ulong MethodId, int State)> frames) { - index++; // type + callstackType = (AsyncCallstackType)buffer[index++]; // type index++; // callstack ID (reserved) frameCount = buffer[index++]; taskId = ReadCompressedUInt64(buffer, ref index); @@ -412,16 +448,16 @@ private static void ReadCallstackPayload(ReadOnlySpan buffer, ref int inde if (frameCount == 0) return; - ulong currentNativeIP = ReadCompressedUInt64(buffer, ref index); + ulong currentMethodId = ReadCompressedUInt64(buffer, ref index); int state = ReadCompressedInt32(buffer, ref index); - frames.Add((currentNativeIP, state)); + frames.Add((currentMethodId, state)); for (int i = 1; i < frameCount; i++) { long delta = ReadCompressedInt64(buffer, ref index); state = ReadCompressedInt32(buffer, ref index); - currentNativeIP = (ulong)((long)currentNativeIP + delta); - frames.Add((currentNativeIP, state)); + currentMethodId = (ulong)((long)currentMethodId + delta); + frames.Add((currentMethodId, state)); } } @@ -486,8 +522,9 @@ private sealed class ParsedEvent public ulong TaskId { get; init; } // Callstack events (Create/Resume/Suspend): frames + public AsyncCallstackType CallstackType { get; init; } public byte FrameCount { get; init; } - public List<(ulong NativeIP, int State)> Frames { get; init; } = []; + public List<(ulong MethodId, int State)> Frames { get; init; } = []; // UnwindAsyncException: frame count unwound public uint UnwindFrameCount { get; init; } @@ -507,9 +544,9 @@ public bool HasMarkerFrame(string markerMethodName) { if (Frames.Count == 0) return false; - foreach (var (nativeIP, _) in Frames) + foreach (var (methodId, _) in Frames) { - var methodName = GetMethodNameFromNativeIP(nativeIP); + var methodName = GetMethodNameFromMethodId(CallstackType, methodId); if (methodName is not null && methodName.Contains(markerMethodName, StringComparison.Ordinal)) return true; } @@ -614,6 +651,7 @@ private static ParsedEventStream ParseAllEvents(CollectedEvents events) ulong osThreadId = header.Value.OsThreadId; ulong currentTaskId = 0; + var taskIdStack = new Stack(); int index = HeaderSize; long baseTimestamp = (long)header.Value.StartTimestamp; @@ -629,9 +667,12 @@ private static ParsedEventStream ParseAllEvents(CollectedEvents events) ParsedEvent evt = eventId switch { AsyncEventID.CreateAsyncContext or AsyncEventID.ResumeAsyncContext => - ParseContextEvent(eventId, baseTimestamp, osThreadId, buffer, ref index, ref currentTaskId), + ParseContextEvent(eventId, baseTimestamp, osThreadId, buffer, ref index, ref currentTaskId, taskIdStack), - AsyncEventID.SuspendAsyncContext or AsyncEventID.CompleteAsyncContext or + AsyncEventID.CompleteAsyncContext => + ParseCompleteContextEvent(baseTimestamp, osThreadId, ref currentTaskId, taskIdStack), + + AsyncEventID.SuspendAsyncContext or AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod => new ParsedEvent { @@ -645,8 +686,8 @@ AsyncEventID.SuspendAsyncContext or AsyncEventID.CompleteAsyncContext or ParseResetEvent(eventId, baseTimestamp, osThreadId, ref currentTaskId), AsyncEventID.CreateAsyncCallstack or AsyncEventID.ResumeAsyncCallstack or - AsyncEventID.SuspendAsyncCallstack => - ParseCallstackEvent(eventId, baseTimestamp, osThreadId, buffer, ref index), + AsyncEventID.SuspendAsyncCallstack or AsyncEventID.AppendAsyncCallstack => + ParseCallstackEvent(eventId, baseTimestamp, osThreadId, buffer, ref index, ref currentTaskId, taskIdStack), AsyncEventID.UnwindAsyncException => ParseUnwindEvent(baseTimestamp, osThreadId, currentTaskId, buffer, ref index), @@ -667,9 +708,11 @@ AsyncEventID.CreateAsyncCallstack or AsyncEventID.ResumeAsyncCallstack or return new ParsedEventStream(allEvents); static ParsedEvent ParseContextEvent(AsyncEventID eventId, long timestamp, ulong osThreadId, - ReadOnlySpan buffer, ref int index, ref ulong currentTaskId) + ReadOnlySpan buffer, ref int index, ref ulong currentTaskId, Stack taskIdStack) { ulong id = ReadCompressedUInt64(buffer, ref index); + if (eventId == AsyncEventID.ResumeAsyncContext && id != currentTaskId) + taskIdStack.Push(currentTaskId); currentTaskId = id; return new ParsedEvent { @@ -680,6 +723,20 @@ static ParsedEvent ParseContextEvent(AsyncEventID eventId, long timestamp, ulong }; } + static ParsedEvent ParseCompleteContextEvent(long timestamp, ulong osThreadId, + ref ulong currentTaskId, Stack taskIdStack) + { + ulong completedTaskId = currentTaskId; + currentTaskId = taskIdStack.Count > 0 ? taskIdStack.Pop() : 0; + return new ParsedEvent + { + EventId = AsyncEventID.CompleteAsyncContext, + Timestamp = timestamp, + OsThreadId = osThreadId, + TaskId = completedTaskId + }; + } + static ParsedEvent ParseResetEvent(AsyncEventID eventId, long timestamp, ulong osThreadId, ref ulong currentTaskId) { ulong prevTaskId = currentTaskId; @@ -695,15 +752,21 @@ static ParsedEvent ParseResetEvent(AsyncEventID eventId, long timestamp, ulong o } static ParsedEvent ParseCallstackEvent(AsyncEventID eventId, long timestamp, ulong osThreadId, - ReadOnlySpan buffer, ref int index) + ReadOnlySpan buffer, ref int index, ref ulong currentTaskId, Stack taskIdStack) { - ReadCallstackPayload(buffer, ref index, out ulong taskId, out byte frameCount, out var frames); + ReadCallstackPayload(buffer, ref index, out ulong taskId, out AsyncCallstackType callstackType, out byte frameCount, out var frames); + if (taskId != currentTaskId) + { + taskIdStack.Push(currentTaskId); + currentTaskId = taskId; + } return new ParsedEvent { EventId = eventId, Timestamp = timestamp, OsThreadId = osThreadId, TaskId = taskId, + CallstackType = callstackType, FrameCount = frameCount, Frames = frames }; @@ -793,8 +856,20 @@ private static void DumpAllEvents(CollectedEvents events) private static void RunScenarioAndFlush(Func scenario) { - Task.Run(scenario).GetAwaiter().GetResult(); - SendFlushCommand(); + // V1 (task-based) async: the dispatcher's finally block emits CompleteAsyncContext + // after inner.MoveNext() returns, but MoveNext() already set the task result which + // unblocks this thread. Brief sleep ensures the pool thread's finally completes. + // V2 (runtime-async) does not have this issue — Complete fires inside the dispatch + // loop before the task is signaled. + try + { + Task.Run(scenario).GetAwaiter().GetResult(); + } + finally + { + Thread.Sleep(50); + SendFlushCommand(); + } } private static void RunScenario(Func scenario) @@ -870,7 +945,7 @@ private static bool HasCallstackWithExpectedFrames(List callstacks, foreach (var cs in callstacks) { var resolvedNames = cs.Frames - .Select(f => GetMethodNameFromNativeIP(f.NativeIP)) + .Select(f => GetMethodNameFromMethodId(cs.CallstackType, f.MethodId)) .ToList(); int matchIndex = 0; @@ -1119,7 +1194,7 @@ public async Task RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait() { Assert.True(cs.FrameCount > 0, "Expected at least one frame in create callstack"); Assert.True(cs.TaskId != 0, "Expected non-zero task ID in create callstack"); - Assert.True(cs.Frames[0].NativeIP != 0, "Expected non-zero NativeIP in first frame"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); }); } @@ -1173,7 +1248,7 @@ public async Task RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait() { Assert.True(cs.FrameCount > 0, "Expected at least one frame in suspend callstack"); Assert.True(cs.TaskId != 0, "Expected non-zero task ID in suspend callstack"); - Assert.True(cs.Frames[0].NativeIP != 0, "Expected non-zero NativeIP in first frame"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); }); } @@ -1339,7 +1414,7 @@ public async Task RuntimeAsync_CreateAndFirstResumeCallstacksMatch() Assert.Equal(create.Frames.Count, matchingResume.Frames.Count); for (int i = 0; i < create.Frames.Count; i++) { - Assert.Equal(create.Frames[i].NativeIP, matchingResume.Frames[i].NativeIP); + Assert.Equal(create.Frames[i].MethodId, matchingResume.Frames[i].MethodId); } } @@ -1369,7 +1444,7 @@ public async Task RuntimeAsync_CallstackEmittedOnResume() { Assert.True(cs.FrameCount > 0, "Expected at least one frame in callstack"); Assert.True(cs.TaskId != 0, "Expected non-zero task ID in resume callstack"); - Assert.True(cs.Frames[0].NativeIP != 0, "Expected non-zero NativeIP in first frame"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); }); } @@ -1991,15 +2066,15 @@ public async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip() { for (int i = 0; i < cs.Frames.Count; i++) { - var (nativeIP, _) = cs.Frames[i]; - Assert.True(nativeIP != 0, $"Frame {i} has zero NativeIP"); + var (methodId, _) = cs.Frames[i]; + Assert.True(methodId != 0, $"Frame {i} has zero MethodId"); - var method = GetMethodNameFromNativeIP(nativeIP); - Assert.True(method is not null, $"Frame {i}: NativeIP 0x{nativeIP:X} does not resolve to a managed method"); + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Frame {i}: MethodId 0x{methodId:X} does not resolve to a managed method"); if (i > 0) { - long delta = (long)(cs.Frames[i].NativeIP - cs.Frames[i - 1].NativeIP); + long delta = (long)(cs.Frames[i].MethodId - cs.Frames[i - 1].MethodId); if (delta > 0) hasPositiveDelta = true; else if (delta < 0) @@ -2060,11 +2135,11 @@ public void RuntimeAsync_CallstackStressWithVaryingDepths() Assert.Equal((int)cs.FrameCount, cs.Frames.Count); for (int f = 0; f < cs.Frames.Count; f++) { - var (nativeIP, _) = cs.Frames[f]; - Assert.True(nativeIP != 0, $"Frame {f} has zero NativeIP"); + var (methodId, _) = cs.Frames[f]; + Assert.True(methodId != 0, $"Frame {f} has zero MethodId"); - var method = GetMethodNameFromNativeIP(nativeIP); - Assert.True(method is not null, $"Frame {f}: NativeIP 0x{nativeIP:X} does not resolve to a managed method"); + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); } } @@ -2210,11 +2285,11 @@ public void RuntimeAsync_CallstackOverflowPathProducesValidFrames() Assert.Equal((int)cs.FrameCount, cs.Frames.Count); for (int f = 0; f < cs.Frames.Count; f++) { - var (nativeIP, _) = cs.Frames[f]; - Assert.True(nativeIP != 0, $"Overflow callstack frame {f} has zero NativeIP"); + var (methodId, _) = cs.Frames[f]; + Assert.True(methodId != 0, $"Overflow callstack frame {f} has zero MethodId"); - var method = GetMethodNameFromNativeIP(nativeIP); - Assert.True(method is not null, $"Overflow callstack frame {f}: NativeIP 0x{nativeIP:X} does not resolve to a managed method"); + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Overflow callstack frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); } } } @@ -2258,11 +2333,11 @@ public void RuntimeAsync_CallstackDepthCappedAtMaxFrames() Assert.Equal((int)deepest.FrameCount, deepest.Frames.Count); // Verify all frames are valid. - foreach (var (nativeIP, _) in deepest.Frames) + foreach (var (methodId, _) in deepest.Frames) { - Assert.True(nativeIP != 0, "Frame has zero NativeIP"); - var method = GetMethodNameFromNativeIP(nativeIP); - Assert.True(method is not null, $"NativeIP 0x{nativeIP:X} does not resolve to a managed method"); + Assert.True(methodId != 0, "Frame has zero MethodId"); + var method = GetMethodNameFromMethodId(deepest.CallstackType, methodId); + Assert.True(method is not null, $"MethodId 0x{methodId:X} does not resolve to a managed method"); } } @@ -2371,6 +2446,7 @@ public static int OutputEventBuffer(ReadOnlySpan buffer) AsyncEventID.CreateAsyncCallstack => OutputAsyncCallstackEvent("CreateAsyncCallstack", buffer.Slice(index)), AsyncEventID.ResumeAsyncCallstack => OutputAsyncCallstackEvent("ResumeAsyncCallstack", buffer.Slice(index)), AsyncEventID.SuspendAsyncCallstack => OutputAsyncCallstackEvent("SuspendAsyncCallstack", buffer.Slice(index)), + AsyncEventID.AppendAsyncCallstack => OutputAsyncCallstackEvent("AppendAsyncCallstack", buffer.Slice(index)), AsyncEventID.ResumeAsyncMethod => OutputResumeAsyncMethodEvent(), AsyncEventID.CompleteAsyncMethod => OutputCompleteAsyncMethodEvent(), AsyncEventID.ResetAsyncThreadContext => OutputResetAsyncThreadContextEvent(), @@ -2539,32 +2615,32 @@ private static int OutputAsyncCallstackEvent(string eventName, ReadOnlySpan Date: Mon, 1 Jun 2026 19:02:33 +0200 Subject: [PATCH 04/19] Cleanup and instrumentation optimizations. --- .../CompilerServices/AsyncProfiler.CoreCLR.cs | 2 +- .../Runtime/CompilerServices/AsyncProfiler.cs | 16 +++------ .../CompilerServices/AsyncTaskDispatcher.cs | 26 +++++++++++---- .../AsyncTaskMethodBuilderT.cs | 17 ++++++---- .../ConfiguredValueTaskAwaitable.cs | 4 +-- .../Runtime/CompilerServices/TaskAwaiter.cs | 33 ++++++++++--------- .../CompilerServices/ValueTaskAwaiter.cs | 4 +-- .../CompilerServices/YieldAwaitable.cs | 2 +- .../Threading/Tasks/TaskContinuation.cs | 2 +- 9 files changed, 58 insertions(+), 48 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs index 95c705f22cd160..af5e98f1106488 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs @@ -464,7 +464,7 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, { CaptureRuntimeAsyncCallstackState state = default; state.Continuation = asyncCallstack; - EmitAsyncCallstack(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, state); + EmitAsyncCallstack(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, ref state); } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 13601022425456..c9f959caef9c33 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -771,17 +771,9 @@ public static void Resume(ref AsyncTaskDispatcherInfo info) public static void Append(AsyncTaskDispatcher dispatcher, AsyncThreadContext context, long currentTimestamp) { - if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords)) + if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords) && dispatcher.ContinuationChainChanged) { - Task? lastTask = dispatcher.LastContinuation; - if (lastTask != null) - { - object? newContinuation = lastTask.DiagnosticContinuationObject; - if (newContinuation != null) - { - AsyncCallstack.EmitEvent(dispatcher, context, newContinuation, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); - } - } + AsyncCallstack.EmitEvent(dispatcher, context, dispatcher.LastContinuation?.DiagnosticContinuationObject, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); } } @@ -1331,7 +1323,7 @@ public static void EmitEvent(AsyncTaskDispatcher dispatcher, AsyncThreadContext CaptureTaskAsyncCallstackState state = default; state.Continuation = box; - EmitAsyncCallstack(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, state); + EmitAsyncCallstack(context, currentTimestamp, currentTimestamp - context.LastEventTimestamp, eventID, id, ref state); box = ResolveAsyncStateMachineBox(state.LastContinuation); if (box != null) @@ -1461,7 +1453,7 @@ private static bool GetFrameDiagnosticsData(object? continuationObject, out ulon return null; } - private static void EmitAsyncCallstack(AsyncThreadContext context, long currentTimestamp, long delta, AsyncEventID eventID, ulong id, T captureCallstack) + private static void EmitAsyncCallstack(AsyncThreadContext context, long currentTimestamp, long delta, AsyncEventID eventID, ulong id, ref T captureCallstack) where T : ICaptureAsyncCallstack, allows ref struct { ref EventBuffer eventBuffer = ref context.EventBuffer; diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index d93004bb3e3606..9c29010fadc3ab 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using static System.Runtime.CompilerServices.AsyncInstrumentation; using static System.Runtime.CompilerServices.AsyncProfiler; namespace System.Runtime.CompilerServices @@ -32,16 +33,23 @@ internal unsafe ref struct AsyncTaskDispatcherInfo [ThreadStatic] internal static unsafe AsyncTaskDispatcherInfo* t_current; + public static bool InstrumentCheckPoint + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => AsyncInstrumentation.IsSupported && AsyncInstrumentation.ActiveFlags != AsyncInstrumentation.Flags.Disabled; + } + internal static bool IsSuspended => t_current != null && t_current->Dispatcher is { Suspended: true }; - internal static unsafe AsyncTaskDispatcher? SuspendAsyncContext() + internal static unsafe AsyncTaskDispatcher? SuspendAsyncContext(AsyncInstrumentation.Flags flags) { AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) { Debug.Assert(!activeDispatcher.Suspended); activeDispatcher.Suspended = true; - if (AsyncInstrumentation.IsEnabled.SuspendAsyncContext(AsyncInstrumentation.SyncActiveFlags())) + + if (AsyncInstrumentation.IsEnabled.SuspendAsyncContext(flags)) { AsyncProfiler.SuspendAsyncContext.Suspend(activeDispatcher, ref current->AsyncProfilerInfo); } @@ -61,12 +69,15 @@ internal static unsafe void UnwindAsyncFrame() } } - internal static unsafe void ResumeAsyncMethod() + internal static unsafe void ResumeAsyncMethod(AsyncInstrumentation.Flags flags) { AsyncTaskDispatcherInfo* current = t_current; if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) { - AsyncProfiler.ResumeAsyncMethod.Resume(activeDispatcher, ref current->AsyncProfilerInfo); + if ((AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags) && activeDispatcher.ContinuationChainChanged) || AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) + { + AsyncProfiler.ResumeAsyncMethod.Resume(activeDispatcher, ref current->AsyncProfilerInfo); + } } } @@ -119,15 +130,16 @@ internal ulong ContextId } } + internal bool ContinuationChainChanged => LastContinuation?.DiagnosticContinuationObject != null; + /// /// Creates a new dispatcher for the given box. If a dispatcher is already active on the /// current thread (mid-chain yield), marks the current frame as suspended and emit a suspend event. /// internal static AsyncTaskDispatcher Create(IAsyncStateMachineBox box) { - Debug.WriteLine($"[AsyncTaskDispatcher.Create] Thread={Environment.CurrentManagedThreadId}, box={box.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); - - AsyncTaskDispatcher? activeDispatcher = AsyncTaskDispatcherInfo.SuspendAsyncContext(); + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + AsyncTaskDispatcher? activeDispatcher = AsyncTaskDispatcherInfo.SuspendAsyncContext(flags); if (activeDispatcher != null) { Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Suspended Id={activeDispatcher.ContextId}, tid={Environment.CurrentManagedThreadId}"); 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 4f0ba9409793d1..9216fe857b82a8 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 @@ -354,17 +354,20 @@ private void MoveNext(Thread? threadPoolThread) { Debug.Assert(!IsCompleted); - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint) { - if (AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags) || AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - Debug.WriteLine($"[AsyncTaskMethodBuilder.MoveNext] method={GetType().Name}, tid={Environment.CurrentManagedThreadId}"); - AsyncTaskDispatcherInfo.ResumeAsyncMethod(); + if (AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags) || AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) + { + Debug.WriteLine($"[AsyncTaskMethodBuilder.MoveNext] method={GetType().Name}, tid={Environment.CurrentManagedThreadId}"); + AsyncTaskDispatcherInfo.ResumeAsyncMethod(flags); + } } - } - Debug.Assert(!AsyncTaskDispatcherInfo.IsSuspended); + Debug.Assert(!AsyncTaskDispatcherInfo.IsSuspended); + } bool loggingOn = TplEventSource.Log.IsEnabled(); if (loggingOn) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs index e65205299401a5..5c80c97eaa66d4 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs @@ -102,7 +102,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } @@ -212,7 +212,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs index 09e26decb6de49..b3a1d9cc4a61d0 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs @@ -194,29 +194,32 @@ internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox { Debug.Assert(stateMachineBox != null); - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint) { - if (continueOnCapturedContext) + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - if (SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext)) + if (continueOnCapturedContext) { - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + if (SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext)) + { + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } + else if (TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default) + { + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } } - else if (TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default) + + // If we're awaiting a non-async task (I/O, Timer, TCS, etc.), + // this box is the root of a V1 async chain. Wrap or reuse a dispatch box. + // For mid-chain boxes (awaiting another async method), the continuation will + // be inlined via RunContinuations, so no dispatcher wrapping is needed here. + if (task is not IAsyncStateMachineBox) { stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); } } - - // If we're awaiting a non-async task (I/O, Timer, TCS, etc.), - // this box is the root of a V1 async chain. Wrap or reuse a dispatch box. - // For mid-chain boxes (awaiting another async method), the continuation will - // be inlined via RunContinuations, so no dispatcher wrapping is needed here. - if (task is not IAsyncStateMachineBox) - { - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); - } } // If TaskWait* ETW events are enabled, trace a beginning event for this await diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs index 32317a435b0cdc..afdc2bff86318f 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs @@ -98,7 +98,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b // allocating a full AsyncTaskDispatcher (Task-derived). The IValueTaskSource.OnCompleted API // already takes Action + state separately, so we can use a lightweight static callback // that performs PUSH/MoveNext/POP inline without the Task overhead. - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } @@ -185,7 +185,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs index e26d290139a32b..a421661b4dc759 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs @@ -116,7 +116,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b { Debug.Assert(box != null); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs index 86b543ab9fc3e9..0e580bac153760 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs @@ -768,7 +768,7 @@ internal static void RunOrScheduleAction(IAsyncStateMachineBox box, bool allowIn // If we're not allowed to run here, schedule the action if (!allowInlining || !IsValidLocationForInlining) { - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) { box = AsyncTaskDispatcher.Create(box); } From 00bf37a2f19b285d2d4438897d19d2ffe4908855 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Tue, 2 Jun 2026 11:47:37 +0200 Subject: [PATCH 05/19] Fixing inline resume method append frame logic. --- .../Runtime/CompilerServices/AsyncProfiler.cs | 14 +++++- .../CompilerServices/AsyncTaskDispatcher.cs | 44 +++++++++++++------ .../AsyncTaskMethodBuilderT.cs | 8 +--- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index c9f959caef9c33..1b02924c929684 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -777,6 +777,14 @@ public static void Append(AsyncTaskDispatcher dispatcher, AsyncThreadContext con } } + public static void Append(AsyncTaskDispatcher dispatcher, IAsyncStateMachineBox enteringBox, AsyncThreadContext context, long currentTimestamp) + { + if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords) && dispatcher.ReachedLastContinuation) + { + AsyncCallstack.EmitEvent(dispatcher, context, enteringBox, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); + } + } + public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, ulong id) { const int MaxEventPayloadSize = Serializer.MaxCompressedUInt64Size; @@ -926,7 +934,7 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class ResumeAsyncMethod { - public static void Resume(AsyncTaskDispatcher dispatcher, ref Info info) + public static void Resume(AsyncTaskDispatcher dispatcher, IAsyncStateMachineBox box, ref Info info) { AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); @@ -938,7 +946,7 @@ public static void Resume(AsyncTaskDispatcher dispatcher, ref Info info) long currentTimestamp = Stopwatch.GetTimestamp(); if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords)) { - ResumeAsyncContext.Append(dispatcher, context, currentTimestamp); + ResumeAsyncContext.Append(dispatcher, box, context, currentTimestamp); } if (IsEnabled.ResumeAsyncMethodEvent(activeEventKeywords)) @@ -1340,6 +1348,8 @@ public static void EmitEvent(AsyncTaskDispatcher dispatcher, AsyncThreadContext { dispatcher.LastContinuation = null; } + + dispatcher.ReachedLastContinuation = false; } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index 9c29010fadc3ab..6357517730475f 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -69,15 +69,37 @@ internal static unsafe void UnwindAsyncFrame() } } - internal static unsafe void ResumeAsyncMethod(AsyncInstrumentation.Flags flags) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static unsafe void TryFireResumeAsyncMethod(IAsyncStateMachineBox box, AsyncInstrumentation.Flags flags) { AsyncTaskDispatcherInfo* current = t_current; - if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) + if (current == null || current->Dispatcher is not AsyncTaskDispatcher activeDispatcher) { - if ((AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags) && activeDispatcher.ContinuationChainChanged) || AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) - { - AsyncProfiler.ResumeAsyncMethod.Resume(activeDispatcher, ref current->AsyncProfilerInfo); - } + return; + } + + bool methodEventEnabled = AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags); + bool callstackEnabled = AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags); + if (!methodEventEnabled && !(callstackEnabled && activeDispatcher.LastContinuation != null)) + { + return; + } + + ResumeAsyncMethod(activeDispatcher, current, box, methodEventEnabled, callstackEnabled); + } + + private static unsafe void ResumeAsyncMethod(AsyncTaskDispatcher activeDispatcher, AsyncTaskDispatcherInfo* info, IAsyncStateMachineBox box, bool methodEventEnabled, bool callstackEnabled) + { + bool callstackEventEnabled = callstackEnabled && activeDispatcher.ReachedLastContinuation; + + if (!activeDispatcher.ReachedLastContinuation && ReferenceEquals(activeDispatcher.LastContinuation, box)) + { + activeDispatcher.ReachedLastContinuation = true; + } + + if (methodEventEnabled || callstackEventEnabled) + { + AsyncProfiler.ResumeAsyncMethod.Resume(activeDispatcher, box, ref info->AsyncProfilerInfo); } } @@ -99,12 +121,12 @@ internal sealed class AsyncTaskDispatcher : Task, IAsyncStateMac internal IAsyncStateMachineBox? InnerBox => _inner; - // Set by SuspendAsyncContext when this dispatcher's box yields during its single MoveNext. - // Dispatcher instances are one-shot (one MoveNext call per dispatcher), so default-false is sufficient. internal bool Suspended; internal Task? LastContinuation; + internal bool ReachedLastContinuation; + internal AsyncTaskDispatcher(IAsyncStateMachineBox inner) : base() { _inner = inner; @@ -142,12 +164,10 @@ internal static AsyncTaskDispatcher Create(IAsyncStateMachineBox box) AsyncTaskDispatcher? activeDispatcher = AsyncTaskDispatcherInfo.SuspendAsyncContext(flags); if (activeDispatcher != null) { - Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Suspended Id={activeDispatcher.ContextId}, tid={Environment.CurrentManagedThreadId}"); return new AsyncTaskDispatcher(box, activeDispatcher); } AsyncTaskDispatcher newDispatcher = new AsyncTaskDispatcher(box); - Debug.WriteLine($"[AsyncTaskDispatcher.Create] New dispatcher Id={newDispatcher.ContextId}, tid={Environment.CurrentManagedThreadId}"); if (AsyncInstrumentation.IsEnabled.CreateAsyncContext(AsyncInstrumentation.SyncActiveFlags())) { AsyncProfiler.CreateAsyncContext.Create((ulong)newDispatcher.ContextId); @@ -166,8 +186,6 @@ public unsafe void MoveNext() return; } - Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Thread={Environment.CurrentManagedThreadId}, Id={ContextId}, inner={inner.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); - AsyncTaskDispatcherInfo dispatcherInfo; ref AsyncTaskDispatcherInfo* refCurrent = ref AsyncTaskDispatcherInfo.t_current; AsyncTaskDispatcherInfo* previous = refCurrent; @@ -181,7 +199,6 @@ public unsafe void MoveNext() if (AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags)) { - Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Resuming Id={ContextId}, tid={Environment.CurrentManagedThreadId}"); AsyncProfiler.ResumeAsyncContext.Resume(ref dispatcherInfo); } @@ -193,7 +210,6 @@ public unsafe void MoveNext() { if (!Suspended && AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) { - Debug.WriteLine($"[AsyncTaskDispatcher.MoveNext] Completed Id={ContextId}, tid={Environment.CurrentManagedThreadId}"); AsyncProfiler.CompleteAsyncContext.Complete(this, ref dispatcherInfo.AsyncProfilerInfo); } 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 9216fe857b82a8..e4d6df65f6b844 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 @@ -359,11 +359,7 @@ private void MoveNext(Thread? threadPoolThread) AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - if (AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags) || AsyncInstrumentation.IsEnabled.ResumeAsyncMethod(flags)) - { - Debug.WriteLine($"[AsyncTaskMethodBuilder.MoveNext] method={GetType().Name}, tid={Environment.CurrentManagedThreadId}"); - AsyncTaskDispatcherInfo.ResumeAsyncMethod(flags); - } + AsyncTaskDispatcherInfo.TryFireResumeAsyncMethod(this, flags); } Debug.Assert(!AsyncTaskDispatcherInfo.IsSuspended); @@ -605,7 +601,6 @@ internal static void SetExistingTaskResult(Task task, TResult? result) { if (AsyncInstrumentation.IsEnabled.CompleteAsyncMethod(flags)) { - Debug.WriteLine($"[AsyncTaskMethodBuilder.SetExistingTaskResult] method={task.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); AsyncTaskDispatcherInfo.CompleteAsyncMethod(); } } @@ -643,7 +638,6 @@ internal static void SetException(Exception exception, ref Task? taskFi AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags) && AsyncInstrumentation.IsEnabled.UnwindAsyncException(flags)) { - Debug.WriteLine($"[AsyncTaskMethodBuilder.SetException] method={taskField.GetType().Name}, tid={Environment.CurrentManagedThreadId}"); AsyncTaskDispatcherInfo.UnwindAsyncFrame(); } From 67151908413fc8935ff2d9b544e9aff447dcc270 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Tue, 2 Jun 2026 14:10:39 +0200 Subject: [PATCH 06/19] Fixing double create emit on custom sync/scheduler scenarios. --- .../Runtime/CompilerServices/TaskAwaiter.cs | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs index b3a1d9cc4a61d0..16d3514731ae1e 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs @@ -199,26 +199,19 @@ internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) { - if (continueOnCapturedContext) + if (task is not IAsyncStateMachineBox) { - if (SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext)) - { - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); - } - else if (TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default) + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } + else if (continueOnCapturedContext) + { + bool customSyncContext = SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext); + bool customTaskScheduler = TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default; + if (customSyncContext || customTaskScheduler) { stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); } } - - // If we're awaiting a non-async task (I/O, Timer, TCS, etc.), - // this box is the root of a V1 async chain. Wrap or reuse a dispatch box. - // For mid-chain boxes (awaiting another async method), the continuation will - // be inlined via RunContinuations, so no dispatcher wrapping is needed here. - if (task is not IAsyncStateMachineBox) - { - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); - } } } From d6409c03c4593e55ad8a0a56f6e27a873938ab6d Mon Sep 17 00:00:00 2001 From: lateralusX Date: Tue, 2 Jun 2026 14:11:01 +0200 Subject: [PATCH 07/19] Added callstack merge capabilities to tests. --- .../AsyncProfilerTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) 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 ced7577d38ae0c..1ae17aa63313ef 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 @@ -614,6 +614,80 @@ public List ForTask(ulong taskId) => public List CallstacksWithMarker(AsyncEventID callstackEventId, string markerMethodName) => _events.Where(e => e.EventId == callstackEventId && e.HasMarkerFrame(markerMethodName)).ToList(); + /// + /// Reconstructs full resume callstacks by merging each ResumeAsyncCallstack with any subsequent + /// AppendAsyncCallstack events for the same context, up until the next Suspend/Complete on that + /// context. + /// + /// V1 dispatchers may emit a partial Resume callstack when the parent continuation hasn't yet + /// registered (race between dispatcher pickup and parent's AwaitUnsafeOnCompleted). Frames that + /// register later are emitted as AppendAsyncCallstack at the next hook point. Merging produces + /// the complete chain that was observable during the dispatcher's lifetime. + /// + /// Returns one ParsedEvent per Resume, with Frames and FrameCount reflecting the merged total. + /// + public List MergedResumeCallstacks() + { + var result = new List(); + var openByTaskId = new Dictionary(); // taskId → index into result + + foreach (var evt in _events) + { + switch (evt.EventId) + { + case AsyncEventID.ResumeAsyncCallstack: + { + var merged = new ParsedEvent + { + EventId = evt.EventId, + Timestamp = evt.Timestamp, + OsThreadId = evt.OsThreadId, + TaskId = evt.TaskId, + CallstackType = evt.CallstackType, + FrameCount = evt.FrameCount, + Frames = new List<(ulong MethodId, int State)>(evt.Frames), + }; + openByTaskId[evt.TaskId] = result.Count; + result.Add(merged); + break; + } + case AsyncEventID.AppendAsyncCallstack: + { + if (openByTaskId.TryGetValue(evt.TaskId, out int idx)) + { + var existing = result[idx]; + var combinedFrames = new List<(ulong MethodId, int State)>(existing.Frames); + combinedFrames.AddRange(evt.Frames); + result[idx] = new ParsedEvent + { + EventId = existing.EventId, + Timestamp = existing.Timestamp, + OsThreadId = existing.OsThreadId, + TaskId = existing.TaskId, + CallstackType = existing.CallstackType, + FrameCount = (byte)Math.Min(combinedFrames.Count, byte.MaxValue), + Frames = combinedFrames, + }; + } + break; + } + case AsyncEventID.SuspendAsyncContext: + case AsyncEventID.CompleteAsyncContext: + openByTaskId.Remove(evt.TaskId); + break; + } + } + + return result; + } + + /// + /// Get merged resume callstacks (Resume + subsequent Appends) that contain the marker method + /// in any of their merged frames. + /// + public List MergedResumeCallstacksWithMarker(string markerMethodName) => + MergedResumeCallstacks().Where(e => e.HasMarkerFrame(markerMethodName)).ToList(); + /// /// Get callstack events (of specified type) that contain the marker method, /// taking only the first match per Task.Id (deepest chain by timestamp). From 409742d9f3ccad7593effc9b3fd76e78c7fca102 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Tue, 2 Jun 2026 14:11:24 +0200 Subject: [PATCH 08/19] Extend asyncv1 test suite. --- .../AsyncProfilerV1Tests.cs | 1531 ++++++++++++++++- 1 file changed, 1440 insertions(+), 91 deletions(-) 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 9de838b41e88c1..6ebefe4beca4ec 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 @@ -55,15 +55,6 @@ static async Task TaskAsync_Level3() await Task.Delay(100); } - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_MultiYield() - { - await Task.Yield(); - await Task.Yield(); - await Task.Yield(); - } - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] static async Task TaskAsync_ExceptionHandled() @@ -100,47 +91,6 @@ static async Task TaskAsync_UnhandledExceptionInner() throw new InvalidOperationException("v1 unhandled inner"); } - private static ulong AssertCompleteContextChain(ParsedEventStream stream, params AsyncEventID[] expectedSequence) - { - var byTask = stream.ByTaskId(); - var candidates = new List(); - - foreach (var (taskId, events) in byTask) - { - var contextEvents = events - .Where(e => e.EventId is AsyncEventID.CreateAsyncContext - or AsyncEventID.ResumeAsyncContext - or AsyncEventID.SuspendAsyncContext - or AsyncEventID.CompleteAsyncContext - or AsyncEventID.UnwindAsyncException) - .Select(e => e.EventId) - .ToList(); - - candidates.Add($" TaskId={taskId}: [{string.Join(", ", contextEvents)}]"); - - if (contextEvents.Count != expectedSequence.Length) - continue; - - bool match = true; - for (int i = 0; i < expectedSequence.Length; i++) - { - if (contextEvents[i] != expectedSequence[i]) - { - match = false; - break; - } - } - - if (match) - return taskId; - } - - string expected = string.Join(", ", expectedSequence); - string found = string.Join(Environment.NewLine, candidates); - Assert.Fail($"No context found with expected chain [{expected}].\nContexts found:\n{found}"); - return 0; // unreachable - } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_EventsEmitted() { @@ -171,51 +121,94 @@ public void TaskAsync_CreateAsyncContextEmittedOnFirstAwait() Assert.True(creates.Count >= 1, $"Expected at least 1 CreateAsyncContext, got {creates.Count}"); } + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_EventSequenceOrderMarker() + { + // Use Task.Delay (not Task.Yield) so the dispatcher has predictable scheduling latency. + // The marker is the leaf await, so there is no parent-registration race to worry about. + await Task.Delay(100); + } + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_EventSequenceOrder() { - var events = CollectEvents(CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_SingleYield()); + RunScenarioAndFlush(() => TaskAsync_EventSequenceOrderMarker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - AssertCompleteContextChain(stream, - AsyncEventID.CreateAsyncContext, - AsyncEventID.ResumeAsyncContext, - AsyncEventID.CompleteAsyncContext); + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_EventSequenceOrderMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one merged resume callstack with {nameof(TaskAsync_EventSequenceOrderMarker)}"); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_SuspendResumeCompleteEventsMarker() + { + // Three sequential Delays produce three Suspend/Resume cycles on the same context + // (Create reuses the active dispatcher's context id and emits Suspend on each subsequent yield). + // Using Task.Delay (not Task.Yield) avoids the dispatcher-vs-registration race; the marker + // is the inner box, so it is reliably present in the Resume callstack at walk time. + await Task.Delay(100); + await Task.Delay(100); + await Task.Delay(100); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_SuspendResumeCompleteEvents() { - var events = CollectEvents(CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_MultiYield()); + RunScenarioAndFlush(() => TaskAsync_SuspendResumeCompleteEventsMarker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - ulong taskId = AssertCompleteContextChain(stream, - AsyncEventID.CreateAsyncContext, - AsyncEventID.ResumeAsyncContext, - AsyncEventID.SuspendAsyncContext, - AsyncEventID.ResumeAsyncContext, - AsyncEventID.SuspendAsyncContext, - AsyncEventID.ResumeAsyncContext, - AsyncEventID.CompleteAsyncContext); + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_SuspendResumeCompleteEventsMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(TaskAsync_SuspendResumeCompleteEventsMarker)}"); - var taskEvents = stream.ForTask(taskId); - foreach (var evt in taskEvents) - { - if (evt.EventId is AsyncEventID.ResumeAsyncContext or AsyncEventID.CreateAsyncContext) - Assert.Equal(taskId, evt.TaskId); - } + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx1 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx1 > createIdx, "Expected first ResumeAsyncContext after Create"); + + int suspendIdx1 = ids.IndexOf(AsyncEventID.SuspendAsyncContext, resumeIdx1 + 1); + Assert.True(suspendIdx1 > resumeIdx1, "Expected first SuspendAsyncContext after first Resume"); + + int resumeIdx2 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, suspendIdx1 + 1); + Assert.True(resumeIdx2 > suspendIdx1, "Expected second ResumeAsyncContext after first Suspend"); + + int suspendIdx2 = ids.IndexOf(AsyncEventID.SuspendAsyncContext, resumeIdx2 + 1); + Assert.True(suspendIdx2 > resumeIdx2, "Expected second SuspendAsyncContext after second Resume"); + + int resumeIdx3 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, suspendIdx2 + 1); + Assert.True(resumeIdx3 > suspendIdx2, "Expected third ResumeAsyncContext after second Suspend"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx3 + 1); + Assert.True(completeIdx > resumeIdx3, "Expected CompleteAsyncContext after third Resume"); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -235,50 +228,89 @@ public void TaskAsync_ResumeCompleteMethodEvents() Assert.Contains(AsyncEventID.CompleteAsyncMethod, ids); } + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_HandledException_EmitsUnwindAndCompleteMarker() + { + await TaskAsync_ExceptionHandled(); + } + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_HandledException_EmitsUnwindAndComplete() { - var events = CollectEvents(CoreKeywords | UnwindAsyncExceptionKeyword, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_ExceptionHandled()); + RunScenarioAndFlush(() => TaskAsync_HandledException_EmitsUnwindAndCompleteMarker()); }); - DumpAllEvents(events); + //DumpAllEvents(events); var stream = ParseAllEvents(events); - AssertCompleteContextChain(stream, - AsyncEventID.CreateAsyncContext, - AsyncEventID.ResumeAsyncContext, - AsyncEventID.UnwindAsyncException, - AsyncEventID.CompleteAsyncContext); + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_HandledException_EmitsUnwindAndCompleteMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(TaskAsync_HandledException_EmitsUnwindAndCompleteMarker)}"); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx > resumeIdx, "Expected UnwindAsyncException after Resume"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx + 1); + Assert.True(completeIdx > unwindIdx, "Expected CompleteAsyncContext after Unwind"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker() + { + await TaskAsync_UnhandledExceptionOuter(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_UnhandledException_EmitsUnwindAndComplete() { - var events = CollectEvents(CoreKeywords | UnwindAsyncExceptionKeyword, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => { try { - RunScenario(() => TaskAsync_UnhandledExceptionOuter()); + RunScenarioAndFlush(() => TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker()); } catch (InvalidOperationException) { } - SendFlushCommand(); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - AssertCompleteContextChain(stream, - AsyncEventID.CreateAsyncContext, - AsyncEventID.ResumeAsyncContext, - AsyncEventID.UnwindAsyncException, - AsyncEventID.UnwindAsyncException, - AsyncEventID.CompleteAsyncContext); + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)}"); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx1 = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx1 > resumeIdx, "Expected first UnwindAsyncException after Resume"); + + int unwindIdx2 = ids.IndexOf(AsyncEventID.UnwindAsyncException, unwindIdx1 + 1); + Assert.True(unwindIdx2 > unwindIdx1, "Expected second UnwindAsyncException after first Unwind"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx2 + 1); + Assert.True(completeIdx > unwindIdx2, "Expected CompleteAsyncContext after second Unwind"); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -355,12 +387,11 @@ public void TaskAsync_UnhandledException_MethodEventsWithUnwind() { try { - RunScenario(() => TaskAsync_UnhandledExceptionOuter()); + RunScenarioAndFlush(() => TaskAsync_UnhandledExceptionOuter()); } catch (InvalidOperationException) { } - SendFlushCommand(); }); // DumpAllEvents(events); @@ -386,5 +417,1323 @@ or AsyncEventID.CompleteAsyncMethod Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[2]); Assert.Equal(AsyncEventID.UnwindAsyncException, tail[3]); } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_ResumeAsyncCallstackEmitted() + { + //System.Diagnostics.Debugger.Launch(); + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_DeepChain()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var callstacks = stream.All + .Where(e => e.EventId == AsyncEventID.ResumeAsyncCallstack) + .ToList(); + + Assert.NotEmpty(callstacks); + Assert.All(callstacks, cs => + { + Assert.True(cs.FrameCount > 0, "Expected at least one frame in resume callstack"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero methodId in first frame"); + }); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_CallstackDepthMarker() + { + await TaskAsync_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CallstackDepthMatchesChainDepth() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_CallstackDepthMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CallstackDepthMarker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with TaskAsync_CallstackDepthMarker"); + + // TaskAsync_CallstackDepthMarker → Level1 → Level2 → Level3: deepest callstack should have exactly 4 frames + Assert.Equal(4, markerCallstacks[0].FrameCount); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_DistinctMethodIdsMarker() + { + await TaskAsync_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CallstackFramesHaveDistinctMethodIds() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_DistinctMethodIdsMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_DistinctMethodIdsMarker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with TaskAsync_DistinctMethodIdsMarker"); + + // Frames in the same callstack should have distinct methodIds (different async methods) + var methodIds = markerCallstacks[0].Frames.Select(f => f.MethodId).ToList(); + Assert.Equal(methodIds.Count, methodIds.Distinct().Count()); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_StateRoot() + { + await Task.Yield(); + await TaskAsync_StateMiddle(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_StateMiddle() + { + await Task.Yield(); + await Task.Yield(); + await TaskAsync_StateLeaf(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_StateLeaf() + { + await Task.Delay(100); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_CallstackStatesMarker() + { + await TaskAsync_StateRoot(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CallstackFramesHaveDistinctStates() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_CallstackStatesMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CallstackStatesMarker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with marker"); + + // The deepest callstack (on the final Delay resume) should have 4 frames: + // Leaf (state=3), Middle (state=2), Root (state=1), Marker (state=0) + var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; + Assert.Equal(4, deepest.FrameCount); + + // Each frame should have a different state reflecting its suspend point + var states = deepest.Frames.Select(f => f.State).ToList(); + Assert.Equal(0, states[0]); // Leaf: suspended at 1st await (state=0) + Assert.Equal(2, states[1]); // Middle: suspended at 3rd await (state=2) + Assert.Equal(1, states[2]); // Root: suspended at 2nd await (state=1) + Assert.Equal(0, states[3]); // Marker: suspended at 1st await (state=0) + } + + // --- Yield at each level scenario --- + // Each frame yields after calling its child, causing separate resume events + // with progressively shrinking callstacks as outer frames complete. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_YieldEachLevel_Marker() + { + await TaskAsync_YieldEachLevel_Level1(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_YieldEachLevel_Level1() + { + await TaskAsync_YieldEachLevel_Level2(); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_YieldEachLevel_Level2() + { + await TaskAsync_YieldEachLevel_Level3(); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_YieldEachLevel_Level3() + { + await Task.Delay(100); + await Task.Yield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_YieldAtEachLevel_CallstackShrinks() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_YieldEachLevel_Marker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_YieldEachLevel_Marker)); + + // After Task.Delay resumes: full chain (Level3, Level2, Level1, Marker) = 4 frames + // After Level3's yield resumes: Level3 completes, chain is (Level2, Level1, Marker) = 3 frames + // After Level2's yield resumes: Level2 completes, chain is (Level1, Marker) = 2 frames + Assert.Contains(markerCallstacks, cs => cs.FrameCount == 4); + Assert.Contains(markerCallstacks, cs => cs.FrameCount == 3); + Assert.Contains(markerCallstacks, cs => cs.FrameCount == 2); + } + + // --- Append callstack race scenario --- + // Forces the chain-growth race where the parent registers as a continuation + // AFTER the child's dispatcher has already walked the callstack but BEFORE + // the dispatcher hits its next suspend/complete point. This is the exact + // window the AppendAsyncCallstack mechanism is designed to fill in. + // + // Uses a SemaphoreSlim to deterministically order events independent of TP + // scheduling latency (a pure Thread.Sleep approach is unreliable because the + // TP dispatch latency for D1 can exceed the parent's sleep window). + // + // Order of events: + // 1. Parent calls Child; Child suspends at first Yield; D1 is created and queued. + // 2. Parent calls s_appendRace_proceed.Wait() — blocks. + // 3. D1 picked up by TP. D1.Resume walks chain → 1 frame (Child), because + // Parent hasn't done `await t` yet (it's still in Wait()). + // 4. D1 calls Child.MoveNext; Child resumes past the await and calls Release(). + // 5. Parent unblocks, does `await t` — registers Parent on Child.Task. + // 6. Meanwhile Child does Thread.Sleep(200) holding D1 alive. + // 7. Child hits second Yield → SuspendAsyncContext on D1 → Append check sees + // Parent now registered → emits AppendAsyncCallstack with Parent's frame. + + private static SemaphoreSlim s_appendRace_proceed; + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_AppendRace_Child() + { + await Task.Yield(); + // Inside D1.MoveNext now; Resume callstack walk already happened. + s_appendRace_proceed.Release(); + Thread.Sleep(200); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_AppendRace_Parent() + { + Task t = TaskAsync_AppendRace_Child(); + s_appendRace_proceed.Wait(); + await t; + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_AppendCallstack_FiresOnLateParentRegistration() + { + // System.Diagnostics.Debugger.Launch(); + s_appendRace_proceed = new SemaphoreSlim(0, 1); + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_AppendRace_Parent()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The initial Resume on the TP thread should walk only Child (race: Parent not registered yet). + var childOnlyResumes = stream + .OfType(AsyncEventID.ResumeAsyncCallstack) + .Where(e => e.FrameCount == 1 && e.HasMarkerFrame(nameof(TaskAsync_AppendRace_Child))) + .ToList(); + Assert.NotEmpty(childOnlyResumes); + + // After Parent registers and Child hits its next suspend/complete hook, + // an AppendAsyncCallstack should fire with the Parent frame. + var appendsWithParent = stream + .OfType(AsyncEventID.AppendAsyncCallstack) + .Where(e => e.HasMarkerFrame(nameof(TaskAsync_AppendRace_Parent))) + .ToList(); + Assert.NotEmpty(appendsWithParent); + } + + // --- Negative: Append should NOT fire when the chain is already complete at Resume time --- + // The deep-chain marker awaits Level1→Level2→Level3, where Level3 awaits Task.Delay(100). + // The 100ms delay gives all parent continuations ample time to register before the + // dispatcher walks the chain. The walker should terminate at a non-box (Task.Run wrapper) + // and set LastContinuation = null, so subsequent ResumeAsyncMethod/Suspend/Complete hooks + // for the inline cascade short-circuit and emit no Append events. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_CompleteChain_NoAppendMarker() + { + await TaskAsync_DeepChain(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CompleteChain_DoesNotEmitAppendEvents() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_CompleteChain_NoAppendMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // Sanity: the marker frame must appear in the initial Resume callstack (full chain captured). + var markerCallstacks = stream + .OfType(AsyncEventID.ResumeAsyncCallstack) + .Where(e => e.HasMarkerFrame(nameof(TaskAsync_CompleteChain_NoAppendMarker))) + .ToList(); + Assert.True(markerCallstacks.Count > 0, + $"Expected initial Resume callstack to contain {nameof(TaskAsync_CompleteChain_NoAppendMarker)} (full chain at walk time)"); + + // No Append events should fire — the chain was complete at Resume time. + var appendEvents = stream + .OfType(AsyncEventID.AppendAsyncCallstack) + .ToList(); + Assert.True(appendEvents.Count == 0, + $"Expected zero AppendAsyncCallstack events for a complete-chain scenario, got {appendEvents.Count}"); + } + + // --- Custom SynchronizationContext scenario --- + // Validates that when a non-default SynchronizationContext is active during an await, + // the dispatcher wrapping path is taken (TaskAwaiter.UnsafeOnCompletedInternal wraps the box) + // and the continuation flows through the custom context's Post back into the dispatcher's + // MoveNext. Standard Resume/Suspend/Complete events should fire normally; the marker frame + // must be visible in the Resume callstack. + + private sealed class InlinePostSynchronizationContext : SynchronizationContext + { + private int _postCount; + public int PostCount => _postCount; + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref _postCount); + d(state); + } + } + + private static InlinePostSynchronizationContext? s_taskAsyncSyncContextCtx; + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_SyncContextMarker() + { + // Install a non-default SynchronizationContext on this thread so the await captures it. + // The await's continuation will be routed via SynchronizationContextAwaitTaskContinuation, + // which wraps the box in an AsyncTaskDispatcher and posts back to the context. + // + // Note: the await may resume on a different thread (the SyncContext's Post may run on + // the timer thread or another worker). Only restore the previous context if we resumed + // on the same thread, to avoid polluting an unrelated thread's SynchronizationContext. + int callerThreadId = Environment.CurrentManagedThreadId; + SynchronizationContext? prev = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(s_taskAsyncSyncContextCtx); + try + { + await Task.Delay(100); + } + finally + { + if (Environment.CurrentManagedThreadId == callerThreadId) + { + SynchronizationContext.SetSynchronizationContext(prev); + } + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack() + { + s_taskAsyncSyncContextCtx = new InlinePostSynchronizationContext(); + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_SyncContextMarker()); + }); + + // DumpAllEvents(events); + + // The custom SyncContext should have received at least one Post for the await continuation. + Assert.True(s_taskAsyncSyncContextCtx.PostCount > 0, + $"Expected custom SynchronizationContext to receive at least one Post, got {s_taskAsyncSyncContextCtx.PostCount}"); + + var stream = ParseAllEvents(events); + + // The marker frame should appear in the Resume callstack (or via Append if the chain raced). + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_SyncContextMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected merged Resume callstack containing {nameof(TaskAsync_SyncContextMarker)}"); + + // Verify the standard Create → Resume → Complete sequence fired for our context. + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext for the custom SyncContext scenario"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + + // --- Custom TaskScheduler scenario --- + // Validates that when a non-default TaskScheduler is active (the marker runs on a custom + // scheduler via Task.Factory.StartNew(..., scheduler)), the dispatcher wrapping path is taken + // and the continuation flows through the custom scheduler's QueueTask back into the dispatcher's + // MoveNext via TaskSchedulerAwaitTaskContinuation. Standard Resume/Suspend/Complete events + // should fire normally. + + private sealed class InlineRunTaskScheduler : TaskScheduler + { + private int _queuedCount; + public int QueuedCount => _queuedCount; + + protected override void QueueTask(Task task) + { + Interlocked.Increment(ref _queuedCount); + TryExecuteTask(task); + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + + protected override IEnumerable? GetScheduledTasks() => null; + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_TaskSchedulerMarker() + { + // We rely on the caller having scheduled this method on a custom TaskScheduler via + // Task.Factory.StartNew, so TaskScheduler.InternalCurrent is the custom scheduler at + // the moment of await. The await's continuation gets routed through that scheduler. + await Task.Delay(100); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack() + { + var scheduler = new InlineRunTaskScheduler(); + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + // Schedule the marker on our custom scheduler so TaskScheduler.InternalCurrent + // is the custom scheduler when its first await is registered. Unwrap+GetResult + // blocks until the async chain completes, then RunScenarioAndFlush's pattern is + // inlined here (we can't reuse RunScenarioAndFlush because it uses Task.Run). + try + { + Task.Factory.StartNew( + () => TaskAsync_TaskSchedulerMarker(), + CancellationToken.None, + TaskCreationOptions.None, + scheduler).Unwrap().GetAwaiter().GetResult(); + } + finally + { + Thread.Sleep(50); + SendFlushCommand(); + } + }); + + // DumpAllEvents(events); + + // The custom scheduler must have received at least one QueueTask call (for the outer + // task). The continuation may or may not also be queued depending on whether the runtime + // inlines it on the timer thread; what matters for this test is that the dispatcher's + // events fired for the async context. + Assert.True(scheduler.QueuedCount >= 1, + $"Expected custom TaskScheduler to receive at least one QueueTask call, got {scheduler.QueuedCount}"); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_TaskSchedulerMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected merged Resume callstack containing {nameof(TaskAsync_TaskSchedulerMarker)}"); + + // Verify standard Create → Resume → Complete sequence for our context. + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext for the custom TaskScheduler scenario"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + + // --- ValueTask scenario methods --- + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_Level1() + { + await ValueTaskAsync_Level2(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_Level2() + { + await ValueTaskAsync_Level3(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_Level3() + { + await Task.Delay(100); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_Marker() + { + await ValueTaskAsync_Level1(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_EventSequenceOrderMarker() + { + await ValueTaskAsync_Marker(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_EventSequenceOrder() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => ValueTaskAsync_EventSequenceOrderMarker().AsTask()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_EventSequenceOrderMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(ValueTaskAsync_EventSequenceOrderMarker)}"); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_MethodEventsEmitted() + { + var events = CollectEvents(MethodKeywords | CoreKeywords, () => + { + RunScenarioAndFlush(() => ValueTaskAsync_Marker().AsTask()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // ValueTask chain of 4 methods: Marker → Level1 → Level2 → Level3 + var methodEvents = stream.All + .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod) + .Select(e => e.EventId) + .ToList(); + + int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); + int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); + + Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events for ValueTask chain, got {resumeCount}"); + Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events for ValueTask chain, got {completeCount}"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_CallstackDepthMatchesChainDepth() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => ValueTaskAsync_Marker().AsTask()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_Marker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ValueTaskAsync_Marker"); + + // ValueTaskAsync_Marker → Level1 → Level2 → Level3: deepest should have 4 frames + Assert.Equal(4, markerCallstacks[0].FrameCount); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_CallstackFramesHaveDistinctMethodIds() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => ValueTaskAsync_Marker().AsTask()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_Marker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ValueTaskAsync_Marker"); + + var methodIds = markerCallstacks[0].Frames.Select(f => f.MethodId).ToList(); + Assert.Equal(methodIds.Count, methodIds.Distinct().Count()); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_InnerThrows() + { + await Task.Delay(100); + throw new InvalidOperationException("valuetask inner throw"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_ExceptionHandled() + { + try + { + await ValueTaskAsync_InnerThrows(); + } + catch (InvalidOperationException) + { + } + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker() + { + await ValueTaskAsync_ExceptionHandled(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_HandledException_EmitsUnwindAndComplete() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => + { + RunScenarioAndFlush(() => ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker().AsTask()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker)}"); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx > resumeIdx, "Expected UnwindAsyncException after Resume"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx + 1); + Assert.True(completeIdx > unwindIdx, "Expected CompleteAsyncContext after Unwind"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_UnhandledOuter() + { + await ValueTaskAsync_UnhandledInner(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_UnhandledInner() + { + await Task.Delay(100); + throw new InvalidOperationException("valuetask unhandled inner"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async ValueTask ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker() + { + await ValueTaskAsync_UnhandledOuter(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => + { + try + { + RunScenarioAndFlush(() => ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker().AsTask()); + } + catch (InvalidOperationException) + { + } + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)); + Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)}"); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx1 = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx1 > resumeIdx, "Expected first UnwindAsyncException after Resume"); + + int unwindIdx2 = ids.IndexOf(AsyncEventID.UnwindAsyncException, unwindIdx1 + 1); + Assert.True(unwindIdx2 > unwindIdx1, "Expected second UnwindAsyncException after first Unwind"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx2 + 1); + Assert.True(completeIdx > unwindIdx2, "Expected CompleteAsyncContext after second Unwind"); + } + + // --- Negative: no events when profiler is disabled --- + // Validates that the InstrumentCheckPoint + AsyncProfiler flag short-circuit kicks in + // before the listener is attached, so async work done with no listener leaves no + // context-level events behind. After attachment, only background metadata events should + // be present (no Create/Resume/Suspend/Complete from prior or current work). + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_NoEventsWhenDisabledScenario() + { + await Task.Delay(50); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_NoEventsWhenDisabled() + { + // Run async work WITHOUT a listener attached. No keywords enabled → no events emitted. + for (int i = 0; i < 50; i++) + { + RunScenario(() => TaskAsync_NoEventsWhenDisabledScenario()); + } + + // Now attach a listener but don't perform any V1 async work — verify no stale events + // from the previous work leaked through. + var events = CollectEvents(CoreKeywords, () => { /* no-op */ }); + + var ids = ParseAllEvents(events).EventIds; + int contextEvents = ids.Count(id => + id == AsyncEventID.CreateAsyncContext || + id == AsyncEventID.ResumeAsyncContext || + id == AsyncEventID.SuspendAsyncContext || + id == AsyncEventID.CompleteAsyncContext); + + Assert.Equal(0, contextEvents); + } + + // --- Keyword gatekeeping --- + // Validates that each individual keyword only enables its corresponding event type. + // Auto-emitted infrastructure events (ResetAsyncThreadContext, AsyncProfilerMetadata) + // are always allowed. ResumeAsyncCallstackKeyword controls both ResumeAsyncCallstack + // AND AppendAsyncCallstack (V1 emits Append events under the same keyword as Resume). + + public static IEnumerable TaskAsyncKeywordGatekeepingData() + { + yield return new object[] { (long)CreateAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CreateAsyncContext } }; + yield return new object[] { (long)ResumeAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncContext } }; + yield return new object[] { (long)SuspendAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.SuspendAsyncContext } }; + yield return new object[] { (long)CompleteAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CompleteAsyncContext } }; + yield return new object[] { (long)UnwindAsyncExceptionKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.UnwindAsyncException } }; + yield return new object[] { (long)ResumeAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncCallstack, AsyncEventID.AppendAsyncCallstack } }; + yield return new object[] { (long)ResumeAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncMethod } }; + yield return new object[] { (long)CompleteAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CompleteAsyncMethod } }; + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_KeywordGatekeepingMarker() + { + // Exercise multiple event types: exception unwind, multiple suspends, method invocations. + try + { + await TaskAsync_InnerThrows(); + } + catch (InvalidOperationException) { } + await Task.Delay(50); + } + + [ConditionalTheory(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + [MemberData(nameof(TaskAsyncKeywordGatekeepingData))] + public void TaskAsync_KeywordGatekeeping(long keywordValue, AsyncEventID[] allowedEventIds) + { + EventKeywords kw = (EventKeywords)keywordValue; + var allowed = new HashSet(allowedEventIds); + + var events = CollectEvents(kw, () => + { + RunScenarioAndFlush(() => TaskAsync_KeywordGatekeepingMarker()); + }); + + var stream = ParseAllEvents(events); + var unexpected = stream.EventIds.Where(id => !allowed.Contains(id)).ToList(); + + Assert.True(unexpected.Count == 0, + $"Keyword 0x{(long)kw:X}: unexpected event IDs [{string.Join(", ", unexpected)}], allowed [{string.Join(", ", allowed)}]"); + } + + // --- Fork/join (WhenAll) test --- + // Validates V1 dispatcher behavior under a fork-join pattern: a single outer task awaits + // multiple parallel branches via Task.WhenAll. Each branch is its own async chain that + // completes on a (potentially) different ThreadPool thread. The outer resumes only after + // all branches have completed. This exercises: + // 1. Multi-branch chain tracking — each branch produces its own Create/Resume/Complete. + // 2. Concurrent Append safety — branches may complete on different threads simultaneously. + // 3. Outer resume after fan-in — the marker's Resume callstack reconstructs correctly + // after WhenAll's join releases the outer continuation. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAllBranchA() => await Task.Delay(100); + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAllBranchB() => await Task.Delay(120); + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAllBranchC() => await Task.Delay(140); + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAllMarker() + { + await Task.WhenAll( + TaskAsync_WhenAllBranchA(), + TaskAsync_WhenAllBranchB(), + TaskAsync_WhenAllBranchC()); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_WhenAll_TracksAllBranchesAndJoin() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_WhenAllMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The outer marker must resume after WhenAll's join releases it. Its callstack + // should contain the marker frame (proves the outer dispatcher was tracked and + // the resume callstack reconstruction works through the WhenAll join point). + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected at least one Resume callstack containing {nameof(TaskAsync_WhenAllMarker)} after WhenAll join"); + + // Each branch is its own async chain; its inner await of Task.Delay produces a + // Resume callstack containing the branch frame. + var branchACallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllBranchA)); + var branchBCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllBranchB)); + var branchCCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllBranchC)); + Assert.True(branchACallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchA)}"); + Assert.True(branchBCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchB)}"); + Assert.True(branchCCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchC)}"); + + // Every Create must be balanced by a Complete — fork-join must not leak dispatcher + // contexts, and concurrent branch completion must not double-Create. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); + + // We expect at least 4 Create events: 3 branches + 1 outer marker (the outer's + // await of WhenAll wraps its box). More is fine — internal infrastructure tasks + // (WhenAll's join task, Task.Delay continuations) may also wrap depending on + // SyncContext/Scheduler state. The lower bound proves all our user-visible chains + // were tracked. + Assert.True(createCount >= 4, + $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); + + // The outer marker's chain should fire the standard Create → Resume → Complete + // sequence on its own TaskId, in that order. + ulong markerTaskId = markerCallstacks[0].TaskId; + var markerIds = stream.ForTask(markerTaskId).Select(e => e.EventId).ToList(); + + int createIdx = markerIds.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext for the WhenAll outer marker"); + + int resumeIdx = markerIds.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create on the outer marker"); + + int completeIdx = markerIds.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume on the outer marker"); + + // The outer should be created exactly once (no double-wrap regression). + int createCountForMarker = markerIds.Count(id => id == AsyncEventID.CreateAsyncContext); + Assert.Equal(1, createCountForMarker); + } + + // --- Fork/join (WhenAny) test --- + // Validates V1 dispatcher behavior under WhenAny: outer resumes when the FIRST branch + // completes; the remaining branches continue running in the background. This is + // structurally different from WhenAll because: + // 1. Outer is resumed mid-fan-in, while sibling branches are still alive. + // 2. The outer dispatcher may be resumed MORE THAN ONCE (here: once after WhenAny, + // then again after WhenAll on the slow branches), exercising the resume-of-same- + // context cycle without re-Creating the dispatcher. + // 3. Branch dispatcher lifetimes are independent of the outer's WhenAny return — + // we still observe their Create/Resume/Complete events when they finish later. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAnyFast() => await Task.Delay(50); + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAnySlow1() => await Task.Delay(400); + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAnySlow2() => await Task.Delay(600); + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_WhenAnyMarker() + { + Task fast = TaskAsync_WhenAnyFast(); + Task slow1 = TaskAsync_WhenAnySlow1(); + Task slow2 = TaskAsync_WhenAnySlow2(); + + await Task.WhenAny(fast, slow1, slow2); + + // Ensure the slow branches actually complete before the scenario ends so their + // Create/Resume/Complete events are observable in the trace. This also forces a + // second suspend/resume cycle on the outer marker. + await Task.WhenAll(slow1, slow2); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_WhenAnyMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The outer marker is resumed at least once (after WhenAny releases it). Its + // callstack must contain the marker frame. + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnyMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected at least one Resume callstack containing {nameof(TaskAsync_WhenAnyMarker)} after WhenAny"); + + // All branches — including the slow ones whose completion the outer is no longer + // strictly waiting on after WhenAny returned — must produce their own Resume + // callstacks. This proves their dispatcher lifetimes are tracked independently. + var fastCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnyFast)); + var slow1Callstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnySlow1)); + var slow2Callstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnySlow2)); + Assert.True(fastCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnyFast)}"); + Assert.True(slow1Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow1)}"); + Assert.True(slow2Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow2)}"); + + // Every Create must be balanced by a Complete — concurrent fan-in with independent + // sibling lifetimes must not leak or double-count dispatcher contexts. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); + + // At least 4 Creates: 3 branches + 1 outer marker. + Assert.True(createCount >= 4, + $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); + + // The outer marker's chain: exactly one Create, at least two Resumes (one after + // WhenAny, one after WhenAll on the slow branches), then Complete. This validates + // resume-of-same-context cycles without re-Creating the dispatcher (no double-wrap). + ulong markerTaskId = markerCallstacks[0].TaskId; + var markerEvents = stream.ForTask(markerTaskId); + var markerIds = markerEvents.Select(e => e.EventId).ToList(); + + int createCountForMarker = markerIds.Count(id => id == AsyncEventID.CreateAsyncContext); + Assert.Equal(1, createCountForMarker); + + int resumeCountForMarker = markerIds.Count(id => id == AsyncEventID.ResumeAsyncContext); + // Resume count is timing-sensitive: ideally the outer suspends/resumes twice (after + // WhenAny, then after the subsequent WhenAll on the slow branches). But under load, + // by the time WhenAny returns and we reach the WhenAll, the slow tasks may have + // already completed — in which case WhenAll returns synchronously without a second + // suspend/resume. Either shape is correct runtime behavior; we only require >=1 + // (proves the outer was resumed at all). + Assert.True(resumeCountForMarker >= 1, + $"Expected outer marker to be resumed at least once, got {resumeCountForMarker}"); + + // Note: We don't assert an exact count of CompleteAsyncContext for the marker. V1's + // Suspend/Complete events don't carry an explicit TaskId in the wire format; the parser + // recovers it from the active context. The parser pops on Complete but NOT on Suspend, + // so when the marker suspends (awaiting WhenAll on the slow branches) and a sibling + // branch's Complete then fires, the parser misattributes that Complete to the still- + // active marker context. So the parsed Complete count for the marker may be >1 even + // though the runtime emitted exactly one Complete for it. The overall Create==Complete + // balance check above already covers the no-leak guarantee. + int completeCountForMarker = markerIds.Count(id => id == AsyncEventID.CompleteAsyncContext); + Assert.True(completeCountForMarker >= 1, "Expected at least one CompleteAsyncContext for the outer marker"); + + // First event for the marker is its Create; last is a Complete. + Assert.Equal(AsyncEventID.CreateAsyncContext, markerIds[0]); + Assert.Equal(AsyncEventID.CompleteAsyncContext, markerIds[^1]); + } + + // --- Callstack cap / overflow / stress tests --- + // Mirrors the V2 versions but uses V1 (Task-based) recursive chains. The walker shares + // the same buffer/cap infrastructure (byte FrameCount, rent-on-overflow fallback) so these + // tests guard the same code paths from the V1 entry point. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_RecursiveChain(int depth) + { + if (depth <= 1) + { + await Task.Delay(100); + return; + } + await TaskAsync_RecursiveChain(depth - 1); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_RecursiveChainMarker(int depth) + { + await TaskAsync_RecursiveChain(depth); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CallstackDepthCappedAtMaxFrames() + { + // Build a chain deeper than the 255-frame cap (byte FrameCount). The deepest + // ResumeAsyncCallstack should clamp at byte.MaxValue without crashing. + const int requestedDepth = 300; + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_RecursiveChainMarker(requestedDepth)); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var callstacks = stream.OfType(AsyncEventID.ResumeAsyncCallstack).ToList(); + Assert.True(callstacks.Count >= 1, "Expected at least one callstack"); + + // Walker caps frames at byte.MaxValue. Requested depth is 300, capped to 255. + var deepest = callstacks.MaxBy(cs => cs.FrameCount); + Assert.Equal(byte.MaxValue, deepest!.FrameCount); + Assert.Equal((int)deepest.FrameCount, deepest.Frames.Count); + + // Every captured frame should resolve to a managed method. + foreach (var (methodId, _) in deepest.Frames) + { + Assert.True(methodId != 0, "Frame has zero MethodId"); + var method = GetMethodNameFromMethodId(deepest.CallstackType, methodId); + Assert.True(method is not null, $"MethodId 0x{methodId:X} does not resolve to a managed method"); + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_CallstackStressWithVaryingDepths() + { + // Stress test: many V1 async invocations with varying chain depths. Varying sizes + // place some callstacks at buffer boundaries, naturally exercising the overflow/rewind + // path in the shared callstack emission code. + const int iterations = 50; + int[] depths = new int[iterations]; + var rng = new Random(42); + for (int i = 0; i < iterations; i++) + depths[i] = rng.Next(1, 60); + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(async () => + { + for (int i = 0; i < iterations; i++) + await TaskAsync_RecursiveChainMarker(depths[i]); + }); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var callstacks = stream + .OfType(AsyncEventID.ResumeAsyncCallstack) + .Where(e => e.HasMarkerFrame(nameof(TaskAsync_RecursiveChainMarker))) + .ToList(); + + // Every emitted callstack must have valid frame data. + foreach (var cs in callstacks) + { + Assert.True(cs.FrameCount > 0, "Callstack has 0 frames"); + Assert.Equal((int)cs.FrameCount, cs.Frames.Count); + for (int f = 0; f < cs.Frames.Count; f++) + { + var (methodId, _) = cs.Frames[f]; + Assert.True(methodId != 0, $"Frame {f} has zero MethodId"); + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); + } + } + + // We expect at least one marker callstack per iteration (some may not be the deepest + // due to mid-chain dispatcher walks). Use >= as the strict count varies with timing. + Assert.True(callstacks.Count >= iterations, + $"Expected at least {iterations} callstacks with marker, got {callstacks.Count}"); + + // Verify multiple buffer flushes occurred — proves the buffer machinery is exercised. + int bufferCount = 0; + ForEachEventBufferPayload(events, _ => bufferCount++); + Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); + } + + // --- ConfigureAwait(false) chain test --- + // Validates that ConfigureAwait(false) at every level of a chain does NOT break the + // dispatcher cascade or cause the box to be wrapped more than once. ConfigureAwait(false) + // routes through ConfiguredTaskAwaitable instead of TaskAwaiter, which has its own + // UnsafeOnCompletedInternal path. A regression here would either drop chain frames or + // emit multiple Create events per logical async method. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_ConfigureAwaitFalseLeaf() + { + await Task.Delay(100).ConfigureAwait(false); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_ConfigureAwaitFalseMid() + { + await TaskAsync_ConfigureAwaitFalseLeaf().ConfigureAwait(false); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_ConfigureAwaitFalseMarker() + { + await TaskAsync_ConfigureAwaitFalseMid().ConfigureAwait(false); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_ConfigureAwaitFalse_DoesNotBreakCascadeOrDoubleWrap() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_ConfigureAwaitFalseMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The full chain (Leaf -> Mid -> Marker) must appear in the Resume callstack. + // For ConfigureAwait(false) box-to-box chains with no SyncContext/Scheduler, the + // runtime takes the inline-cascade optimization: a single dispatcher walks the + // entire chain instead of wrapping each level. This is the OPTIMAL trace shape — + // minimum dispatcher overhead, full chain visibility via the callstack. + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_ConfigureAwaitFalseMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected at least one Resume callstack containing {nameof(TaskAsync_ConfigureAwaitFalseMarker)} (cascade not broken)"); + + // The deepest callstack should include all 3 chain frames — proves the chain walk + // crossed every ConfigureAwait(false) level without dropping any. + var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; + var frameNames = deepest.Frames + .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) + .Where(n => n is not null) + .ToList(); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseLeaf), frameNames); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseMid), frameNames); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseMarker), frameNames); + + // Every Create must be balanced by a Complete — no leaks. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); + + // Strongest no-double-wrap check: the cascade optimization should produce exactly + // 1 dispatcher for the entire chain (the leaf's non-box Task.Delay wrapping). A + // regression that re-introduced per-level wrapping would push this to 3+. + Assert.Equal(1, createCount); + } + + // --- Faulted task test --- + // Validates that an async method that throws (and whose exception is caught upstream) + // still produces a clean trace: balanced Create/Complete events, an UnwindAsyncException + // event reflecting the unwound frames, and the marker's Resume callstack present. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_FaultedInner() + { + await Task.Delay(50); + throw new InvalidOperationException("test fault"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_FaultedMarker() + { + try + { + await TaskAsync_FaultedInner(); + } + catch (InvalidOperationException) + { + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_FaultedTask_BalancedEventsAndUnwindEmitted() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | UnwindAsyncExceptionKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_FaultedMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The marker must still resume and complete — exception propagation does not orphan + // the dispatcher. + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_FaultedMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected at least one Resume callstack containing {nameof(TaskAsync_FaultedMarker)}"); + + // No leak: every dispatcher that was Created must Complete, even on the fault path. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); + + // The runtime emits UnwindAsyncException when an async method completes with an + // exception (AsyncTaskMethodBuilder.SetException path). + int unwindCount = stream.OfType(AsyncEventID.UnwindAsyncException).Count(); + Assert.True(unwindCount > 0, + "Expected at least one UnwindAsyncException event for the faulted inner task"); + } + + // --- Cancellation test --- + // Validates that cancellation (OperationCanceledException flowing through the chain) + // produces a well-formed trace with balanced events and the cancelled chain's marker + // visible in a Resume callstack. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_CancelledInner(CancellationToken ct) + { + await Task.Delay(5000, ct); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_CancelledMarker() + { + using var cts = new CancellationTokenSource(); + Task inner = TaskAsync_CancelledInner(cts.Token); + cts.CancelAfter(50); + try + { + await inner; + } + catch (OperationCanceledException) + { + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_Cancellation_BalancedEvents() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | UnwindAsyncExceptionKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_CancelledMarker()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The marker must resume and produce a callstack — cancellation propagation does + // not orphan the dispatcher chain. + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CancelledMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected at least one Resume callstack containing {nameof(TaskAsync_CancelledMarker)}"); + + // No leak: every Create must be balanced by a Complete on the cancellation path. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); + + // At least 2 Creates: inner cancelled task + outer marker. Both must Complete. + Assert.True(createCount >= 2, + $"Expected at least 2 CreateAsyncContext events (inner + marker), got {createCount}"); + } } } From b37ab8f69e9eaa8f504039bf08f1c8d1f69b10bf Mon Sep 17 00:00:00 2001 From: lateralusX Date: Wed, 3 Jun 2026 11:35:13 +0200 Subject: [PATCH 09/19] Native AOT adjustments around method id and state. --- .../Runtime/CompilerServices/AsyncProfiler.cs | 3 +- .../CompilerServices/AsyncTaskDispatcher.cs | 6 +- .../AsyncTaskMethodBuilderT.cs | 87 +++++++++---------- .../CompilerServices/IAsyncStateMachineBox.cs | 2 +- .../PoolingAsyncValueTaskMethodBuilderT.cs | 5 +- 5 files changed, 48 insertions(+), 55 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 1b02924c929684..31d31ed1a5edd0 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -1420,8 +1420,7 @@ private static bool GetFrameDiagnosticsData(object? continuationObject, out ulon IAsyncStateMachineBox? box = ResolveAsyncStateMachineBox(continuationObject); if (box != null) { - box.GetDiagnosticData(out methodId, out state, out nextContinuation); - return true; + return box.GetDiagnosticData(out methodId, out state, out nextContinuation); } methodId = 0; diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index 6357517730475f..568f196c680486 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -233,18 +233,18 @@ public void ClearStateUponCompletion() _inner = null; } - public void GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) + public bool GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) { IAsyncStateMachineBox? inner = _inner; if (inner != null) { - inner.GetDiagnosticData(out methodId, out state, out nextContinuation); - return; + return inner.GetDiagnosticData(out methodId, out state, out nextContinuation); } methodId = 0; state = -1; nextContinuation = null; + return false; } } } 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 e4d6df65f6b844..9ac98f02fec446 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 @@ -434,15 +434,50 @@ public void ClearStateUponCompletion() /// Gets the state machine as a boxed object. This should only be used for debugging purposes. IAsyncStateMachine IAsyncStateMachineBox.GetStateMachineObject() => StateMachine!; // likely boxes, only use for debugging - void IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) + bool IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) { - methodId = TStateMachineDiagnosticData.MethodId; - state = TStateMachineDiagnosticData.GetState(ref StateMachine); - nextContinuation = this.DiagnosticContinuationObject; + if (AsyncInstrumentation.IsSupported) + { + methodId = TStateMachineDiagnosticData.MethodId; + state = TStateMachineDiagnosticData.GetState(ref StateMachine); + nextContinuation = this.DiagnosticContinuationObject; + return true; + } + else + { + methodId = 0; + state = -1; + nextContinuation = null; + return false; + } } private static class TStateMachineDiagnosticData { +#if NATIVEAOT + // In NativeAOT we don't have reflection to resolve the method handle and state field offset. + // Due to the way the state machine is constructed, we can't get a direct pointer to its MoveNext method + // and using the interface dispatch to locate the method at slot 0 is unreliable due to Native AOT optimizations. + // The state field is also not guaranteed to be at a specific offset due to auto layout and Native AOT optimizations. + // To support this on Native AOT we would need to precompute this information in ILC and emit a + // hash table keyed by state machine MethodTable. At runtime we would still need to cache + // this data in static fields to avoid lookup cost when walking each continuation frame. + // On JIT these static fields are lazy evaluated and cached on initial access, but on Native AOT + // they will be pre-allocated, so code should be linked out when diagnostics is not supported. + // Given the added complexity on Native AOT, the fact that this is only used for diagnostics, + // and that Native AOT currently have limited asyncv1 diagnostics support in tooling, we can + // postpone the support until proven needed. + public static ulong MethodId => 0; + public static int GetState(ref TStateMachine? _) + { + return -1; + } +#else + private static readonly ulong s_methodId = ResolveMethodId(); + private static readonly int s_resolveStateFieldOffset = ResolveStateFieldOffset(); + + public static ulong MethodId => s_methodId; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetState(ref TStateMachine? stateMachine) { @@ -463,27 +498,6 @@ public static int GetState(ref TStateMachine? stateMachine) return -1; } - public static ulong MethodId => s_methodId; - - private static readonly ulong s_methodId = ResolveMethodId(); - private static readonly int s_resolveStateFieldOffset = ResolveStateFieldOffset(); - -#if NATIVEAOT - private static ulong ResolveMethodId() - { - unsafe - { - MethodTable* instanceType = (MethodTable*)typeof(TStateMachine).TypeHandle.Value; - MethodTable* interfaceType = (MethodTable*)typeof(IAsyncStateMachine).TypeHandle.Value; - if (instanceType != null && interfaceType != null) - { - return (ulong)RuntimeImports.RhResolveDispatchOnType(instanceType, interfaceType, slot: 0); - } - return 0; - } - } -#else - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] private static ulong ResolveMethodId() { @@ -495,41 +509,20 @@ private static ulong ResolveMethodId() return 0; } -#endif [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087", Justification = "Only reachable for class state machines (debug builds) where Roslyn always generates constructors. The type is guaranteed preserved because AsyncStateMachineBox directly instantiates and uses it.")] private static int ResolveStateFieldOffset() { FieldInfo? stateField = typeof(TStateMachine).GetField("<>1__state", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (stateField != null) { -#if NATIVEAOT - const int Sentinel = 0x7F345678; - - object instance = typeof(TStateMachine).IsValueType ? (object)default(TStateMachine)! : RuntimeHelpers.GetUninitializedObject(typeof(TStateMachine)); - stateField.SetValue(instance, Sentinel); - - int size = (int)RuntimeHelpers.GetRawObjectDataSize(instance); - Debug.Assert(size >= sizeof(int), "TStateMachine object is too small to contain a state field."); - - ref byte data = ref RuntimeHelpers.GetRawData(instance); - - for (int i = 0; i < size; i += sizeof(int)) - { - if (Unsafe.As(ref Unsafe.AddByteOffset(ref data, i)) == Sentinel) - { - return i; - } - } -#else Debug.Assert(stateField is RtFieldInfo, $"Expected RtFieldInfo but got {stateField.GetType().Name}"); return RuntimeFieldHandle.GetInstanceFieldOffset((RtFieldInfo)stateField); -#endif } return 0; } +#endif } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs index cd6eccf668b1cd..834fa016e5e7fd 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/IAsyncStateMachineBox.cs @@ -24,6 +24,6 @@ internal interface IAsyncStateMachineBox void ClearStateUponCompletion(); /// Gets the state machine diagnostic data. - void GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation); + bool GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation); } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs index 916a45bc5c7d87..48038326e5de70 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs @@ -447,12 +447,13 @@ void IValueTaskSource.GetResult(short token) /// Gets the state machine as a boxed object. This should only be used for debugging purposes. IAsyncStateMachine IAsyncStateMachineBox.GetStateMachineObject() => StateMachine!; // likely boxes, only use for debugging - void IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) + bool IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) { - // TODO-AsyncProfiler: Implement when pooling async builders are fully supported in AsyncProfiler. For now, return default values that won't cause confusion in the profiler. + // TODO-AsyncProfiler: Implement when pooling async builders are fully supported in AsyncProfiler. methodId = 0; state = -1; nextContinuation = null; + return false; } } } From 981127b9ccaffbf31e04afe8f16d7d039390ac39 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Wed, 3 Jun 2026 12:05:00 +0200 Subject: [PATCH 10/19] Don't emit append event for last continuation when method events are enabled. --- .../src/System/Runtime/CompilerServices/AsyncProfiler.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 31d31ed1a5edd0..0b3ec30b77f090 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -781,7 +781,10 @@ public static void Append(AsyncTaskDispatcher dispatcher, IAsyncStateMachineBox { if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords) && dispatcher.ReachedLastContinuation) { - AsyncCallstack.EmitEvent(dispatcher, context, enteringBox, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); + if (!ReferenceEquals(enteringBox, dispatcher.LastContinuation)) + { + AsyncCallstack.EmitEvent(dispatcher, context, enteringBox, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); + } } } From c8bab472085f36bf1ed9692a8ef1db829cb996e4 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Wed, 3 Jun 2026 12:05:56 +0200 Subject: [PATCH 11/19] Add single threaded asyncv1 smoke test. --- .../AsyncProfilerV1Tests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) 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 6ebefe4beca4ec..e47c2d3638f32c 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 @@ -1553,6 +1553,114 @@ public void TaskAsync_CallstackStressWithVaryingDepths() Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); } + // --- Single-threaded compatible test --- + // Validates that V1 dispatcher events fire correctly on platforms without threading + // support (single-threaded WASM). Uses TaskCompletionSource as a deterministic + // suspension/resume primitive instead of Task.Delay / Task.Run, so the test runs + // cleanly on the single-threaded runtime. + // + // The chain suspends on `await gate`; SetResult drives synchronous resumption that + // unwinds the entire chain in-order on the calling thread. This exercises the V1 + // inline-cascade code path (1 Create for the whole chain, full callstack reconstruction). + // + // Coverage in one test: Create/Resume/Complete events fire, callstack reconstruction + // works across all chain levels, marker frame visibility, no double-wrap, balanced + // Create/Complete (no leaks). The IsRuntimeAsyncSupported gate (looser than the + // IsRuntimeAsyncAndThreadingSupported gate used by the rest of the V1 suite) lets this + // test run on single-threaded WASM. + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_SingleThreadInner(Task gate) + { + await gate; + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_SingleThreadMid(Task gate) + { + await TaskAsync_SingleThreadInner(gate); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task TaskAsync_SingleThreadMarker(Task gate) + { + await TaskAsync_SingleThreadMid(gate); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task TaskAsync_SingleThreadCompatible_ChainEventsAndCallstack() + { + var events = await CollectEventsAsync(ResumeAsyncCallstackKeyword | CoreKeywords | MethodKeywords, async () => + { + var tcs = new TaskCompletionSource(); + Task chain = TaskAsync_SingleThreadMarker(tcs.Task); + // chain is now suspended: Inner awaits gate, Mid awaits Inner, Marker awaits Mid. + // SetResult (default, NOT RunContinuationsAsynchronously which requires ThreadPool) + // runs continuations synchronously inline on this thread, driving the entire chain + // to completion in-order without involving any timer or worker thread. + tcs.SetResult(); + await chain; + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // The marker frame must appear in a Resume callstack — proves the chain was walkable. + var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_SingleThreadMarker)); + Assert.True(markerCallstacks.Count > 0, + $"Expected at least one Resume callstack containing {nameof(TaskAsync_SingleThreadMarker)}"); + + // All 3 chain frames must be reconstructable in the deepest callstack — proves the + // inline-cascade walker crosses every level without dropping any. + var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; + var frameNames = deepest.Frames + .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) + .Where(n => n is not null) + .ToList(); + Assert.Contains(nameof(TaskAsync_SingleThreadInner), frameNames); + Assert.Contains(nameof(TaskAsync_SingleThreadMid), frameNames); + Assert.Contains(nameof(TaskAsync_SingleThreadMarker), frameNames); + + // No dispatcher leak: every Create balanced by a Complete on the synchronous cascade path. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); + + // Inline-cascade optimization: exactly 1 Create for the entire chain (the leaf's + // non-box TCS wrapping). A regression that re-introduced per-level wrapping would + // push this higher. This is the same strong invariant as the ConfigureAwait(false) + // test, validated here on the single-threaded code path. + Assert.Equal(1, createCount); + + // Method-level instrumentation: each async method's MoveNext invocation emits a + // Resume/Complete pair (distinct from the dispatcher-level Resume/Complete events + // above). For our 3-method chain, every method must fire at least one Resume and + // at least one Complete. + int methodResumeCount = stream.OfType(AsyncEventID.ResumeAsyncMethod).Count(); + int methodCompleteCount = stream.OfType(AsyncEventID.CompleteAsyncMethod).Count(); + Assert.True(methodResumeCount >= 3, + $"Expected at least 3 ResumeAsyncMethod events (one per chain level), got {methodResumeCount}"); + Assert.True(methodCompleteCount >= 3, + $"Expected at least 3 CompleteAsyncMethod events (one per chain level), got {methodCompleteCount}"); + + // Method-level events should be balanced: every method resume must have a matching + // complete on this synchronous, exception-free path. + Assert.Equal(methodResumeCount, methodCompleteCount); + + // No AppendAsyncCallstack events should be emitted in this scenario. The original + // Resume callstack already contained the full 3-frame chain (Inner -> Mid -> Marker), + // and Marker's parent chain never grew during the synchronous cascade (the test never + // awaits Marker's task before the cascade completes). Any Append here would be a + // regression of the duplicate-emission bug where the entering-box overload of + // Append re-emitted the LastContinuation box that was already in the trace. + int appendCount = stream.OfType(AsyncEventID.AppendAsyncCallstack).Count(); + Assert.Equal(0, appendCount); + } + // --- ConfigureAwait(false) chain test --- // Validates that ConfigureAwait(false) at every level of a chain does NOT break the // dispatcher cascade or cause the box to be wrapped more than once. ConfigureAwait(false) From 053e4ca1e782aaa9eef10d17319b84fe38145bd1 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Thu, 4 Jun 2026 18:14:05 +0200 Subject: [PATCH 12/19] Drop suspend event on asyncv1. --- .../Runtime/CompilerServices/AsyncProfiler.cs | 48 +++++------ .../CompilerServices/AsyncTaskDispatcher.cs | 80 ++++++++++++------- .../AsyncTaskMethodBuilderT.cs | 2 - 3 files changed, 73 insertions(+), 57 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 0b3ec30b77f090..dcf59dcc98fc99 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -701,6 +701,30 @@ private static AsyncThreadContext CreateAsyncThreadContext() internal static partial class CreateAsyncContext { + public static void Create(AsyncTaskDispatcher dispatcher, ref Info info, ulong id) + { + AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); + + SyncPoint.Check(context); + + EventKeywords activeEventKeywords = context.ActiveEventKeywords; + if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) + { + long currentTimestamp = Stopwatch.GetTimestamp(); + if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords)) + { + ResumeAsyncContext.Append(dispatcher, context, currentTimestamp); + } + + if (IsEnabled.CreateAsyncContextEvent(activeEventKeywords)) + { + EmitEvent(context, currentTimestamp, id); + } + } + + AsyncThreadContext.Release(context); + } + public static void Create(ulong id) { Info info = default; @@ -802,30 +826,6 @@ public static void EmitEvent(AsyncThreadContext context, long currentTimestamp, internal static partial class SuspendAsyncContext { - public static void Suspend(AsyncTaskDispatcher dispatcher, ref Info info) - { - AsyncThreadContext context = AsyncThreadContext.Acquire(ref info); - - SyncPoint.Check(context); - - EventKeywords activeEventKeywords = context.ActiveEventKeywords; - if (IsEnabled.AnyAsyncEvents(activeEventKeywords)) - { - long currentTimestamp = Stopwatch.GetTimestamp(); - if (IsEnabled.ResumeAsyncCallstackEvent(activeEventKeywords)) - { - ResumeAsyncContext.Append(dispatcher, context, currentTimestamp); - } - - if (IsEnabled.SuspendAsyncContextEvent(activeEventKeywords)) - { - EmitEvent(context, currentTimestamp); - } - } - - AsyncThreadContext.Release(context); - } - public static void EmitEvent(AsyncThreadContext context, long currentTimestamp) { Serializer.AsyncEventHeader(context, ref context.EventBuffer, currentTimestamp, AsyncEventID.SuspendAsyncContext, 0); diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index 568f196c680486..d8e2ed67cd3498 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -39,21 +39,17 @@ public static bool InstrumentCheckPoint get => AsyncInstrumentation.IsSupported && AsyncInstrumentation.ActiveFlags != AsyncInstrumentation.Flags.Disabled; } - internal static bool IsSuspended => t_current != null && t_current->Dispatcher is { Suspended: true }; - - internal static unsafe AsyncTaskDispatcher? SuspendAsyncContext(AsyncInstrumentation.Flags flags) + internal static unsafe AsyncTaskDispatcher? GetActiveDispatcher() { AsyncTaskDispatcherInfo* current = AsyncTaskDispatcherInfo.t_current; if (current != null && current->Dispatcher is AsyncTaskDispatcher activeDispatcher) { - Debug.Assert(!activeDispatcher.Suspended); - activeDispatcher.Suspended = true; - - if (AsyncInstrumentation.IsEnabled.SuspendAsyncContext(flags)) - { - AsyncProfiler.SuspendAsyncContext.Suspend(activeDispatcher, ref current->AsyncProfilerInfo); - } - + // V1 dispatchers emit only Resume/Complete per MoveNext invocation — no Suspend + // events. Each dispatcher MoveNext is treated as a discrete unit. When a child + // wrapper is created here (parent box yielded), the parent's MoveNext will simply + // emit Complete on return; the child's lifecycle (Resume + Complete) fires later + // when its continuation runs. The logical context spans multiple dispatchers via + // shared contextId, with Resume count == Complete count as the balance invariant. return activeDispatcher; } @@ -121,8 +117,6 @@ internal sealed class AsyncTaskDispatcher : Task, IAsyncStateMac internal IAsyncStateMachineBox? InnerBox => _inner; - internal bool Suspended; - internal Task? LastContinuation; internal bool ReachedLastContinuation; @@ -133,10 +127,10 @@ internal AsyncTaskDispatcher(IAsyncStateMachineBox inner) : base() _contextId = 0; } - internal AsyncTaskDispatcher(IAsyncStateMachineBox inner, AsyncTaskDispatcher suspended) : base() + internal AsyncTaskDispatcher(IAsyncStateMachineBox inner, AsyncTaskDispatcher parent) : base() { _inner = inner; - _contextId = suspended.ContextId; + _contextId = parent.ContextId; } internal ulong ContextId @@ -156,24 +150,44 @@ internal ulong ContextId /// /// Creates a new dispatcher for the given box. If a dispatcher is already active on the - /// current thread (mid-chain yield), marks the current frame as suspended and emit a suspend event. + /// current thread (mid-chain yield), the new dispatcher becomes a child wrapping the box + /// and inherits the active dispatcher's contextId so subsequent events fold into the + /// same logical context. Every dispatcher (root or child) emits a CreateAsyncContext + /// event with its contextId — children share their parent's contextId, so multiple Create + /// events appear for the same logical context. Parsers can reconstruct Suspend semantics + /// via per-contextId refcounting: Complete events while refcount > 0 indicate a dispatcher + /// MoveNext ended but the chain continues; the final Complete (refcount → 0) marks the + /// logical context fully drained. Balance invariant: Create count == Complete count per + /// contextId. + /// + /// When created as a child (parent is mid-MoveNext, about to suspend), the parent + /// dispatcher emits an Append event here — this is the last synchronization point where + /// the chain state is known stable. Once we return and the new child dispatcher is + /// scheduled, other threads may race ahead and walk/mutate state before the parent's + /// Complete-time Append fires. /// - internal static AsyncTaskDispatcher Create(IAsyncStateMachineBox box) + internal static unsafe AsyncTaskDispatcher Create(IAsyncStateMachineBox box) { - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - AsyncTaskDispatcher? activeDispatcher = AsyncTaskDispatcherInfo.SuspendAsyncContext(flags); - if (activeDispatcher != null) - { - return new AsyncTaskDispatcher(box, activeDispatcher); - } + AsyncTaskDispatcherInfo* activeInfo = AsyncTaskDispatcherInfo.t_current; + AsyncTaskDispatcher? activeDispatcher = (activeInfo != null && activeInfo->Dispatcher is AsyncTaskDispatcher d) ? d : null; + AsyncTaskDispatcher dispatcher = activeDispatcher != null + ? new AsyncTaskDispatcher(box, activeDispatcher) + : new AsyncTaskDispatcher(box); - AsyncTaskDispatcher newDispatcher = new AsyncTaskDispatcher(box); - if (AsyncInstrumentation.IsEnabled.CreateAsyncContext(AsyncInstrumentation.SyncActiveFlags())) + AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + if (AsyncInstrumentation.IsEnabled.CreateAsyncContext(flags) || AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags)) { - AsyncProfiler.CreateAsyncContext.Create((ulong)newDispatcher.ContextId); + if (activeDispatcher is not null) + { + AsyncProfiler.CreateAsyncContext.Create(activeDispatcher, ref activeInfo->AsyncProfilerInfo, (ulong)dispatcher.ContextId); + } + else + { + AsyncProfiler.CreateAsyncContext.Create((ulong)dispatcher.ContextId); + } } - return newDispatcher; + return dispatcher; } internal sealed override void ExecuteDirectly(Thread? threadPoolThread) => MoveNext(); @@ -208,15 +222,19 @@ public unsafe void MoveNext() } finally { - if (!Suspended && AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) + // Always emit Complete in V1 — each dispatcher MoveNext is a discrete unit + // emitting one Resume + one Complete. The logical context (shared contextId) + // spans multiple dispatchers; Resume count == Complete count per context. + if (AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) { AsyncProfiler.CompleteAsyncContext.Complete(this, ref dispatcherInfo.AsyncProfilerInfo); } - // If Suspended, the Suspend event was already emitted inline by Create. + // Pop t_current inside finally so the TLS pointer is restored even if + // inner.MoveNext() throws. Otherwise t_current would dangle at a destroyed + // stack frame and pollute subsequent code on this thread. + refCurrent = dispatcherInfo.Next; } - - refCurrent = dispatcherInfo.Next; } public Action MoveNextAction => _moveNextAction ??= MoveNext; 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 9ac98f02fec446..e01e2f8cc8324b 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 @@ -361,8 +361,6 @@ private void MoveNext(Thread? threadPoolThread) { AsyncTaskDispatcherInfo.TryFireResumeAsyncMethod(this, flags); } - - Debug.Assert(!AsyncTaskDispatcherInfo.IsSuspended); } bool loggingOn = TplEventSource.Log.IsEnabled(); From 4850a4fbc824565c0020f06f982750256caf34e1 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Thu, 4 Jun 2026 18:15:06 +0200 Subject: [PATCH 13/19] Major async profiler tests refactory, split into v1 and v2. --- .../AsyncProfilerTests.cs | 2286 ++++------------- .../AsyncProfilerV1Tests.cs | 1606 ++++++------ .../AsyncProfilerV2Tests.cs | 1537 +++++++++++ .../System.Threading.Tasks.Tests.csproj | 1 + 4 files changed, 2720 insertions(+), 2710 deletions(-) create mode 100644 src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV2Tests.cs 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 1ae17aa63313ef..eaaa3f21922174 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 @@ -94,17 +94,16 @@ public partial class AsyncProfilerTests CompleteAsyncContextKeyword | CompleteAsyncMethodKeyword | UnwindAsyncExceptionKeyword; // CoreCLR has StackFrame.GetMethodFromNativeIP (static, non-public). - // NativeAOT lacks that but has an internal StackFrame(IntPtr, bool) constructor; - // we resolve the name via DiagnosticMethodInfo.Create(frame). private static readonly MethodInfo? s_getMethodFromNativeIPMethod = typeof(StackFrame).GetMethod("GetMethodFromNativeIP", BindingFlags.Static | BindingFlags.NonPublic); + // NativeAOT has DiagnosticMethodInfo.Create(StackFrame) (instance, non-public). private static readonly ConstructorInfo? s_stackFrameFromIPCtor = s_getMethodFromNativeIPMethod is null ? typeof(StackFrame).GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, new[] { typeof(IntPtr), typeof(bool) }, null) : null; - internal static string? GetMethodNameFromMethodId(AsyncCallstackType callstackType, ulong methodId) + private static string? GetMethodNameFromMethodId(AsyncCallstackType callstackType, ulong methodId) { if (methodId != 0) { @@ -112,14 +111,14 @@ s_getMethodFromNativeIPMethod is null { if (s_getMethodFromNativeIPMethod is not null) { - var method = (MethodBase?)s_getMethodFromNativeIPMethod.Invoke(null, new object[] { (IntPtr)methodId }); + MethodBase? method = (MethodBase?)s_getMethodFromNativeIPMethod.Invoke(null, new object[] { (IntPtr)methodId }); return method?.Name; } if (s_stackFrameFromIPCtor is not null) { - var frame = (StackFrame)s_stackFrameFromIPCtor.Invoke(new object[] { (IntPtr)methodId, false })!; - var diagInfo = DiagnosticMethodInfo.Create(frame); + StackFrame frame = (StackFrame)s_stackFrameFromIPCtor.Invoke(new object[] { (IntPtr)methodId, false })!; + DiagnosticMethodInfo? diagInfo = DiagnosticMethodInfo.Create(frame); return diagInfo?.Name; } } @@ -148,135 +147,6 @@ s_getMethodFromNativeIPMethod is null return null; } - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SingleAsyncYield() - { - await Task.Yield(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task ChainedAsyncYield() - { - await InnerAsyncYield(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task InnerAsyncYield() - { - await Task.Yield(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task OuterCatches() - { - try - { - await InnerThrows(); - } - catch (InvalidOperationException) - { - } - await Task.Yield(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task InnerThrows() - { - await Task.Yield(); - throw new InvalidOperationException("inner"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task DeepOuterCatches() - { - try - { - await DeepMiddle(); - } - catch (InvalidOperationException) - { - } - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task DeepMiddle() - { - await DeepInnerThrows(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task DeepInnerThrows() - { - await Task.Yield(); - throw new InvalidOperationException("deep inner"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task DeepUnhandledOuter() - { - await DeepUnhandledMiddle(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task DeepUnhandledMiddle() - { - await DeepUnhandledInnerThrows(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task DeepUnhandledInnerThrows() - { - await Task.Yield(); - throw new InvalidOperationException("deep unhandled"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task RecursiveAsyncChain(int depth) - { - if (depth <= 1) - { - await Task.Yield(); - return; - } - await RecursiveAsyncChain(depth - 1); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task WrapperTestA(List<(string MethodName, int WrapperSlot)> captures) - { - await WrapperTestB(captures); - captures.Add((nameof(WrapperTestA), GetCurrentWrapperSlot(nameof(WrapperTestA)))); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task WrapperTestB(List<(string MethodName, int WrapperSlot)> captures) - { - await WrapperTestC(captures); - captures.Add((nameof(WrapperTestB), GetCurrentWrapperSlot(nameof(WrapperTestB)))); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task WrapperTestC(List<(string MethodName, int WrapperSlot)> captures) - { - await Task.Yield(); - captures.Add((nameof(WrapperTestC), GetCurrentWrapperSlot(nameof(WrapperTestC)))); - } - private static TestEventListener CreateListener(EventKeywords keywords) { var listener = new TestEventListener(); @@ -312,52 +182,63 @@ private static int GetCurrentWrapperSlot(string resumedMethodName) string? name = GetFrameMethodName(st.GetFrame(i)); if (name is not null && name.Contains(resumedMethodName)) { - // Scan subsequent frames, skipping unresolvable stubs (e.g. delegate invoke thunks on NativeAOT). for (int j = i + 1; j < st.FrameCount; j++) { string? wrapperName = GetFrameMethodName(st.GetFrame(j)); if (wrapperName is null) + { continue; + } + if (wrapperName.StartsWith(WrapperNamePrefix, StringComparison.Ordinal)) { string wrapperSuffix = wrapperName.Substring(WrapperNamePrefix.Length); return int.TryParse(wrapperSuffix, out int wrapperSlot) ? wrapperSlot : -1; } + break; } + return -1; } } + return -1; } private static string? GetFrameMethodName(StackFrame? frame) { if (frame is null) + { return null; + } + string? name = frame.GetMethod()?.Name; if (name is null) { name = DiagnosticMethodInfo.Create(frame)?.Name; } + return name; } private delegate bool EventVisitor(AsyncEventID eventId, ReadOnlySpan buffer, ref int index); - private delegate bool EventVisitorWithTimestamp(AsyncEventID eventId, long timestamp, ReadOnlySpan buffer, ref int index); - private static void ParseEventBuffer(ReadOnlySpan buffer, EventVisitor visitor) { ParseEventBuffer(buffer, (AsyncEventID eventId, long _, ReadOnlySpan buf, ref int idx) => visitor(eventId, buf, ref idx)); } + private delegate bool EventVisitorWithTimestamp(AsyncEventID eventId, long timestamp, ReadOnlySpan buffer, ref int index); + private static void ParseEventBuffer(ReadOnlySpan buffer, EventVisitorWithTimestamp visitor) { EventBufferHeader? header = ParseEventBufferHeader(buffer); if (header is null) + { return; + } int index = HeaderSize; long baseTimestamp = (long)header.Value.StartTimestamp; @@ -365,7 +246,9 @@ private static void ParseEventBuffer(ReadOnlySpan buffer, EventVisitorWith while (index < buffer.Length) { if (index + 2 > buffer.Length) + { break; + } AsyncEventID eventId = (AsyncEventID)buffer[index++]; @@ -373,7 +256,9 @@ private static void ParseEventBuffer(ReadOnlySpan buffer, EventVisitorWith baseTimestamp += delta; if (!visitor(eventId, baseTimestamp, buffer, ref index)) + { break; + } } } @@ -383,45 +268,59 @@ private static bool SkipEventPayload(AsyncEventID eventId, ReadOnlySpan bu { case AsyncEventID.CreateAsyncContext: case AsyncEventID.ResumeAsyncContext: + { ReadCompressedUInt64(buffer, ref index); return true; + } case AsyncEventID.SuspendAsyncContext: case AsyncEventID.CompleteAsyncContext: case AsyncEventID.ResumeAsyncMethod: case AsyncEventID.CompleteAsyncMethod: case AsyncEventID.ResetAsyncThreadContext: case AsyncEventID.ResetAsyncContinuationWrapperIndex: + { return true; + } case AsyncEventID.AsyncProfilerMetadata: + { SkipMetadataPayload(buffer, ref index); return true; + } case AsyncEventID.AsyncProfilerSyncClock: + { ReadCompressedUInt64(buffer, ref index); // qpcSync ReadCompressedUInt64(buffer, ref index); // utcSync return true; + } case AsyncEventID.UnwindAsyncException: + { ReadCompressedUInt32(buffer, ref index); return true; + } case AsyncEventID.CreateAsyncCallstack: case AsyncEventID.ResumeAsyncCallstack: case AsyncEventID.SuspendAsyncCallstack: case AsyncEventID.AppendAsyncCallstack: + { SkipCallstackPayload(buffer, ref index); return true; + } default: + { return false; + } } } private static uint ReadCompressedUInt32(ReadOnlySpan buffer, ref int index) { - EventBuffer.Deserializer.ReadCompressedUInt32(buffer, ref index, out uint value); + Deserializer.ReadCompressedUInt32(buffer, ref index, out uint value); return value; } private static ulong ReadCompressedUInt64(ReadOnlySpan buffer, ref int index) { - EventBuffer.Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong value); + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong value); return value; } @@ -439,14 +338,16 @@ private static void ReadCallstackPayload(ReadOnlySpan buffer, ref int inde private static void ReadCallstackPayload(ReadOnlySpan buffer, ref int index, out ulong taskId, out AsyncCallstackType callstackType, out byte frameCount, out List<(ulong MethodId, int State)> frames) { - callstackType = (AsyncCallstackType)buffer[index++]; // type - index++; // callstack ID (reserved) + callstackType = (AsyncCallstackType)buffer[index++]; + index++; frameCount = buffer[index++]; taskId = ReadCompressedUInt64(buffer, ref index); frames = new List<(ulong, int)>(frameCount); if (frameCount == 0) + { return; + } ulong currentMethodId = ReadCompressedUInt64(buffer, ref index); int state = ReadCompressedInt32(buffer, ref index); @@ -463,13 +364,13 @@ private static void ReadCallstackPayload(ReadOnlySpan buffer, ref int inde private static int ReadCompressedInt32(ReadOnlySpan buffer, ref int index) { - EventBuffer.Deserializer.ReadCompressedInt32(buffer, ref index, out int value); + Deserializer.ReadCompressedInt32(buffer, ref index, out int value); return value; } private static long ReadCompressedInt64(ReadOnlySpan buffer, ref int index) { - EventBuffer.Deserializer.ReadCompressedInt64(buffer, ref index, out long value); + Deserializer.ReadCompressedInt64(buffer, ref index, out long value); return value; } @@ -495,15 +396,17 @@ private record struct MetadataFromBuffer(ulong QpcFrequency, ulong QpcSync, ulon private static EventBufferHeader? ParseEventBufferHeader(ReadOnlySpan buffer) { if (buffer.Length < HeaderSize || buffer[0] != 1) + { return null; + } int index = 1; - EventBuffer.Deserializer.ReadUInt32(buffer, ref index, out uint totalSize); - EventBuffer.Deserializer.ReadUInt32(buffer, ref index, out uint contextId); - EventBuffer.Deserializer.ReadUInt64(buffer, ref index, out ulong threadId); - EventBuffer.Deserializer.ReadUInt32(buffer, ref index, out uint eventCount); - EventBuffer.Deserializer.ReadUInt64(buffer, ref index, out ulong startTs); - EventBuffer.Deserializer.ReadUInt64(buffer, ref index, out ulong endTs); + Deserializer.ReadUInt32(buffer, ref index, out uint totalSize); + Deserializer.ReadUInt32(buffer, ref index, out uint contextId); + Deserializer.ReadUInt64(buffer, ref index, out ulong threadId); + Deserializer.ReadUInt32(buffer, ref index, out uint eventCount); + Deserializer.ReadUInt64(buffer, ref index, out ulong startTs); + Deserializer.ReadUInt64(buffer, ref index, out ulong endTs); return new EventBufferHeader(buffer[0], totalSize, contextId, threadId, eventCount, startTs, endTs); } @@ -514,42 +417,35 @@ private sealed class ParsedEvent public long Timestamp { get; init; } public ulong OsThreadId { get; init; } - /// - /// The Task.Id associated with this event. For context events (Create/Resume), - /// this is the payload ID. For callstack events, this is the ID from the callstack - /// header. For other events, this is the active task ID at the time of emission. - /// public ulong TaskId { get; init; } - // Callstack events (Create/Resume/Suspend): frames public AsyncCallstackType CallstackType { get; init; } public byte FrameCount { get; init; } public List<(ulong MethodId, int State)> Frames { get; init; } = []; - // UnwindAsyncException: frame count unwound public uint UnwindFrameCount { get; init; } - // Metadata public MetadataFromBuffer? Metadata { get; init; } - // SyncClock public ulong SyncClockQpc { get; init; } public ulong SyncClockUtc { get; init; } - /// - /// Returns true if any frame in this event's callstack resolves to a method - /// whose name contains the specified marker string. - /// public bool HasMarkerFrame(string markerMethodName) { if (Frames.Count == 0) + { return false; + } + foreach (var (methodId, _) in Frames) { - var methodName = GetMethodNameFromMethodId(CallstackType, methodId); + string? methodName = GetMethodNameFromMethodId(CallstackType, methodId); if (methodName is not null && methodName.Contains(markerMethodName, StringComparison.Ordinal)) + { return true; + } } + return false; } } @@ -565,151 +461,160 @@ public ParsedEventStream(List events) _events.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp)); } - /// All events in timestamp order. + // All events in timestamp order. public IReadOnlyList All => _events; - /// All distinct event IDs present in the stream. + // All distinct event IDs present in the stream. public IEnumerable EventIds => _events.Select(e => e.EventId).Distinct(); - /// Filter events by event ID, in timestamp order. + // Filter events by event ID, in timestamp order. public IEnumerable OfType(AsyncEventID eventId) => _events.Where(e => e.EventId == eventId); - /// Filter events by multiple event IDs, in timestamp order. + // Filter events by multiple event IDs, in timestamp order. public IEnumerable OfTypes(params AsyncEventID[] eventIds) { var set = new HashSet(eventIds); return _events.Where(e => set.Contains(e.EventId)); } - /// Get events grouped by Task.Id, each group in timestamp order. + // Get events grouped by Task.Id, each group in timestamp order. public Dictionary> ByTaskId() { if (_byTaskId is not null) + { return _byTaskId; + } _byTaskId = new Dictionary>(); foreach (var evt in _events) { if (evt.TaskId == 0) + { continue; + } + if (!_byTaskId.TryGetValue(evt.TaskId, out var list)) { list = new List(); _byTaskId[evt.TaskId] = list; } + list.Add(evt); } + return _byTaskId; } - /// Get events for a specific Task.Id in timestamp order. + // Get events for a specific Task.Id in timestamp order. public List ForTask(ulong taskId) => ByTaskId().TryGetValue(taskId, out var list) ? list : new List(); - /// - /// Get callstack events (of specified type) that contain the marker method in their frames. - /// Results are in timestamp order. - /// + // Get callstack events (of specified type) that contain the marker method in their frames. + // Results are in timestamp order. public List CallstacksWithMarker(AsyncEventID callstackEventId, string markerMethodName) => _events.Where(e => e.EventId == callstackEventId && e.HasMarkerFrame(markerMethodName)).ToList(); - /// - /// Reconstructs full resume callstacks by merging each ResumeAsyncCallstack with any subsequent - /// AppendAsyncCallstack events for the same context, up until the next Suspend/Complete on that - /// context. - /// + // Reconstructs full resume callstacks by merging each ResumeAsyncCallstack with any subsequent + // AppendAsyncCallstack events for the same context, up until the next Suspend/Complete on that + // context. + // /// V1 dispatchers may emit a partial Resume callstack when the parent continuation hasn't yet /// registered (race between dispatcher pickup and parent's AwaitUnsafeOnCompleted). Frames that /// register later are emitted as AppendAsyncCallstack at the next hook point. Merging produces /// the complete chain that was observable during the dispatcher's lifetime. /// /// Returns one ParsedEvent per Resume, with Frames and FrameCount reflecting the merged total. - /// public List MergedResumeCallstacks() { var result = new List(); - var openByTaskId = new Dictionary(); // taskId → index into result + var openByTaskId = new Dictionary(); foreach (var evt in _events) { switch (evt.EventId) { case AsyncEventID.ResumeAsyncCallstack: + { + var merged = new ParsedEvent { - var merged = new ParsedEvent - { - EventId = evt.EventId, - Timestamp = evt.Timestamp, - OsThreadId = evt.OsThreadId, - TaskId = evt.TaskId, - CallstackType = evt.CallstackType, - FrameCount = evt.FrameCount, - Frames = new List<(ulong MethodId, int State)>(evt.Frames), - }; - openByTaskId[evt.TaskId] = result.Count; - result.Add(merged); - break; - } + EventId = evt.EventId, + Timestamp = evt.Timestamp, + OsThreadId = evt.OsThreadId, + TaskId = evt.TaskId, + CallstackType = evt.CallstackType, + FrameCount = evt.FrameCount, + Frames = new List<(ulong MethodId, int State)>(evt.Frames), + }; + + openByTaskId[evt.TaskId] = result.Count; + result.Add(merged); + + break; + } case AsyncEventID.AppendAsyncCallstack: + { + if (openByTaskId.TryGetValue(evt.TaskId, out int idx)) { - if (openByTaskId.TryGetValue(evt.TaskId, out int idx)) + ParsedEvent existing = result[idx]; + var combinedFrames = new List<(ulong MethodId, int State)>(existing.Frames); + combinedFrames.AddRange(evt.Frames); + + result[idx] = new ParsedEvent { - var existing = result[idx]; - var combinedFrames = new List<(ulong MethodId, int State)>(existing.Frames); - combinedFrames.AddRange(evt.Frames); - result[idx] = new ParsedEvent - { - EventId = existing.EventId, - Timestamp = existing.Timestamp, - OsThreadId = existing.OsThreadId, - TaskId = existing.TaskId, - CallstackType = existing.CallstackType, - FrameCount = (byte)Math.Min(combinedFrames.Count, byte.MaxValue), - Frames = combinedFrames, - }; - } - break; + EventId = existing.EventId, + Timestamp = existing.Timestamp, + OsThreadId = existing.OsThreadId, + TaskId = existing.TaskId, + CallstackType = existing.CallstackType, + FrameCount = (byte)Math.Min(combinedFrames.Count, byte.MaxValue), + Frames = combinedFrames, + }; } + + break; + } case AsyncEventID.SuspendAsyncContext: case AsyncEventID.CompleteAsyncContext: + { openByTaskId.Remove(evt.TaskId); break; + } } } return result; } - /// - /// Get merged resume callstacks (Resume + subsequent Appends) that contain the marker method - /// in any of their merged frames. - /// + // Get merged resume callstacks (Resume + subsequent Appends) that contain the marker method + // in any of their merged frames. public List MergedResumeCallstacksWithMarker(string markerMethodName) => MergedResumeCallstacks().Where(e => e.HasMarkerFrame(markerMethodName)).ToList(); - /// - /// Get callstack events (of specified type) that contain the marker method, - /// taking only the first match per Task.Id (deepest chain by timestamp). - /// + // Get callstack events (of specified type) that contain the marker method, + // taking only the first match per Task.Id (deepest chain by timestamp). public List CallstacksWithMarkerFirstPerTask(AsyncEventID callstackEventId, string markerMethodName) { - var matched = CallstacksWithMarker(callstackEventId, markerMethodName); + List matched = CallstacksWithMarker(callstackEventId, markerMethodName); var seen = new HashSet(); var result = new List(); + foreach (var evt in matched) { if (evt.TaskId != 0 && seen.Add(evt.TaskId)) + { result.Add(evt); + } } + return result; } - /// Get all metadata events. + // Get all metadata events. public List MetadataEvents => _events.Where(e => e.Metadata.HasValue).Select(e => e.Metadata!.Value).ToList(); - /// Get distinct OS thread IDs across all events. + // Get distinct OS thread IDs across all events. public HashSet OsThreadIds => new(_events.Select(e => e.OsThreadId).Where(id => id != 0)); } @@ -721,7 +626,9 @@ private static ParsedEventStream ParseAllEvents(CollectedEvents events) { EventBufferHeader? header = ParseEventBufferHeader(buffer); if (header is null) + { return; + } ulong osThreadId = header.Value.OsThreadId; ulong currentTaskId = 0; @@ -732,7 +639,9 @@ private static ParsedEventStream ParseAllEvents(CollectedEvents events) while (index < buffer.Length) { if (index + 2 > buffer.Length) + { break; + } AsyncEventID eventId = (AsyncEventID)buffer[index++]; long delta = (long)ReadCompressedUInt64(buffer, ref index); @@ -786,8 +695,12 @@ static ParsedEvent ParseContextEvent(AsyncEventID eventId, long timestamp, ulong { ulong id = ReadCompressedUInt64(buffer, ref index); if (eventId == AsyncEventID.ResumeAsyncContext && id != currentTaskId) + { taskIdStack.Push(currentTaskId); + } + currentTaskId = id; + return new ParsedEvent { EventId = eventId, @@ -802,6 +715,7 @@ static ParsedEvent ParseCompleteContextEvent(long timestamp, ulong osThreadId, { ulong completedTaskId = currentTaskId; currentTaskId = taskIdStack.Count > 0 ? taskIdStack.Pop() : 0; + return new ParsedEvent { EventId = AsyncEventID.CompleteAsyncContext, @@ -815,7 +729,10 @@ static ParsedEvent ParseResetEvent(AsyncEventID eventId, long timestamp, ulong o { ulong prevTaskId = currentTaskId; if (eventId == AsyncEventID.ResetAsyncThreadContext) + { currentTaskId = 0; + } + return new ParsedEvent { EventId = eventId, @@ -834,6 +751,7 @@ static ParsedEvent ParseCallstackEvent(AsyncEventID eventId, long timestamp, ulo taskIdStack.Push(currentTaskId); currentTaskId = taskId; } + return new ParsedEvent { EventId = eventId, @@ -850,6 +768,7 @@ static ParsedEvent ParseUnwindEvent(long timestamp, ulong osThreadId, ulong curr ReadOnlySpan buffer, ref int index) { uint unwindCount = ReadCompressedUInt32(buffer, ref index); + return new ParsedEvent { EventId = AsyncEventID.UnwindAsyncException, @@ -864,6 +783,7 @@ static ParsedEvent ParseMetadataEvent(long timestamp, ulong osThreadId, ulong cu ReadOnlySpan buffer, ref int index) { ReadMetadataPayload(buffer, ref index, out ulong freq, out ulong qpcSync, out ulong utcSync, out uint bufSize, out byte wrapperCount); + return new ParsedEvent { EventId = AsyncEventID.AsyncProfilerMetadata, @@ -879,6 +799,7 @@ static ParsedEvent ParseSyncClockEvent(long timestamp, ulong osThreadId, ulong c { ulong qpcSync = ReadCompressedUInt64(buffer, ref index); ulong utcSync = ReadCompressedUInt64(buffer, ref index); + return new ParsedEvent { EventId = AsyncEventID.AsyncProfilerSyncClock, @@ -894,6 +815,7 @@ static ParsedEvent ParseUnknownEvent(AsyncEventID eventId, long timestamp, ulong ulong currentTaskId, ReadOnlySpan buffer, ref int index) { SkipEventPayload(eventId, buffer, ref index); + return new ParsedEvent { EventId = eventId, @@ -925,7 +847,7 @@ private static void ForEachEventBufferPayload(ConcurrentQueue EventBuffer.OutputEventBuffer(buffer)); + EventBuffer.DumpAllEvents(events); } private static void RunScenarioAndFlush(Func scenario) @@ -935,6 +857,13 @@ private static void RunScenarioAndFlush(Func scenario) // unblocks this thread. Brief sleep ensures the pool thread's finally completes. // V2 (runtime-async) does not have this issue — Complete fires inside the dispatch // loop before the task is signaled. + // + // Clear SynchronizationContext so RuntimeAsync continuations don't capture + // xunit's context, which would cause per-frame re-queuing instead of inlining. + SynchronizationContext? prevCtx = SynchronizationContext.Current; + int originalThreadId = Environment.CurrentManagedThreadId; + SynchronizationContext.SetSynchronizationContext(null); + try { Task.Run(scenario).GetAwaiter().GetResult(); @@ -942,6 +871,15 @@ private static void RunScenarioAndFlush(Func scenario) finally { Thread.Sleep(50); + + // Only restore the SynchronizationContext if we're still on the same thread. + // ConfigureAwait(false) may resume on a different thread pool thread, and + // setting the original thread's context there would be incorrect. + if (Environment.CurrentManagedThreadId == originalThreadId) + { + SynchronizationContext.SetSynchronizationContext(prevCtx); + } + SendFlushCommand(); } } @@ -965,11 +903,13 @@ await listener.RunWithCallbackAsync(result.Events.Enqueue, async () => { SendFlushCommand(); result.Events.Clear(); + // Clear SynchronizationContext so RuntimeAsync continuations don't capture // xunit's context, which would cause per-frame re-queuing instead of inlining. - var prevCtx = SynchronizationContext.Current; + SynchronizationContext? prevCtx = SynchronizationContext.Current; int originalThreadId = Environment.CurrentManagedThreadId; SynchronizationContext.SetSynchronizationContext(null); + try { await scenario().ConfigureAwait(false); @@ -983,8 +923,12 @@ await listener.RunWithCallbackAsync(result.Events.Enqueue, async () => { SynchronizationContext.SetSynchronizationContext(prevCtx); } + + // Post-flush inside finally so buffered events from before an exception + // still reach the listener (otherwise on a scenario throw the trace would + // be truncated, hiding what happened up to the failure point). + SendFlushCommand(); } - SendFlushCommand(); }).ConfigureAwait(false); } return result; @@ -1004,16 +948,14 @@ private static CollectedEvents CollectEvents(EventKeywords keywords, Action - /// Returns true if any callstack event contains all expected method names as frames, - /// appearing in the given order (index 0 = innermost/deepest frame). - /// private static bool HasCallstackWithExpectedFrames(List callstacks, string[] expectedFrames) { foreach (var cs in callstacks) @@ -1026,23 +968,24 @@ private static bool HasCallstackWithExpectedFrames(List callstacks, for (int i = 0; i < resolvedNames.Count && matchIndex < expectedFrames.Length; i++) { if (resolvedNames[i] is not null && resolvedNames[i]!.Contains(expectedFrames[matchIndex], StringComparison.Ordinal)) + { matchIndex++; + } } if (matchIndex == expectedFrames.Length) + { return true; + } } return false; } - /// - /// For a given context, simulates the async callstack depth by walking events in order: - /// ResumeAsyncCallstack sets the depth to frame count, CompleteAsyncMethod decrements, - /// UnwindAsyncException subtracts unwound frames. Asserts depth reaches zero. - /// + // For a given context, simulates the async callstack depth by walking events in order: + // ResumeAsyncCallstack sets the depth to frame count, CompleteAsyncMethod decrements, + // UnwindAsyncException subtracts unwound frames. Asserts depth reaches zero. private static void AssertCallstackSimulationReachesZero(ParsedEventStream stream, string markerMethodName) { - // Find context ID via marker on a resume callstack var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, markerMethodName); Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{markerMethodName}'"); @@ -1056,1740 +999,375 @@ private static void AssertCallstackSimulationReachesZero(ParsedEventStream strea switch (evt.EventId) { case AsyncEventID.ResumeAsyncCallstack: + { stackDepth = (int)evt.FrameCount; break; + } case AsyncEventID.CompleteAsyncMethod: + { if (stackDepth > 0) + { stackDepth--; + } + break; + } case AsyncEventID.UnwindAsyncException: + { stackDepth = Math.Max(0, stackDepth - (int)evt.UnwindFrameCount); break; + } } } Assert.Equal(0, stackDepth); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_EventBufferHeaderFormat() + private static class Deserializer { - var events = await CollectEventsAsync(CoreKeywords, SingleAsyncYield); + public static void ReadInt32(ReadOnlySpan buffer, ref int index, out int value) + { + uint uValue; + ReadUInt32(buffer, ref index, out uValue); + value = (int)uValue; + } - // DumpAllEvents(events); + public static void ReadCompressedInt32(ReadOnlySpan buffer, ref int index, out int value) + { + uint uValue; + ReadCompressedUInt32(buffer, ref index, out uValue); + value = ZigzagDecodeInt32(uValue); + } - int buffersChecked = 0; - ForEachEventBufferPayload(events, buffer => + public static void ReadUInt32(ReadOnlySpan buffer, ref int index, out uint value) { - EventBufferHeader? parsed = ParseEventBufferHeader(buffer); - Assert.NotNull(parsed); - EventBufferHeader header = parsed.Value; + value = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(index)); + index += 4; + } - Assert.Equal(1, header.Version); - Assert.Equal((uint)buffer.Length, header.TotalSize); - Assert.True(header.AsyncThreadContextId > 0, "Async thread context ID should be positive"); - Assert.True(header.OsThreadId != 0, "OS thread ID should be non-zero"); - Assert.True(header.StartTimestamp > 0, "Start timestamp should be positive"); - Assert.True(header.EndTimestamp >= header.StartTimestamp, $"End timestamp ({header.EndTimestamp}) should be >= start timestamp ({header.StartTimestamp})"); + public static void ReadCompressedUInt32(ReadOnlySpan buffer, ref int index, out uint value) + { + int shift = 0; + byte b; - int eventCount = 0; - ParseEventBuffer(buffer, (AsyncEventID eventId, ReadOnlySpan buf, ref int idx) => + value = 0; + do { - eventCount++; - return SkipEventPayload(eventId, buf, ref idx); - }); + b = buffer[index++]; + value |= (uint)(b & 0x7F) << shift; + shift += 7; + } while ((b & 0x80) != 0); + } - Assert.Equal(header.EventCount, (uint)eventCount); - Assert.True(header.EventCount > 0, "Expected at least one event in buffer"); + public static void ReadInt64(ReadOnlySpan buffer, ref int index, out long value) + { + ulong uValue; + ReadUInt64(buffer, ref index, out uValue); + value = (long)uValue; + } - buffersChecked++; - }); + public static void ReadCompressedInt64(ReadOnlySpan buffer, ref int index, out long value) + { + ulong uValue; + ReadCompressedUInt64(buffer, ref index, out uValue); + value = ZigzagDecodeInt64(uValue); + } - Assert.True(buffersChecked > 0, "Expected at least one buffer"); - } + public static void ReadUInt64(ReadOnlySpan buffer, ref int index, out ulong value) + { + value = BinaryPrimitives.ReadUInt64LittleEndian(buffer.Slice(index)); + index += 8; + } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_EventsEmitted() - { - var events = await CollectEventsAsync(AllKeywords, SingleAsyncYield); + public static void ReadCompressedUInt64(ReadOnlySpan buffer, ref int index, out ulong value) + { + int shift = 0; + byte b; - // DumpAllEvents(events); + value = 0; + do + { + b = buffer[index++]; + value |= (ulong)(b & 0x7F) << shift; + shift += 7; + } while ((b & 0x80) != 0); + } - Assert.True(events.Events.Count > 0, "Expected at least one AsyncEvents event to be emitted"); - Assert.Contains(events.Events, e => e.EventId == AsyncEventsId); - } + private static int ZigzagDecodeInt32(uint value) => (int)((value >> 1) ^ (~(value & 1) + 1)); - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendResumeCompleteMarker() - { - await Task.Yield(); - await SingleAsyncYield(); + private static long ZigzagDecodeInt64(ulong value) => (long)((value >> 1) ^ (~(value & 1) + 1)); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_SuspendResumeCompleteEvents() + private static class EventBuffer { - var events = await CollectEventsAsync(CallstackKeywords, SuspendResumeCompleteMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - - // Find our context via marker callstack. - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(SuspendResumeCompleteMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with SuspendResumeCompleteMarker"); - - ulong taskId = markerCallstacks[0].TaskId; - var taskEvts = stream.ForTask(taskId); - var ids = taskEvts.Select(e => e.EventId).ToList(); + public static void DumpAllEvents(CollectedEvents events) + { + ForEachEventBufferPayload(events.Events, buffer => EventBuffer.OutputEventBuffer(buffer)); + OutputFooter(); + } - Assert.Contains(AsyncEventID.ResumeAsyncContext, ids); - Assert.Contains(AsyncEventID.SuspendAsyncContext, ids); - Assert.Contains(AsyncEventID.CompleteAsyncContext, ids); - } + private static int OutputEventBuffer(ReadOnlySpan buffer) + { + OutputHeader("Async Event Buffer"); + int index = 0; - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task ContextLifecycleMarker() - { - await Task.Yield(); - await SingleAsyncYield(); - } + if ((uint)buffer.Length < 1) + { + Console.WriteLine("Buffer too small."); + OutputFooter(); + return index; + } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_ContextEventIdLifecycle() - { - var events = await CollectEventsAsync(CallstackKeywords, ContextLifecycleMarker); + byte version = buffer[index++]; + Console.WriteLine($"Version: {version}"); - // DumpAllEvents(events); + if (version != 1) + { + Console.WriteLine($"Unsupported version: {version}"); + OutputFooter(); + return index; + } - var stream = ParseAllEvents(events); + Deserializer.ReadUInt32(buffer, ref index, out uint totalSize); + Deserializer.ReadUInt32(buffer, ref index, out uint contextId); + Deserializer.ReadUInt64(buffer, ref index, out ulong osThreadId); + Deserializer.ReadUInt32(buffer, ref index, out uint totalEventCount); + Deserializer.ReadUInt64(buffer, ref index, out ulong startTimestamp); + Deserializer.ReadUInt64(buffer, ref index, out ulong endTimestamp); - // Find events in the context that contains our marker method. - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ContextLifecycleMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ContextLifecycleMarker"); + Console.WriteLine($"TotalSize: {totalSize}"); + Console.WriteLine($"AsyncThreadContextId: {contextId}"); + Console.WriteLine($"OSThreadId: {osThreadId}"); + Console.WriteLine($"TotalEventCount: {totalEventCount}"); + Console.WriteLine($"StartTimestamp: 0x{startTimestamp:X16}"); + Console.WriteLine($"EndTimestamp: 0x{endTimestamp:X16}"); - ulong taskId = markerCallstacks[0].TaskId; - Assert.True(taskId > 0, "Context ID should be non-zero"); + int eventCount = 0; + ulong currentTimestamp = startTimestamp; - var taskEvts = stream.ForTask(taskId); - var ids = taskEvts.Select(e => e.EventId).ToList(); - - int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); - int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext); - Assert.True(createIdx >= 0, "Expected CreateAsyncContext in context events"); - Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after CreateAsyncContext"); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_ResumeCompleteMethodEvents() - { - var events = await CollectEventsAsync(MethodKeywords, ChainedAsyncYield); - - // DumpAllEvents(events); - - var ids = ParseAllEvents(events).EventIds; - - Assert.Contains(AsyncEventID.ResumeAsyncMethod, ids); - Assert.Contains(AsyncEventID.CompleteAsyncMethod, ids); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task EventSequenceOrderMarker() - { - await Task.Yield(); - await SingleAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_EventSequenceOrder() - { - var events = await CollectEventsAsync(CallstackKeywords, EventSequenceOrderMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - - // Find our context via marker callstack. - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(EventSequenceOrderMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with EventSequenceOrderMarker"); - - ulong taskId = markerCallstacks[0].TaskId; - var taskEvts = stream.ForTask(taskId); - var ids = taskEvts.Select(e => e.EventId).ToList(); - - // Verify the expected lifecycle sequence exists in order. - int resumeIdx1 = ids.IndexOf(AsyncEventID.ResumeAsyncContext); - Assert.True(resumeIdx1 >= 0, "Expected first ResumeAsyncContext"); - - int suspendIdx = ids.IndexOf(AsyncEventID.SuspendAsyncContext, resumeIdx1 + 1); - Assert.True(suspendIdx > resumeIdx1, "Expected SuspendAsyncContext after first Resume"); - - int resumeIdx2 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, suspendIdx + 1); - Assert.True(resumeIdx2 > suspendIdx, "Expected second ResumeAsyncContext after Suspend"); - - int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx2 + 1); - Assert.True(completeIdx > resumeIdx2, "Expected CompleteAsyncContext after second Resume"); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CreateAsyncContextEmittedOnFirstAwait() - { - var events = await CollectEventsAsync(CreateAsyncContextKeyword | CompleteAsyncContextKeyword, SingleAsyncYield); - - // DumpAllEvents(events); - - var ids = ParseAllEvents(events).EventIds; - Assert.Contains(AsyncEventID.CreateAsyncContext, ids); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreateCallstackMarker() - { - await SingleAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait() - { - var events = await CollectEventsAsync(CallstackKeywords, CreateCallstackMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateCallstackMarker)); - - Assert.NotEmpty(createCallstacks); - Assert.All(createCallstacks, cs => - { - Assert.True(cs.FrameCount > 0, "Expected at least one frame in create callstack"); - Assert.True(cs.TaskId != 0, "Expected non-zero task ID in create callstack"); - Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); - }); - } - - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreateCallstackDepthMarker() - { - await ChainedAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CreateCallstackDepthMatchesChain() - { - var events = await CollectEventsAsync(CallstackKeywords, CreateCallstackDepthMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateCallstackDepthMarker)); - - // The expected [NoInlining] frames in order (innermost first): - // InnerAsyncYield -> ChainedAsyncYield -> CreateCallstackDepthMarker - Assert.NotEmpty(createCallstacks); - string[] expectedFrames = [nameof(InnerAsyncYield), nameof(ChainedAsyncYield), nameof(CreateCallstackDepthMarker)]; - Assert.True( - HasCallstackWithExpectedFrames(createCallstacks, expectedFrames), - $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendCallstackMarker() - { - await Task.Yield(); - await SingleAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait() - { - var events = await CollectEventsAsync(CallstackKeywords, SuspendCallstackMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendCallstackMarker)); - - Assert.NotEmpty(suspendCallstacks); - Assert.All(suspendCallstacks, cs => - { - Assert.True(cs.FrameCount > 0, "Expected at least one frame in suspend callstack"); - Assert.True(cs.TaskId != 0, "Expected non-zero task ID in suspend callstack"); - Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); - }); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendDepthMarker() - { - await Task.Yield(); - await ChainedAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_SuspendCallstackDepthMatchesChain() - { - var events = await CollectEventsAsync(CallstackKeywords, SuspendDepthMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendDepthMarker)); - - // The expected [NoInlining] frames in order (innermost first): - // InnerAsyncYield -> ChainedAsyncYield -> SuspendDepthMarker - Assert.NotEmpty(suspendCallstacks); - string[] expectedFrames = [nameof(InnerAsyncYield), nameof(ChainedAsyncYield), nameof(SuspendDepthMarker)]; - Assert.True( - HasCallstackWithExpectedFrames(suspendCallstacks, expectedFrames), - $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendPrecedesCompleteMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_SuspendCallstackPrecedesComplete() - { - var events = await CollectEventsAsync(CallstackKeywords, SuspendPrecedesCompleteMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - - // Find the suspend callstack via marker to get the context ID - var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendPrecedesCompleteMarker)); - Assert.True(suspendStacks.Count >= 1, $"Expected at least one suspend callstack with marker, got {suspendStacks.Count}"); - - ulong taskId = suspendStacks[0].TaskId; - Assert.True(taskId > 0, "Expected non-zero context ID"); - - var taskEvts = stream.ForTask(taskId); - var ids = taskEvts.Select(e => e.EventId).ToList(); - - int suspendIdx = ids.IndexOf(AsyncEventID.SuspendAsyncCallstack); - int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext); - - Assert.True(suspendIdx >= 0, "Expected SuspendAsyncCallstack in context events"); - Assert.True(completeIdx >= 0, "Expected CompleteAsyncContext in context events"); - Assert.True(suspendIdx < completeIdx, $"SuspendAsyncCallstack (index {suspendIdx}) should precede CompleteAsyncContext (index {completeIdx})"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendDeeperMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_SuspendCallstackDeeperThanInitialResume() - { - var events = await CollectEventsAsync(CallstackKeywords, SuspendDeeperMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(SuspendDeeperMarker)); - var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendDeeperMarker)); - - Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker, got {resumeStacks.Count}"); - Assert.True(suspendStacks.Count >= 1, $"Expected at least one suspend callstack with marker, got {suspendStacks.Count}"); - - // First resume (after initial Yield) should be shallow, first suspend (InnerAsyncYield's Yield) should be deeper - var firstResume = resumeStacks[0]; - var firstSuspend = suspendStacks[0]; - - Assert.True(firstSuspend.FrameCount > firstResume.FrameCount, $"First suspend callstack depth ({firstSuspend.FrameCount}) should be deeper than first resume callstack depth ({firstResume.FrameCount})"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreatePrecedesResumeMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CreateCallstackPrecedesResumeCallstack() - { - var events = await CollectEventsAsync(CallstackKeywords, CreatePrecedesResumeMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreatePrecedesResumeMarker)); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CreatePrecedesResumeMarker)); - - Assert.NotEmpty(createStacks); - Assert.NotEmpty(resumeStacks); - - // For each task that has both Create and Resume callstacks, verify Create timestamp precedes Resume. - int matchedPairs = 0; - foreach (var create in createStacks) - { - var matchingResume = resumeStacks.FirstOrDefault(r => r.TaskId == create.TaskId); - if (matchingResume is null) - continue; - - matchedPairs++; - Assert.True(create.Timestamp <= matchingResume.Timestamp, $"For task {create.TaskId}: CreateAsyncCallstack (ts {create.Timestamp}) should precede ResumeAsyncCallstack (ts {matchingResume.Timestamp})"); - } - - Assert.True(matchedPairs >= 1, $"Expected at least one matching Create/Resume callstack pair, but found {matchedPairs}"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreateResumeMatchMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CreateAndFirstResumeCallstacksMatch() - { - var events = await CollectEventsAsync(CallstackKeywords, CreateResumeMatchMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateResumeMatchMarker)); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CreateResumeMatchMarker)); - - Assert.NotEmpty(createStacks); - Assert.NotEmpty(resumeStacks); - - // For each create callstack, find the first resume with the same task ID and verify frames match. - int matchedPairs = 0; - foreach (var create in createStacks) - { - var matchingResume = resumeStacks.FirstOrDefault(r => r.TaskId == create.TaskId); - if (matchingResume is null) - continue; - - matchedPairs++; - Assert.Equal(create.Frames.Count, matchingResume.Frames.Count); - for (int i = 0; i < create.Frames.Count; i++) - { - Assert.Equal(create.Frames[i].MethodId, matchingResume.Frames[i].MethodId); - } - } - - Assert.True(matchedPairs >= 1, $"Expected at least one matching Create/Resume callstack pair, but found {matchedPairs}"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CallstackOnResumeMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CallstackEmittedOnResume() - { - var events = await CollectEventsAsync(CallstackKeywords, CallstackOnResumeMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackOnResumeMarker)); - - Assert.NotEmpty(callstacks); - Assert.All(callstacks, cs => - { - Assert.True(cs.FrameCount > 0, "Expected at least one frame in callstack"); - Assert.True(cs.TaskId != 0, "Expected non-zero task ID in resume callstack"); - Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); - }); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CallstackDepthMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CallstackDepthMatchesChain() - { - var events = await CollectEventsAsync(CallstackKeywords, CallstackDepthMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackDepthMarker)); - - // The expected [NoInlining] frames in order (innermost first): - // InnerAsyncYield -> CallstackDepthMarker - Assert.NotEmpty(callstacks); - string[] expectedFrames = [nameof(InnerAsyncYield), nameof(CallstackDepthMarker)]; - Assert.True( - HasCallstackWithExpectedFrames(callstacks, expectedFrames), - $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationNormalMarker() - { - await Task.Yield(); - await InnerAsyncYield(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CallstackSimulation_NormalCompletion() - { - var events = await CollectEventsAsync(CallstackKeywords, SimulationNormalMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - AssertCallstackSimulationReachesZero(stream, nameof(SimulationNormalMarker)); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationHandledMarker() - { - await DeepOuterCatches(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CallstackSimulation_HandledException() - { - var events = await CollectEventsAsync(CallstackKeywords, SimulationHandledMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - AssertCallstackSimulationReachesZero(stream, nameof(SimulationHandledMarker)); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationUnhandledMarker() - { - await DeepUnhandledOuter(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationUnhandledMarkerCatcher() - { - try - { - await SimulationUnhandledMarker(); - } - catch (InvalidOperationException) - { - } - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CallstackSimulation_UnhandledException() - { - var events = await CollectEventsAsync(CallstackKeywords, SimulationUnhandledMarkerCatcher); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - AssertCallstackSimulationReachesZero(stream, nameof(SimulationUnhandledMarker)); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task UnhandledUnwindMarker() - { - await DeepUnhandledOuter(); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task UnhandledUnwindCatcher() - { - try - { - await UnhandledUnwindMarker(); - } - catch (InvalidOperationException) - { - } - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_UnhandledExceptionUnwind() - { - var events = await CollectEventsAsync(CallstackKeywords, UnhandledUnwindCatcher); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(UnhandledUnwindMarker)); - Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{nameof(UnhandledUnwindMarker)}'"); - - ulong taskId = resumeStacks[0].TaskId; - - var taskEvts = stream.ForTask(taskId); - var eventIds = taskEvts.Select(e => e.EventId).ToList(); - - Assert.Contains(AsyncEventID.ResumeAsyncContext, eventIds); - Assert.Contains(AsyncEventID.UnwindAsyncException, eventIds); - Assert.Contains(AsyncEventID.CompleteAsyncContext, eventIds); - - // Verify unwind frame count for this task - // UnhandledUnwindMarker -> DeepUnhandledOuter -> DeepUnhandledMiddle -> DeepUnhandledInnerThrows, 4 frames deep after the initial resume. - var unwindEvents = taskEvts.Where(e => e.EventId == AsyncEventID.UnwindAsyncException).ToList(); - Assert.NotEmpty(unwindEvents); - Assert.All(unwindEvents, e => Assert.Equal(4u, e.UnwindFrameCount)); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task HandledUnwindMarker() - { - await DeepOuterCatches(); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_HandledExceptionUnwind() - { - var events = await CollectEventsAsync(CallstackKeywords, HandledUnwindMarker); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(HandledUnwindMarker)); - Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{nameof(HandledUnwindMarker)}'"); - - ulong taskId = resumeStacks[0].TaskId; - - var taskEvts = stream.ForTask(taskId); - var eventIds = taskEvts.Select(e => e.EventId).ToList(); - - Assert.Contains(AsyncEventID.ResumeAsyncContext, eventIds); - Assert.Contains(AsyncEventID.UnwindAsyncException, eventIds); - Assert.Contains(AsyncEventID.CompleteAsyncContext, eventIds); - - // Verify unwind frame count for this task - // DeepMiddle -> DeepInnerThrows, 2 frames deep after the initial resume. - var unwindEvents = taskEvts.Where(e => e.EventId == AsyncEventID.UnwindAsyncException).ToList(); - Assert.NotEmpty(unwindEvents); - Assert.All(unwindEvents, e => Assert.Equal(2u, e.UnwindFrameCount)); - } - - // Requires threading: - // Wrapper index tests use RunScenarioAndFlush (Task.Run) to escape xunit's - // AsyncTestSyncContext. With a SynchronizationContext present, each level in the - // async chain re-dispatches through the sync context, creating a separate - // DispatchContinuations call that resets the wrapper index to 0. Task.Run ensures - // the entire continuation chain executes in a single dispatch loop where the - // wrapper index increments sequentially across resumptions. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_WrapperIndexMatchesCallstack() - { - var captures = new List<(string MethodName, int WrapperSlot)>(); - - var events = CollectEvents(ResumeAsyncCallstackKeyword, () => - { - RunScenarioAndFlush(async () => - { - await WrapperTestA(captures); - }); - }); - - // DumpAllEvents(events); - - Assert.True(captures.Count == 3, $"Expected 3 wrapper captures, got {captures.Count}"); - - Assert.All(captures, c => Assert.True(c.WrapperSlot >= 0, $"{c.MethodName} did not find wrapper frame on stack (slot={c.WrapperSlot})")); - - int slotC = captures.First(c => c.MethodName == nameof(WrapperTestC)).WrapperSlot; - int slotB = captures.First(c => c.MethodName == nameof(WrapperTestB)).WrapperSlot; - int slotA = captures.First(c => c.MethodName == nameof(WrapperTestA)).WrapperSlot; - - Assert.Equal(slotC + 1, slotB); - Assert.Equal(slotB + 1, slotA); - } - - // Requires threading: - // Same comment as RuntimeAsync_WrapperIndexMatchesCallstack regarding Task.Run and wrapper index behavior. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_WrapperIndexResetEmitted() - { - var events = CollectEvents(AllKeywords, () => - { - // Recursive chain 34 levels deep crosses the 32-slot boundary, - // triggering at least one ResetAsyncContinuationWrapperIndex event. - RunScenarioAndFlush(async () => - { - await RecursiveAsyncChain(34); - }); - }); - - // DumpAllEvents(events); - - var ids = ParseAllEvents(events).EventIds; - - Assert.Contains(AsyncEventID.ResetAsyncContinuationWrapperIndex, ids); - } - - // Requires threading: - // Same comment as RuntimeAsync_WrapperIndexMatchesCallstack regarding Task.Run and wrapper index behavior. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_WrapperIndexNoResetUnder32() - { - var events = CollectEvents(AllKeywords, () => - { - // A shallow chain stays within the first 32 slots — - // no reset event should be emitted. - RunScenarioAndFlush(async () => - { - await RecursiveAsyncChain(2); - }); - }); - - // DumpAllEvents(events); - - var ids = ParseAllEvents(events).EventIds; - - Assert.DoesNotContain(AsyncEventID.ResetAsyncContinuationWrapperIndex, ids); - } - - // Requires threading: - // The periodic flush timer runs on a background thread. - // On single-threaded runtimes there is no background thread to fire the timer. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_PeriodicTimerFlush() - { - static bool IsRequestedEvent(AsyncEventID id) => - id == AsyncEventID.CreateAsyncContext || - id == AsyncEventID.ResumeAsyncContext || - id == AsyncEventID.SuspendAsyncContext || - id == AsyncEventID.CompleteAsyncContext; - - var events = CollectEvents(CoreKeywords, (collectedEvents, _) => - { - // Run scenario - do NOT flush explicitly afterwards. - RunScenario(async () => - { - await SingleAsyncYield(); - }); - - // Wait for the periodic flush timer (1s interval) to detect the idle buffer and flush it automatically. - Thread.Sleep(1000); - - // Poll to make sure the expected buffer got flush. - bool flushed = SpinWait.SpinUntil(() => - { - var stream = ParseAllEvents(collectedEvents); - return stream.EventIds.Any(id => IsRequestedEvent(id)); - }, TimeSpan.FromSeconds(20)); - - Assert.True(flushed, "Expected periodic timer to flush buffer with core lifecycle events within timeout"); - }); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - int coreEventCount = stream.EventIds.Count(id => IsRequestedEvent(id)); - - Assert.True(coreEventCount > 0, "Expected periodic timer to flush buffer with core lifecycle events"); - } - - // Requires threading: - // Verifies the background flush timer preserves the owning thread's OS thread ID, - // which needs both a dedicated worker thread and the timer thread. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_PeriodicTimerFlush_PreservesOwnerThreadId() - { - // This test verifies that when the background flush timer flushes a thread's buffer, - // the new header written afterwards preserves the owning thread's OS thread ID - // (not the timer thread's ID). - // - // Strategy: run async work on a dedicated thread so its profiler context gets events. - // Between two batches of work, wait for the flush timer to fire. Both buffer flushes - // from the dedicated thread should carry the same OsThreadId. - - ulong workerOsThreadId = 0; - var workerIdReady = new ManualResetEventSlim(false); - var firstBatchDone = new ManualResetEventSlim(false); - var firstFlushSeen = new ManualResetEventSlim(false); - var events = new CollectedEvents(); - - using (var listener = CreateListener(CoreKeywords)) - { - listener.RunWithCallback(e => - { - if (!workerIdReady.IsSet) - return; - if (e.EventId != AsyncEventsId || e.Payload is null || e.Payload.Count == 0) - return; - if (e.Payload[0] is not byte[] payload) - return; - EventBufferHeader? header = ParseEventBufferHeader(payload); - if (header is not null && header.Value.OsThreadId == workerOsThreadId) - events.Events.Enqueue(e); - }, () => - { - SendFlushCommand(); - - var thread = new Thread(() => - { - workerOsThreadId = GetCurrentOSThreadId(); - workerIdReady.Set(); - - // First batch: generate events on this thread's profiler context. - SingleAsyncYield().GetAwaiter().GetResult(); - firstBatchDone.Set(); - - // Wait for the flush to deliver our first buffer before generating more events. - bool flushed = firstFlushSeen.Wait(TimeSpan.FromSeconds(20)); - Assert.True(flushed, "Expected first flush of core lifecycle events within timeout"); - - // Second batch: generate more events on the same thread's context. - SingleAsyncYield().GetAwaiter().GetResult(); - }); - - thread.IsBackground = true; - thread.Start(); - - // Wait for the worker to finish its first batch, then force flush. - firstBatchDone.Wait(TimeSpan.FromSeconds(20)); - SendFlushCommand(); - - // Poll for first buffer from our worker thread. - bool firstFlush = SpinWait.SpinUntil(() => events.Events.Count >= 1, TimeSpan.FromSeconds(20)); - Assert.True(firstFlush, "Expected periodic timer to flush core lifecycle events within timeout"); - - firstFlushSeen.Set(); - - // Wait for the worker to finish its second batch. - bool joined = thread.Join(TimeSpan.FromSeconds(20)); - Assert.True(joined, "Expected worker thread to terminate within timeout after second batch of work"); - - // Force a flush to deliver the second batch. - SendFlushCommand(); - - // Poll for second buffer from our worker thread. - bool secondFlush = SpinWait.SpinUntil(() => events.Events.Count >= 2, TimeSpan.FromSeconds(20)); - Assert.True(secondFlush, "Expected periodic timer to flush core lifecycle events within timeout"); - }); - } - - // DumpAllEvents(events); - - Assert.True(workerOsThreadId != 0, "Failed to capture worker OS thread ID"); - - // The key assertion: find buffers that contain CreateAsyncContext events (our work batches). - // There must be at least 2 such buffers (one per SingleAsyncYield() call), and ALL of them must - // have the worker's OsThreadId - proving the timer flush didn't corrupt the header. - var stream = ParseAllEvents(events); - var createEvents = stream.OfType(AsyncEventID.CreateAsyncContext).ToList(); - Assert.True(createEvents.Count >= 2, $"Expected at least 2 CreateAsyncContext events from the worker thread, got {createEvents.Count}"); - Assert.All(createEvents, e => Assert.Equal(workerOsThreadId, e.OsThreadId)); - } - - // Requires threading: - // Spawns a dedicated thread that exits, then waits for the background flush timer - // to detect and flush the orphaned thread-local buffer. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_DeadThreadFlush() - { - static bool IsRequestedEvent(AsyncEventID id) => - id == AsyncEventID.CreateAsyncContext || - id == AsyncEventID.ResumeAsyncContext || - id == AsyncEventID.SuspendAsyncContext || - id == AsyncEventID.CompleteAsyncContext; - - var events = CollectEvents(CoreKeywords, (collectedEvents, _) => - { - // Spawn a dedicated thread that runs async work then exits. - // Its thread-local buffer becomes orphaned when the thread dies. - var thread = new Thread(() => - { - RunScenario(async () => - { - await SingleAsyncYield(); - }); - }); - - thread.IsBackground = true; - thread.Start(); - bool joined = thread.Join(TimeSpan.FromSeconds(20)); - Assert.True(joined, "Expected worker thread to terminate within timeout before waiting for orphaned buffer flush"); - - // Do NOT send a flush command. - // Wait for the periodic flush timer to detect the dead thread and flush its orphaned buffer. - Thread.Sleep(1000); - - // Poll to make sure the expected buffer got flush. - bool flushed = SpinWait.SpinUntil(() => - { - var stream = ParseAllEvents(collectedEvents); - return stream.EventIds.Any(id => IsRequestedEvent(id)); - }, TimeSpan.FromSeconds(20)); - - Assert.True(flushed, "Expected periodic timer to flush buffer with core lifecycle events within timeout"); - }); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - int coreEventCount = stream.EventIds.Count(id => IsRequestedEvent(id)); - - Assert.True(coreEventCount > 0, "Expected periodic timer to flush dead thread's buffer"); - } - - // This test is sensitive to event noise - it asserts a specific clock event is absent. - // It cannot run in parallel with other async profiler scenarios that might produce - // clock events. Test parallelization is disabled via XunitAssemblyAttributes.cs. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_NoSyncClockEventBeforeInterval() - { - var events = await CollectEventsAsync(CoreKeywords, SingleAsyncYield); - - var ids = ParseAllEvents(events).EventIds; - - Assert.DoesNotContain(AsyncEventID.AsyncProfilerSyncClock, ids); - } - - // This test is sensitive to event noise - it asserts zero context events appear - // after enabling the listener. It cannot run in parallel with other async profiler - // scenarios that might produce events on the same thread context. - // Test parallelization is already disabled via XunitAssemblyAttributes.cs. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_NoEventsWhenDisabled() - { - // Run async work WITHOUT a listener attached - for (int i = 0; i < 50; i++) - { - await SingleAsyncYield(); - } - - // Now attach listener but don't run any RuntimeAsync work inside — - // just call a synchronous no-op. Verify no stale events from above leak through. - var events = await CollectEventsAsync(CoreKeywords, () => Task.CompletedTask); - - // There may be meta data related events, but there should be no suspend/resume/complete events from the earlier work. - var ids = ParseAllEvents(events).EventIds; - - int contextEvents = ids.Count(id => id == AsyncEventID.ResumeAsyncContext || id == AsyncEventID.SuspendAsyncContext || id == AsyncEventID.CompleteAsyncContext); - - Assert.Equal(0, contextEvents); - } - - public static IEnumerable KeywordGatekeepingData() - { - yield return new object[] { (long)CreateAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CreateAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)ResumeAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.ResumeAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)SuspendAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.SuspendAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)CompleteAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CompleteAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)UnwindAsyncExceptionKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.UnwindAsyncException, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)CreateAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CreateAsyncCallstack, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)ResumeAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.ResumeAsyncCallstack, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)SuspendAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.SuspendAsyncCallstack, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)ResumeAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.ResumeAsyncMethod, AsyncEventID.AsyncProfilerMetadata } }; - yield return new object[] { (long)CompleteAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CompleteAsyncMethod, AsyncEventID.AsyncProfilerMetadata } }; - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task KeywordGatekeepingMarker() - { - await OuterCatches(); - await ChainedAsyncYield(); - } - - // This test is sensitive to event noise - it asserts that ONLY the expected event - // types appear for a given keyword. It cannot run in parallel with other async - // profiler scenarios that might produce events on the same thread context. - // Test parallelization is already disabled via XunitAssemblyAttributes.cs. - [ConditionalTheory(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - [MemberData(nameof(KeywordGatekeepingData))] - public async Task RuntimeAsync_KeywordGatekeeping(long keywordValue, AsyncEventID[] allowedEventIds) - { - EventKeywords kw = (EventKeywords)keywordValue; - var allowed = new HashSet(allowedEventIds); - - // Run a scenario that exercises all event types: resume, suspend, - // complete, method events, callstacks, and exception unwinds. - // Only the events matching the enabled keyword should be emitted. - var events = await CollectEventsAsync(kw, KeywordGatekeepingMarker); - - var stream = ParseAllEvents(events); - var unexpected = stream.EventIds.Where(id => !allowed.Contains(id)).ToList(); - - Assert.True(unexpected.Count == 0, $"Keyword 0x{(long)kw:X}: unexpected event IDs [{string.Join(", ", unexpected)}], " + $"allowed [{string.Join(", ", allowed)}]"); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_ResetAsyncThreadContextEvent() - { - var events = await CollectEventsAsync(CoreKeywords, SingleAsyncYield); - - // DumpAllEvents(events); - - var ids = ParseAllEvents(events).EventIds; - - Assert.Contains(AsyncEventID.ResetAsyncThreadContext, ids); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_MetadataEventEmittedOnEnable() - { - var events = await CollectEventsAsync(AllKeywords, SingleAsyncYield); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var metadataList = stream.MetadataEvents; - Assert.True(metadataList.Count >= 1, "Expected at least one metadata event in buffer"); - - MetadataFromBuffer meta = metadataList[0]; - Assert.True(meta.QpcFrequency > 0, $"QPC frequency should be positive, got {meta.QpcFrequency}"); - Assert.True(meta.QpcSync > 0, $"QPC sync timestamp should be positive, got {meta.QpcSync}"); - Assert.True(meta.UtcSync > 0, $"UTC sync timestamp should be positive, got {meta.UtcSync}"); - Assert.True(meta.EventBufferSize > 0, $"Event buffer size should be positive, got {meta.EventBufferSize}"); - Assert.True(meta.WrapperCount > 0, "Wrapper count should be positive"); - } - - // Requires threading: - // Spawns 8 threads with a Barrier to verify metadata is - // emitted exactly once under concurrent enable pressure. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_MetadataEventEmittedOnceAcrossThreads() - { - const int threadCount = 8; - - var events = CollectEvents(AllKeywords, () => - { - using var barrier = new Barrier(threadCount); - var tasks = new Task[threadCount]; - for (int i = 0; i < threadCount; i++) - { - tasks[i] = Task.Factory.StartNew(() => - { - barrier.SignalAndWait(); - SingleAsyncYield().GetAwaiter().GetResult(); - }, TaskCreationOptions.LongRunning); - } - Task.WaitAll(tasks); - SendFlushCommand(); - }); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var metadataList = stream.MetadataEvents; - Assert.True(metadataList.Count == 1, $"Expected exactly 1 metadata event across {threadCount} threads, got {metadataList.Count}"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task NativeIPDeltaRoundtripMarker() - { - await ChainedAsyncYield(); - await DeepOuterCatches(); - await RecursiveAsyncChain(10); - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip() - { - // Verify that delta-encoded NativeIPs in callstacks roundtrip correctly, - // including both positive and negative deltas. With multiple distinct async - // methods at different JIT-assigned addresses, the deltas between consecutive - // NativeIPs will naturally span both directions. This exercises the full - // zigzag + LEB128 encode/decode path through the production serializer. - var events = await CollectEventsAsync(CallstackKeywords, NativeIPDeltaRoundtripMarker); - - var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(NativeIPDeltaRoundtripMarker)); - Assert.NotEmpty(callstacks); - - // Find callstacks with 3+ frames — enough depth for meaningful deltas. - var deepCallstacks = callstacks.Where(cs => cs.FrameCount >= 3).ToList(); - - Assert.True(deepCallstacks.Count > 0, "Expected at least one callstack with 3+ frames for delta verification"); - - bool hasPositiveDelta = false; - bool hasNegativeDelta = false; - - foreach (var cs in deepCallstacks) - { - for (int i = 0; i < cs.Frames.Count; i++) + while (index < buffer.Length) { - var (methodId, _) = cs.Frames[i]; - Assert.True(methodId != 0, $"Frame {i} has zero MethodId"); - - var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); - Assert.True(method is not null, $"Frame {i}: MethodId 0x{methodId:X} does not resolve to a managed method"); - - if (i > 0) + if (index + 2 > buffer.Length) { - long delta = (long)(cs.Frames[i].MethodId - cs.Frames[i - 1].MethodId); - if (delta > 0) - hasPositiveDelta = true; - else if (delta < 0) - hasNegativeDelta = true; + Console.WriteLine($"Trailing bytes: {buffer.Length - index} (incomplete entry header)."); + break; } - } - } - - // With multiple distinct async methods at different addresses, we expect - // both positive and negative deltas. If the JIT happens to lay out all - // methods monotonically (extremely unlikely), at minimum we must see - // non-zero deltas proving the encoding works. - Assert.True(hasPositiveDelta || hasNegativeDelta, "Expected at least one non-zero NativeIP delta across all callstack frames"); - } - - [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CallstackStressMarker(int depth) - { - await RecursiveAsyncChain(depth); - } - - // Requires threading: - // The recursive async chain must execute in a single dispatch - // loop (no sync context) to produce full-depth callstacks. Under xunit's - // AsyncTestSyncContext, each await re-dispatches, fragmenting the chain. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_CallstackStressWithVaryingDepths() - { - // Stress test: run many async calls with varying callstack depths. - // Varying sizes mean some callstacks will land at buffer boundaries, - // naturally exercising the overflow/rewind path in callstack emission. - // lambda -> CallstackStressMarker(d) -> RecursiveAsyncChain(d) produces d + 2 frames. - const int iterations = 200; - int[] depths = new int[iterations]; - var rng = new Random(42); - for (int i = 0; i < iterations; i++) - depths[i] = rng.Next(1, 120); - - var events = CollectEvents(CallstackKeywords, () => - { - RunScenarioAndFlush(async () => - { - for (int i = 0; i < iterations; i++) - await CallstackStressMarker(depths[i]); - }); - }); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackStressMarker)); - // Verify all callstacks have valid frame data that resolves to managed methods. - foreach (var cs in callstacks) - { - Assert.True(cs.FrameCount > 0, "Callstack has 0 frames"); - Assert.Equal((int)cs.FrameCount, cs.Frames.Count); - for (int f = 0; f < cs.Frames.Count; f++) - { - var (methodId, _) = cs.Frames[f]; - Assert.True(methodId != 0, $"Frame {f} has zero MethodId"); - - var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); - Assert.True(method is not null, $"Frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); - } - } - - // One resume callstack per iteration (marker filters out noise). - // lambda -> CallstackStressMarker -> RecursiveAsyncChain(d) produces d + 2 frames. - Assert.True(callstacks.Count >= iterations, $"Expected at least {iterations} callstacks with marker, got {callstacks.Count}"); - - for (int i = 0; i < iterations; i++) - { - // lambda + CallstackStressMarker + RecursiveAsyncChain(d) = d + 2 - int expected = depths[i] + 2; - int actual = callstacks[i].FrameCount; - Assert.True(actual == expected, $"Iteration {i}: expected depth {expected} (lambda -> CallstackStressMarker -> RecursiveAsyncChain({depths[i]})), got {actual}"); - } - - // Verify multiple buffer flushes occurred. - int bufferCount = 0; - ForEachEventBufferPayload(events, _ => bufferCount++); - Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); - } - - // Requires threading: - // Deep recursive chains must execute in a single dispatch loop (no sync context) - // to produce full-depth callstacks that trigger overflow. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_CallstackOverflowPathProducesValidFrames() - { - // Targeted test: run random-depth callstacks until we detect the overflow - // path was exercised, then validate the affected callstack. - // The overflow path fires when a large callstack doesn't fit inline in the - // remaining buffer space — the code rewinds, flushes the partial buffer, - // and re-writes the callstack as the first event in a fresh buffer. - // - // To prove overflow occurred we check consecutive buffer pairs: - // Buffer N: not full (has remaining capacity, but not enough for the next callstack) - // Buffer N+1: first event is a large callstack - // This proves the runtime detected insufficient space, rewound, and flushed. - bool overflowDetected = false; - var rng = new Random(42); - - for (int attempt = 0; attempt < 10 && !overflowDetected; attempt++) - { - int iterations = 500; - int[] depths = new int[iterations]; - for (int i = 0; i < iterations; i++) - depths[i] = rng.Next(50, 250); - - var events = CollectEvents(AllKeywords, () => - { - RunScenarioAndFlush(async () => - { - for (int i = 0; i < iterations; i++) - await RecursiveAsyncChain(depths[i]); - }); - }); - - // Get buffer capacity from metadata. - var stream = ParseAllEvents(events); - var metadataList = stream.MetadataEvents; - if (metadataList.Count == 0) - continue; - uint bufferCapacity = metadataList[0].EventBufferSize; - - // Collect per-buffer info grouped by async thread context. - // Consecutive buffers from the same context represent the overflow sequence. - var buffersByContext = new Dictionary>(); - ForEachEventBufferPayload(events, buffer => - { - EventBufferHeader? header = ParseEventBufferHeader(buffer); - if (header is null) - return; - - uint contextId = header.Value.AsyncThreadContextId; - - // Parse the first event in this buffer. - int index = HeaderSize; - if (index >= buffer.Length) - return; + AsyncEventID eventId = (AsyncEventID)buffer[index++]; - AsyncEventID firstId = (AsyncEventID)buffer[index++]; - // Skip timestamp delta - ReadCompressedUInt64(buffer, ref index); + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong delta); + currentTimestamp += delta; - byte frameCount = 0; - if (firstId == AsyncEventID.ResumeAsyncCallstack || - firstId == AsyncEventID.CreateAsyncCallstack || - firstId == AsyncEventID.SuspendAsyncCallstack) - { - // Callstack payload: type(1) + callstackId(1) + frameCount(1) + compressed taskId + frames... - index++; // type byte - index++; // callstack ID (reserved) - if (index < buffer.Length) - frameCount = buffer[index]; - } + OutputHeader(eventCount, eventId, currentTimestamp); - if (!buffersByContext.TryGetValue(contextId, out var list)) + int payloadStart = index; + try { - list = new List<(int, AsyncEventID, byte)>(); - buffersByContext[contextId] = list; + index += eventId switch + { + AsyncEventID.CreateAsyncContext => OutputCreateAsyncContextEvent(buffer.Slice(index)), + AsyncEventID.ResumeAsyncContext => OutputResumeAsyncContextEvent(buffer.Slice(index)), + AsyncEventID.SuspendAsyncContext => OutputSuspendAsyncContextEvent(), + AsyncEventID.CompleteAsyncContext => OutputCompleteAsyncContextEvent(), + AsyncEventID.UnwindAsyncException => OutputUnwindAsyncExceptionEvent(buffer.Slice(index)), + AsyncEventID.CreateAsyncCallstack => OutputAsyncCallstackEvent(buffer.Slice(index)), + AsyncEventID.ResumeAsyncCallstack => OutputAsyncCallstackEvent(buffer.Slice(index)), + AsyncEventID.SuspendAsyncCallstack => OutputAsyncCallstackEvent(buffer.Slice(index)), + AsyncEventID.AppendAsyncCallstack => OutputAsyncCallstackEvent(buffer.Slice(index)), + AsyncEventID.ResumeAsyncMethod => OutputResumeAsyncMethodEvent(), + AsyncEventID.CompleteAsyncMethod => OutputCompleteAsyncMethodEvent(), + AsyncEventID.ResetAsyncThreadContext => OutputResetAsyncThreadContextEvent(), + AsyncEventID.ResetAsyncContinuationWrapperIndex => OutputResetAsyncContinuationWrapperIndexEvent(), + AsyncEventID.AsyncProfilerMetadata => OutputAsyncProfilerMetadataEvent(buffer.Slice(index)), + AsyncEventID.AsyncProfilerSyncClock => OutputAsyncProfilerSyncClockEvent(buffer.Slice(index)), + _ => throw new InvalidOperationException($"Unknown eventId {eventId}."), + }; } - list.Add((buffer.Length, firstId, frameCount)); - }); - - // Look for overflow evidence within the same thread context: - // buffer N not full, buffer N+1 starts with large callstack. - foreach (var bufferInfos in buffersByContext.Values) - { - for (int i = 0; i < bufferInfos.Count - 1; i++) + catch (Exception ex) { - var current = bufferInfos[i]; - var next = bufferInfos[i + 1]; - - Assert.True((uint)current.UsedSize <= bufferCapacity, $"Buffer used size {current.UsedSize} exceeds capacity {bufferCapacity}."); - - uint remaining = bufferCapacity - (uint)current.UsedSize; - bool currentNotFull = remaining > 0; - bool nextStartsWithLargeCallstack = - (next.FirstEventId == AsyncEventID.ResumeAsyncCallstack || - next.FirstEventId == AsyncEventID.CreateAsyncCallstack || - next.FirstEventId == AsyncEventID.SuspendAsyncCallstack) && - next.FirstFrameCount > 30; - - if (currentNotFull && nextStartsWithLargeCallstack) - { - overflowDetected = true; - break; - } + Console.WriteLine($" Failed decoding entry payload at offset {payloadStart}: {ex.GetType().Name}: {ex.Message}"); + break; } - if (overflowDetected) - break; + eventCount++; } - // Validate all large callstacks in the stream have correct frames. - if (overflowDetected) - { - var largeCallstacks = stream.OfType(AsyncEventID.ResumeAsyncCallstack) - .Where(e => e.FrameCount > 30) - .ToList(); - - foreach (var cs in largeCallstacks) - { - Assert.Equal((int)cs.FrameCount, cs.Frames.Count); - for (int f = 0; f < cs.Frames.Count; f++) - { - var (methodId, _) = cs.Frames[f]; - Assert.True(methodId != 0, $"Overflow callstack frame {f} has zero MethodId"); - - var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); - Assert.True(method is not null, $"Overflow callstack frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); - } - } - } + return index; } - Assert.True(overflowDetected, "Failed to trigger callstack buffer overflow after 10 attempts — " + - "no consecutive buffer pair found where buffer N has remaining capacity and buffer N+1 starts with a large callstack"); - } - - // Requires threading: - // Deep recursive chains must execute in a single dispatch - // loop (no sync context) to produce chains exceeding the 255-frame cap. - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void RuntimeAsync_CallstackDepthCappedAtMaxFrames() - { - // Verify that callstack depth is capped when the continuation chain - // exceeds the maximum frame count (255, limited by byte storage). - // RecursiveAsyncChain(300) produces a 300-deep chain + 1 lambda = 301 frames. - const int requestedDepth = 300; + private const int OutputEventSeparatorWidth = 80; - var events = CollectEvents(ResumeAsyncCallstackKeyword, () => + private static void OutputHeader() { - RunScenarioAndFlush(async () => - { - await RecursiveAsyncChain(requestedDepth); - }); - }); - - // DumpAllEvents(events); + Console.WriteLine($"{new string('-', OutputEventSeparatorWidth)}"); + } - var stream = ParseAllEvents(events); - var callstacks = stream.OfType(AsyncEventID.ResumeAsyncCallstack).ToList(); - Assert.True(callstacks.Count >= 1, "Expected at least one callstack"); + private static void OutputHeader(string header) => Console.WriteLine($"{FormatCenteredLabel(header)}"); - // Find the callstack from our deep RecursiveAsyncChain call. - // The max frame count is capped at 255 (byte.MaxValue) since the - // CaptureRuntimeAsyncCallstackState.Count is a byte. - // RecursiveAsyncChain(300) + 1 lambda = 301 frames, capped to 255. - var deepest = callstacks.MaxBy(cs => cs.FrameCount); - Assert.Equal(255, (int)deepest!.FrameCount); - Assert.Equal((int)deepest.FrameCount, deepest.Frames.Count); + private static void OutputHeader(int eventCount, AsyncEventID id, ulong timestamp) => + Console.WriteLine($"[{eventCount}] {id} (0x{timestamp:X16})"); - // Verify all frames are valid. - foreach (var (methodId, _) in deepest.Frames) + private static string FormatCenteredLabel(string label) { - Assert.True(methodId != 0, "Frame has zero MethodId"); - var method = GetMethodNameFromMethodId(deepest.CallstackType, methodId); - Assert.True(method is not null, $"MethodId 0x{methodId:X} does not resolve to a managed method"); + int totalDashes = Math.Max(6, OutputEventSeparatorWidth - label.Length - 2); + int leftDashes = totalDashes / 2; + int rightDashes = totalDashes - leftDashes; + return $"{new string('-', leftDashes)} {label} {new string('-', rightDashes)}"; } - } - - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task RuntimeAsync_MetadataMatchesWrapperMethods() - { - var events = await CollectEventsAsync(AllKeywords, SingleAsyncYield); - - var stream = ParseAllEvents(events); - var metadataList = stream.MetadataEvents; - Assert.True(metadataList.Count >= 1, "Expected at least one metadata event in buffer"); - - MetadataFromBuffer meta = metadataList[0]; - Assert.True(meta.WrapperCount > 0, "Expected positive wrapper count in metadata"); - // On CoreCLR, verify via reflection that the contract-defined template produces names matching real methods. - // This catches accidental renames of wrapper methods without updating the contract. - if (PlatformDetection.IsCoreCLR) + private static void OutputFooter() { - Type? wrapperType = typeof(System.Runtime.CompilerServices.AsyncTaskMethodBuilder) - .Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper"); - Assert.NotNull(wrapperType); - for (int i = 0; i < meta.WrapperCount; i++) - { - string expectedName = string.Format(WrapperNameTemplate, i); - var method = wrapperType.GetMethod(expectedName, - System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); - Assert.True(method is not null, $"Expected method '{expectedName}' not found on ContinuationWrapper type"); - } - - // Verify that the wrapper count matches the actual number of wrapper methods on the type. - int actualCount = wrapperType.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public) - .Count(m => m.Name.StartsWith(WrapperNamePrefix, StringComparison.Ordinal)); - Assert.Equal(meta.WrapperCount, actualCount); + Console.WriteLine(new string('-', OutputEventSeparatorWidth)); } - } - } - - internal static class EventBuffer - { - public static int OutputEventBuffer(ReadOnlySpan buffer) - { - Console.WriteLine("--- AsyncEvents ---"); - int index = 0; - - if ((uint)buffer.Length < 1) + private static int OutputCreateAsyncContextEvent(ReadOnlySpan buffer) { - Console.WriteLine("Buffer too small."); - Console.WriteLine("----------------------------------"); + int index = 0; + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong id); + Console.WriteLine($" ID: {id}"); return index; } - byte version = buffer[index++]; - Console.WriteLine($"Version: {version}"); - - if (version != 1) + private static int OutputResumeAsyncContextEvent(ReadOnlySpan buffer) { - Console.WriteLine($"Unsupported version: {version}"); - Console.WriteLine("----------------------------------"); + int index = 0; + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong id); + Console.WriteLine($" ID: {id}"); return index; } - Deserializer.ReadUInt32(buffer, ref index, out uint totalSize); - Deserializer.ReadUInt32(buffer, ref index, out uint contextId); - Deserializer.ReadUInt64(buffer, ref index, out ulong osThreadId); - Deserializer.ReadUInt32(buffer, ref index, out uint totalEventCount); - Deserializer.ReadUInt64(buffer, ref index, out ulong startTimestamp); - Deserializer.ReadUInt64(buffer, ref index, out ulong endTimestamp); - - Console.WriteLine($"TotalSize (bytes): {totalSize}"); - Console.WriteLine($"AsyncThreadContextId: {contextId}"); - Console.WriteLine($"OSThreadId: {osThreadId}"); - Console.WriteLine($"TotalEventCount: {totalEventCount}"); - Console.WriteLine($"StartTimestamp: 0x{startTimestamp:X16}"); - Console.WriteLine($"EndTimestamp: 0x{endTimestamp:X16}"); - - int eventCount = 0; - ulong currentTimestamp = startTimestamp; - - while (index < buffer.Length) + private static int OutputSuspendAsyncContextEvent() { - if (index + 2 > buffer.Length) - { - Console.WriteLine($"Trailing bytes: {buffer.Length - index} (incomplete entry header)."); - break; - } - - AsyncEventID eventId = (AsyncEventID)buffer[index++]; - - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong delta); - currentTimestamp += delta; - - Console.WriteLine($"Entry[{eventCount}]: Timestamp=0x{currentTimestamp:X16}, EventId={eventId}"); - - int payloadStart = index; - try - { - index += eventId switch - { - AsyncEventID.CreateAsyncContext => OutputCreateAsyncContextEvent(buffer.Slice(index)), - AsyncEventID.ResumeAsyncContext => OutputResumeAsyncContextEvent(buffer.Slice(index)), - AsyncEventID.SuspendAsyncContext => OutputSuspendAsyncContextEvent(), - AsyncEventID.CompleteAsyncContext => OutputCompleteAsyncContextEvent(), - AsyncEventID.UnwindAsyncException => OutputUnwindAsyncExceptionEvent(buffer.Slice(index)), - AsyncEventID.CreateAsyncCallstack => OutputAsyncCallstackEvent("CreateAsyncCallstack", buffer.Slice(index)), - AsyncEventID.ResumeAsyncCallstack => OutputAsyncCallstackEvent("ResumeAsyncCallstack", buffer.Slice(index)), - AsyncEventID.SuspendAsyncCallstack => OutputAsyncCallstackEvent("SuspendAsyncCallstack", buffer.Slice(index)), - AsyncEventID.AppendAsyncCallstack => OutputAsyncCallstackEvent("AppendAsyncCallstack", buffer.Slice(index)), - AsyncEventID.ResumeAsyncMethod => OutputResumeAsyncMethodEvent(), - AsyncEventID.CompleteAsyncMethod => OutputCompleteAsyncMethodEvent(), - AsyncEventID.ResetAsyncThreadContext => OutputResetAsyncThreadContextEvent(), - AsyncEventID.ResetAsyncContinuationWrapperIndex => OutputResetAsyncContinuationWrapperIndexEvent(), - AsyncEventID.AsyncProfilerMetadata => OutputAsyncProfilerMetadataEvent(buffer.Slice(index)), - AsyncEventID.AsyncProfilerSyncClock => OutputAsyncProfilerSyncClockEvent(buffer.Slice(index)), - _ => throw new InvalidOperationException($"Unknown eventId {eventId}."), - }; - } - catch (Exception ex) - { - Console.WriteLine($" Failed decoding entry payload at offset {payloadStart}: {ex.GetType().Name}: {ex.Message}"); - break; - } - - eventCount++; + return 0; } - Console.WriteLine($"TotalEntriesDecoded: {eventCount}"); - Console.WriteLine("----------------------------------"); - - return index; - } - - private static int OutputCreateAsyncContextEvent(ReadOnlySpan buffer) - { - int index = 0; - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong id); - Console.WriteLine("--- CreateAsyncContext ---"); - Console.WriteLine($" ID: {id}"); - Console.WriteLine("----------------------------"); - return index; - } - - private static int OutputResumeAsyncContextEvent(ReadOnlySpan buffer) - { - int index = 0; - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong id); - Console.WriteLine("--- ResumeAsyncContext ---"); - Console.WriteLine($" ID: {id}"); - Console.WriteLine("----------------------------"); - return index; - } - - private static int OutputSuspendAsyncContextEvent() - { - Console.WriteLine("--- SuspendAsyncContext ---"); - Console.WriteLine("----------------------------"); - return 0; - } - - private static int OutputCompleteAsyncContextEvent() - { - Console.WriteLine("--- CompleteAsyncContext ---"); - Console.WriteLine("----------------------------"); - return 0; - } - - private static int OutputUnwindAsyncExceptionEvent(ReadOnlySpan buffer) - { - uint unwindedFrames; - int index = 0; - - Deserializer.ReadCompressedUInt32(buffer, ref index, out unwindedFrames); - index += OutputUnwindAsyncExceptionEvent(unwindedFrames); - - return index; - } - - private static int OutputUnwindAsyncExceptionEvent(uint unwindedFrames) - { - Console.WriteLine("--- UnwindAsyncException ---"); - Console.WriteLine($"Unwinded Frames: {unwindedFrames}"); - Console.WriteLine("----------------------------"); - return 0; - } - - private static int OutputResumeAsyncMethodEvent() - { - Console.WriteLine("--- ResumeAsyncMethod ---"); - Console.WriteLine("----------------------------"); - return 0; - } + private static int OutputCompleteAsyncContextEvent() + { + return 0; + } - private static int OutputCompleteAsyncMethodEvent() - { - Console.WriteLine("--- CompleteAsyncMethod ---"); - Console.WriteLine("----------------------------"); - return 0; - } + private static int OutputUnwindAsyncExceptionEvent(ReadOnlySpan buffer) + { + uint unwindedFrames; + int index = 0; - private static int OutputResetAsyncContinuationWrapperIndexEvent() - { - Console.WriteLine("--- ResetAsyncContinuationWrapperIndex ---"); - Console.WriteLine("----------------------------"); - return 0; - } + Deserializer.ReadCompressedUInt32(buffer, ref index, out unwindedFrames); + index += OutputUnwindAsyncExceptionEvent(unwindedFrames); - private static int OutputResetAsyncThreadContextEvent() - { - Console.WriteLine("--- ResetAsyncThreadContext ---"); - Console.WriteLine("----------------------------"); - return 0; - } + return index; + } - private static int OutputAsyncProfilerMetadataEvent(ReadOnlySpan buffer) - { - int index = 0; - Console.WriteLine("--- AsyncProfilerMetadata ---"); + private static int OutputUnwindAsyncExceptionEvent(uint unwindedFrames) + { + Console.WriteLine($" Unwinded Frames: {unwindedFrames}"); + return 0; + } - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcFrequency); - Console.WriteLine($" QPCFrequency: {qpcFrequency}"); + private static int OutputResumeAsyncMethodEvent() + { + return 0; + } - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcSync); - Console.WriteLine($" QPCSync: {qpcSync}"); + private static int OutputCompleteAsyncMethodEvent() + { + return 0; + } - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong utcSync); - Console.WriteLine($" UTCSync: {utcSync}"); + private static int OutputResetAsyncContinuationWrapperIndexEvent() + { + return 0; + } - Deserializer.ReadCompressedUInt32(buffer, ref index, out uint eventBufferSize); - Console.WriteLine($" EventBufferSize: {eventBufferSize}"); + private static int OutputResetAsyncThreadContextEvent() + { + return 0; + } - byte wrapperCount = buffer[index++]; - Console.WriteLine($" WrapperCount: {wrapperCount}"); + private static int OutputAsyncProfilerMetadataEvent(ReadOnlySpan buffer) + { + int index = 0; - Console.WriteLine("----------------------------"); - return index; - } + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcFrequency); + Console.WriteLine($" QPCFrequency: {qpcFrequency}"); - private static int OutputAsyncProfilerSyncClockEvent(ReadOnlySpan buffer) - { - int index = 0; - Console.WriteLine("--- AsyncProfilerSyncClock ---"); + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcSync); + Console.WriteLine($" QPCSync: {qpcSync}"); - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcSync); - Console.WriteLine($" QPCSync: {qpcSync}"); + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong utcSync); + Console.WriteLine($" UTCSync: {utcSync}"); - Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong utcSync); - Console.WriteLine($" UTCSync: {utcSync}"); + Deserializer.ReadCompressedUInt32(buffer, ref index, out uint eventBufferSize); + Console.WriteLine($" EventBufferSize: {eventBufferSize}"); - Console.WriteLine("----------------------------"); - return index; - } + byte wrapperCount = buffer[index++]; + Console.WriteLine($" WrapperCount: {wrapperCount}"); - private static int OutputAsyncCallstackEvent(string eventName, ReadOnlySpan buffer) - { - ulong id; - byte type; - byte callstackId; - byte asyncCallstackLength; - int index = 0; - - type = buffer[index++]; - callstackId = buffer[index++]; - asyncCallstackLength = buffer[index++]; - Deserializer.ReadCompressedUInt64(buffer, ref index, out id); - - Console.WriteLine($"--- {eventName} ---"); - Console.WriteLine($"ID: {id}"); - Console.WriteLine($"Type: {type}"); - Console.WriteLine($"CallstackId: {callstackId}"); - Console.WriteLine($"Length: {asyncCallstackLength}"); - - if (asyncCallstackLength == 0) - { return index; } - ulong previousMethodId; - ulong currentMethodId; - int state; - - Deserializer.ReadCompressedUInt64(buffer, ref index, out currentMethodId); - Deserializer.ReadCompressedInt32(buffer, ref index, out state); - - OutputAsyncFrame((AsyncCallstackType)type, currentMethodId, state, 0); - - for (int i = 1; i < asyncCallstackLength; i++) + private static int OutputAsyncProfilerSyncClockEvent(ReadOnlySpan buffer) { - previousMethodId = currentMethodId; - Deserializer.ReadCompressedInt64(buffer, ref index, out long methodIdDelta); - Deserializer.ReadCompressedInt32(buffer, ref index, out state); - currentMethodId = previousMethodId + (ulong)methodIdDelta; - OutputAsyncFrame((AsyncCallstackType)type, currentMethodId, state, i); - } + int index = 0; - return index; - } + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong qpcSync); + Console.WriteLine($" QPCSync: {qpcSync}"); - private static void OutputAsyncFrame(AsyncCallstackType type, ulong methodId, int state, int frameIndex) - { - string asyncMethodName = AsyncProfilerTests.GetMethodNameFromMethodId(type, methodId) ?? "??"; - string methodIdString = $"0x{methodId:X}"; - Console.WriteLine($" Frame {frameIndex}: AsyncMethod = {asyncMethodName}, MethodId = {methodIdString}, State = {state}"); - } + Deserializer.ReadCompressedUInt64(buffer, ref index, out ulong utcSync); + Console.WriteLine($" UTCSync: {utcSync}"); - internal static class Deserializer - { - public static void ReadInt32(ReadOnlySpan buffer, ref int index, out int value) - { - uint uValue; - ReadUInt32(buffer, ref index, out uValue); - value = (int)uValue; + return index; } - public static void ReadCompressedInt32(ReadOnlySpan buffer, ref int index, out int value) + private static int OutputAsyncCallstackEvent(ReadOnlySpan buffer) { - uint uValue; - ReadCompressedUInt32(buffer, ref index, out uValue); - value = ZigzagDecodeInt32(uValue); - } + ulong id; + byte type; + byte callstackId; + byte asyncCallstackLength; + int index = 0; - public static void ReadUInt32(ReadOnlySpan buffer, ref int index, out uint value) - { - value = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(index)); - index += 4; - } + type = buffer[index++]; + callstackId = buffer[index++]; + asyncCallstackLength = buffer[index++]; + Deserializer.ReadCompressedUInt64(buffer, ref index, out id); - public static void ReadCompressedUInt32(ReadOnlySpan buffer, ref int index, out uint value) - { - int shift = 0; - byte b; + Console.WriteLine($" ID: {id}"); + Console.WriteLine($" Type: {type}"); + Console.WriteLine($" CallstackId: {callstackId}"); + Console.WriteLine($" Length: {asyncCallstackLength}"); - value = 0; - do + if (asyncCallstackLength == 0) { - b = buffer[index++]; - value |= (uint)(b & 0x7F) << shift; - shift += 7; - } while ((b & 0x80) != 0); - } - - public static void ReadInt64(ReadOnlySpan buffer, ref int index, out long value) - { - ulong uValue; - ReadUInt64(buffer, ref index, out uValue); - value = (long)uValue; - } + return index; + } - public static void ReadCompressedInt64(ReadOnlySpan buffer, ref int index, out long value) - { - ulong uValue; - ReadCompressedUInt64(buffer, ref index, out uValue); - value = ZigzagDecodeInt64(uValue); - } + ulong previousMethodId; + ulong currentMethodId; + int state; - public static void ReadUInt64(ReadOnlySpan buffer, ref int index, out ulong value) - { - value = BinaryPrimitives.ReadUInt64LittleEndian(buffer.Slice(index)); - index += 8; - } + Deserializer.ReadCompressedUInt64(buffer, ref index, out currentMethodId); + Deserializer.ReadCompressedInt32(buffer, ref index, out state); - public static void ReadCompressedUInt64(ReadOnlySpan buffer, ref int index, out ulong value) - { - int shift = 0; - byte b; + OutputAsyncFrame((AsyncCallstackType)type, currentMethodId, state, 0); - value = 0; - do + for (int i = 1; i < asyncCallstackLength; i++) { - b = buffer[index++]; - value |= (ulong)(b & 0x7F) << shift; - shift += 7; - } while ((b & 0x80) != 0); - } + previousMethodId = currentMethodId; + Deserializer.ReadCompressedInt64(buffer, ref index, out long methodIdDelta); + Deserializer.ReadCompressedInt32(buffer, ref index, out state); + currentMethodId = previousMethodId + (ulong)methodIdDelta; + OutputAsyncFrame((AsyncCallstackType)type, currentMethodId, state, i); + } - private static int ZigzagDecodeInt32(uint value) => (int)((value >> 1) ^ (~(value & 1) + 1)); + return index; + } - private static long ZigzagDecodeInt64(ulong value) => (long)((value >> 1) ^ (~(value & 1) + 1)); + private static void OutputAsyncFrame(AsyncCallstackType type, ulong methodId, int state, int frameIndex) + { + string asyncMethodName = GetMethodNameFromMethodId(type, methodId) ?? "??"; + Console.WriteLine($" [{frameIndex}] {asyncMethodName} (0x{methodId:X}) (state={state})"); + } } } } 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 e47c2d3638f32c..319cf61502b977 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 @@ -10,54 +10,52 @@ namespace System.Threading.Tasks.Tests { - /// - /// Tests for V1 (Task-based AsyncStateMachineBox) async profiler event emission. - /// All scenario methods use [RuntimeAsyncMethodGeneration(false)] to ensure they - /// exercise the legacy Task-based async path even if the default changes in the future. - /// Tests use sync CollectEvents with RunScenarioAndFlush to isolate the V1 chain - /// on a threadpool thread, ensuring dispatcher finally blocks complete before flush. - /// Requires threading support. - /// + // Tests for V1 (Task-based AsyncStateMachineBox) async profiler event emission. + // All scenario methods use [RuntimeAsyncMethodGeneration(false)] to ensure they + // exercise the legacy Task-based async path even if the default changes in the future. + // Most tests use sync CollectEvents with RunScenarioAndFlush to isolate the V1 chain + // on a threadpool thread, ensuring dispatcher finally blocks complete before flush. + // Requires threading support. public partial class AsyncProfilerTests { [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_SingleYield() + private static async Task TaskAsync_SingleYield() { await Task.Yield(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_DeepChain() + private static async Task TaskAsync_DeepChain() { await TaskAsync_Level1(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_Level1() + private static async Task TaskAsync_Level1() { await TaskAsync_Level2(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_Level2() + private static async Task TaskAsync_Level2() { await TaskAsync_Level3(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_Level3() + private static async Task TaskAsync_Level3() { await Task.Delay(100); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_ExceptionHandled() + private static async Task TaskAsync_ExceptionHandled() { try { @@ -70,25 +68,46 @@ static async Task TaskAsync_ExceptionHandled() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_InnerThrows() + private static async Task TaskAsync_InnerThrows() { await Task.Delay(100); - throw new InvalidOperationException("v1 inner"); + throw new InvalidOperationException("inner"); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_UnhandledExceptionOuter() + private static async Task TaskAsync_UnhandledExceptionOuter() { await TaskAsync_UnhandledExceptionInner(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_UnhandledExceptionInner() + private static async Task TaskAsync_UnhandledExceptionInner() + { + await Task.Delay(100); + throw new InvalidOperationException("unhandled inner"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async ValueTask ValueTaskAsync_Level1() + { + await ValueTaskAsync_Level2(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async ValueTask ValueTaskAsync_Level2() + { + await ValueTaskAsync_Level3(); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async ValueTask ValueTaskAsync_Level3() { await Task.Delay(100); - throw new InvalidOperationException("v1 unhandled inner"); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -123,10 +142,8 @@ public void TaskAsync_CreateAsyncContextEmittedOnFirstAwait() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_EventSequenceOrderMarker() + private static async Task TaskAsync_EventSequenceOrder_Marker() { - // Use Task.Delay (not Task.Yield) so the dispatcher has predictable scheduling latency. - // The marker is the leaf await, so there is no parent-registration race to worry about. await Task.Delay(100); } @@ -135,15 +152,15 @@ public void TaskAsync_EventSequenceOrder() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_EventSequenceOrderMarker()); + RunScenarioAndFlush(() => TaskAsync_EventSequenceOrder_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_EventSequenceOrderMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one merged resume callstack with {nameof(TaskAsync_EventSequenceOrderMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_EventSequenceOrder_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -160,12 +177,8 @@ public void TaskAsync_EventSequenceOrder() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_SuspendResumeCompleteEventsMarker() + private static async Task TaskAsync_SuspendResumeCompleteEvents_Marker() { - // Three sequential Delays produce three Suspend/Resume cycles on the same context - // (Create reuses the active dispatcher's context id and emits Suspend on each subsequent yield). - // Using Task.Delay (not Task.Yield) avoids the dispatcher-vs-registration race; the marker - // is the inner box, so it is reliably present in the Resume callstack at walk time. await Task.Delay(100); await Task.Delay(100); await Task.Delay(100); @@ -176,15 +189,15 @@ public void TaskAsync_SuspendResumeCompleteEvents() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_SuspendResumeCompleteEventsMarker()); + RunScenarioAndFlush(() => TaskAsync_SuspendResumeCompleteEvents_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_SuspendResumeCompleteEventsMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(TaskAsync_SuspendResumeCompleteEventsMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_SuspendResumeCompleteEvents_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -192,23 +205,17 @@ public void TaskAsync_SuspendResumeCompleteEvents() int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); - int resumeIdx1 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); - Assert.True(resumeIdx1 > createIdx, "Expected first ResumeAsyncContext after Create"); - - int suspendIdx1 = ids.IndexOf(AsyncEventID.SuspendAsyncContext, resumeIdx1 + 1); - Assert.True(suspendIdx1 > resumeIdx1, "Expected first SuspendAsyncContext after first Resume"); - - int resumeIdx2 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, suspendIdx1 + 1); - Assert.True(resumeIdx2 > suspendIdx1, "Expected second ResumeAsyncContext after first Suspend"); + int resumeCount = ids.Count(id => id == AsyncEventID.ResumeAsyncContext); + Assert.True(resumeCount >= 1, "Expected at least one ResumeAsyncContext"); - int suspendIdx2 = ids.IndexOf(AsyncEventID.SuspendAsyncContext, resumeIdx2 + 1); - Assert.True(suspendIdx2 > resumeIdx2, "Expected second SuspendAsyncContext after second Resume"); + int completeCount = ids.Count(id => id == AsyncEventID.CompleteAsyncContext); + Assert.True(completeCount >= 1, "Expected at least one CompleteAsyncContext"); - int resumeIdx3 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, suspendIdx2 + 1); - Assert.True(resumeIdx3 > suspendIdx2, "Expected third ResumeAsyncContext after second Suspend"); + // Expected ResumeAsyncContext and CompleteAsyncContext counts to match. + Assert.Equal(resumeCount, completeCount); - int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx3 + 1); - Assert.True(completeIdx > resumeIdx3, "Expected CompleteAsyncContext after third Resume"); + // Expected no SuspendAsyncContext events. + Assert.DoesNotContain(AsyncEventID.SuspendAsyncContext, ids); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -230,7 +237,7 @@ public void TaskAsync_ResumeCompleteMethodEvents() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_HandledException_EmitsUnwindAndCompleteMarker() + private static async Task TaskAsync_HandledException_EmitsUnwindAndComplete_Marker() { await TaskAsync_ExceptionHandled(); } @@ -240,15 +247,15 @@ public void TaskAsync_HandledException_EmitsUnwindAndComplete() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_HandledException_EmitsUnwindAndCompleteMarker()); + RunScenarioAndFlush(() => TaskAsync_HandledException_EmitsUnwindAndComplete_Marker()); }); //DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_HandledException_EmitsUnwindAndCompleteMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(TaskAsync_HandledException_EmitsUnwindAndCompleteMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_HandledException_EmitsUnwindAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -268,7 +275,7 @@ public void TaskAsync_HandledException_EmitsUnwindAndComplete() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker() + private static async Task TaskAsync_UnhandledException_EmitsUnwindAndComplete_Marker() { await TaskAsync_UnhandledExceptionOuter(); } @@ -280,7 +287,7 @@ public void TaskAsync_UnhandledException_EmitsUnwindAndComplete() { try { - RunScenarioAndFlush(() => TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker()); + RunScenarioAndFlush(() => TaskAsync_UnhandledException_EmitsUnwindAndComplete_Marker()); } catch (InvalidOperationException) { @@ -291,8 +298,8 @@ public void TaskAsync_UnhandledException_EmitsUnwindAndComplete() var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(TaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_UnhandledException_EmitsUnwindAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -313,81 +320,103 @@ public void TaskAsync_UnhandledException_EmitsUnwindAndComplete() Assert.True(completeIdx > unwindIdx2, "Expected CompleteAsyncContext after second Unwind"); } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_MethodEventCountMatchesChainDepth_Marker() + { + await TaskAsync_DeepChain(); + } + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_MethodEventCountMatchesChainDepth() { - var events = CollectEvents(MethodKeywords | CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | MethodKeywords | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_DeepChain()); + RunScenarioAndFlush(() => TaskAsync_MethodEventCountMatchesChainDepth_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // DeepChain → Level1 → Level2 → Level3 = 4 methods - // Level3 uses Task.Delay to ensure full chain is built before resume. - // On resume: Level3, Level2, Level1, DeepChain each resume and complete = 4 pairs. - var methodEvents = stream.All - .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod) - .Select(e => e.EventId) - .ToList(); + // Marker -> DeepChain -> Level1 -> Level2 -> Level3 + const int ExpectedChainDepth = 5; - int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); - int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_MethodEventCountMatchesChainDepth_Marker)); + Assert.NotEmpty(markerCallstacks); - Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events, got {resumeCount}"); - Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events, got {completeCount}"); + Assert.Equal(ExpectedChainDepth, markerCallstacks[0].Frames.Count); - // Verify interleaved ordering: each Resume is followed by its matching Complete - // Check the last 8 events (4 Resume/Complete pairs from the inner chain) - var tail = methodEvents.Skip(methodEvents.Count - 8).ToList(); - for (int i = 0; i < tail.Count; i += 2) - { - Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[i]); - Assert.Equal(AsyncEventID.CompleteAsyncMethod, tail[i + 1]); - } + ulong chainTaskId = markerCallstacks[0].TaskId; + var chainEvents = stream.ForTask(chainTaskId); + + int resumeCount = chainEvents.Count(e => e.EventId == AsyncEventID.ResumeAsyncMethod); + Assert.Equal(ExpectedChainDepth, resumeCount); + + int completeCount = chainEvents.Count(e => e.EventId == AsyncEventID.CompleteAsyncMethod); + Assert.Equal(ExpectedChainDepth, completeCount); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_HandledException_MethodEventsWithUnwind_Marker() + { + await TaskAsync_ExceptionHandled(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_HandledException_MethodEventsWithUnwind() { - var events = CollectEvents(MethodKeywords | CoreKeywords | UnwindAsyncExceptionKeyword, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | MethodKeywords | CoreKeywords | UnwindAsyncExceptionKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_ExceptionHandled()); + RunScenarioAndFlush(() => TaskAsync_HandledException_MethodEventsWithUnwind_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // ExceptionHandled → InnerThrows (2 methods) - // InnerThrows resumes, throws → Unwind - // ExceptionHandled resumes (catches), completes - // Expected method events: Resume, Unwind, Resume, Complete - var methodEvents = stream.All + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_HandledException_MethodEventsWithUnwind_Marker)); + + Assert.NotEmpty(markerCallstacks); + + ulong chainTaskId = markerCallstacks[0].TaskId; + var chainEvents = stream.ForTask(chainTaskId); + + var sequence = chainEvents .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod or AsyncEventID.UnwindAsyncException) .Select(e => e.EventId) .ToList(); - var tail = methodEvents.Skip(methodEvents.Count - 4).ToList(); - Assert.Equal(4, tail.Count); - Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[0]); - Assert.Equal(AsyncEventID.UnwindAsyncException, tail[1]); - Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[2]); - Assert.Equal(AsyncEventID.CompleteAsyncMethod, tail[3]); + // Exactly one Unwind expected on the chain's context (InnerThrows throws once). + Assert.Equal(1, sequence.Count(id => id == AsyncEventID.UnwindAsyncException)); + + // Around the Unwind: the throwing method's Resume precedes it, and the catching + // method's Resume → Complete pair follows. + int unwindIdx = sequence.IndexOf(AsyncEventID.UnwindAsyncException); + Assert.Equal(AsyncEventID.ResumeAsyncMethod, sequence[unwindIdx - 1]); + Assert.Equal(AsyncEventID.ResumeAsyncMethod, sequence[unwindIdx + 1]); + Assert.Equal(AsyncEventID.CompleteAsyncMethod, sequence[unwindIdx + 2]); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_UnhandledException_MethodEventsWithUnwind_Marker() + { + await TaskAsync_UnhandledExceptionOuter(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_UnhandledException_MethodEventsWithUnwind() { - var events = CollectEvents(MethodKeywords | CoreKeywords | UnwindAsyncExceptionKeyword, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | MethodKeywords | CoreKeywords | UnwindAsyncExceptionKeyword, () => { try { - RunScenarioAndFlush(() => TaskAsync_UnhandledExceptionOuter()); + RunScenarioAndFlush(() => TaskAsync_UnhandledException_MethodEventsWithUnwind_Marker()); } catch (InvalidOperationException) { @@ -398,46 +427,58 @@ public void TaskAsync_UnhandledException_MethodEventsWithUnwind() var stream = ParseAllEvents(events); - // UnhandledExceptionOuter → UnhandledExceptionInner (2 methods, neither catches) - // Inner resumes, throws → Unwind - // Outer resumes (continuation), propagates → Unwind - // No CompleteAsyncMethod for either — both unwind - // Expected method events: Resume, Unwind, Resume, Unwind - var methodEvents = stream.All + // Marker -> UnhandledExceptionOuter -> UnhandledExceptionInner + const int ExpectedChainDepth = 3; + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_UnhandledException_MethodEventsWithUnwind_Marker)); + Assert.NotEmpty(markerCallstacks); + + ulong chainTaskId = markerCallstacks[0].TaskId; + var chainEvents = stream.ForTask(chainTaskId); + + var sequence = chainEvents .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod or AsyncEventID.UnwindAsyncException) .Select(e => e.EventId) .ToList(); - var tail = methodEvents.Skip(methodEvents.Count - 4).ToList(); - Assert.Equal(4, tail.Count); - Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[0]); - Assert.Equal(AsyncEventID.UnwindAsyncException, tail[1]); - Assert.Equal(AsyncEventID.ResumeAsyncMethod, tail[2]); - Assert.Equal(AsyncEventID.UnwindAsyncException, tail[3]); + // Every method in the chain unwinds (no catch); no CompleteAsyncMethod expected. + Assert.Equal(ExpectedChainDepth, sequence.Count(id => id == AsyncEventID.ResumeAsyncMethod)); + Assert.Equal(ExpectedChainDepth, sequence.Count(id => id == AsyncEventID.UnwindAsyncException)); + Assert.Equal(0, sequence.Count(id => id == AsyncEventID.CompleteAsyncMethod)); + + // Per-method ordering: each Resume is immediately followed by its Unwind. + for (int i = 0; i < sequence.Count; i += 2) + { + Assert.Equal(AsyncEventID.ResumeAsyncMethod, sequence[i]); + Assert.Equal(AsyncEventID.UnwindAsyncException, sequence[i + 1]); + } + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_ResumeAsyncCallstackEmitted_Marker() + { + await TaskAsync_DeepChain(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_ResumeAsyncCallstackEmitted() { - //System.Diagnostics.Debugger.Launch(); - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_DeepChain()); + RunScenarioAndFlush(() => TaskAsync_ResumeAsyncCallstackEmitted_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var callstacks = stream.All - .Where(e => e.EventId == AsyncEventID.ResumeAsyncCallstack) - .ToList(); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_ResumeAsyncCallstackEmitted_Marker)); + Assert.NotEmpty(markerCallstacks); - Assert.NotEmpty(callstacks); - Assert.All(callstacks, cs => + Assert.All(markerCallstacks, cs => { Assert.True(cs.FrameCount > 0, "Expected at least one frame in resume callstack"); Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero methodId in first frame"); @@ -446,7 +487,7 @@ public void TaskAsync_ResumeAsyncCallstackEmitted() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_CallstackDepthMarker() + private static async Task TaskAsync_CallstackDepthMatchesChainDepth_Marker() { await TaskAsync_Level1(); } @@ -456,23 +497,23 @@ public void TaskAsync_CallstackDepthMatchesChainDepth() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_CallstackDepthMarker()); + RunScenarioAndFlush(() => TaskAsync_CallstackDepthMatchesChainDepth_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CallstackDepthMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with TaskAsync_CallstackDepthMarker"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CallstackDepthMatchesChainDepth_Marker)); + Assert.NotEmpty(markerCallstacks); - // TaskAsync_CallstackDepthMarker → Level1 → Level2 → Level3: deepest callstack should have exactly 4 frames + // TaskAsync_CallstackDepthMarker → Level1 → Level2 → Level3: deepest callstack should have exactly 4 frames Assert.Equal(4, markerCallstacks[0].FrameCount); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_DistinctMethodIdsMarker() + private static async Task TaskAsync_CallstackFramesHaveDistinctMethodIds_Marker() { await TaskAsync_Level1(); } @@ -482,15 +523,15 @@ public void TaskAsync_CallstackFramesHaveDistinctMethodIds() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_DistinctMethodIdsMarker()); + RunScenarioAndFlush(() => TaskAsync_CallstackFramesHaveDistinctMethodIds_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_DistinctMethodIdsMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with TaskAsync_DistinctMethodIdsMarker"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CallstackFramesHaveDistinctMethodIds_Marker)); + Assert.NotEmpty(markerCallstacks); // Frames in the same callstack should have distinct methodIds (different async methods) var methodIds = markerCallstacks[0].Frames.Select(f => f.MethodId).ToList(); @@ -499,33 +540,33 @@ public void TaskAsync_CallstackFramesHaveDistinctMethodIds() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_StateRoot() + private static async Task TaskAsync_CallstackFramesHaveDistinctStates_Root_Marker() { await Task.Yield(); - await TaskAsync_StateMiddle(); + await TaskAsync_CallstackFramesHaveDistinctStates_Middle_Marker(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_StateMiddle() + private static async Task TaskAsync_CallstackFramesHaveDistinctStates_Middle_Marker() { await Task.Yield(); await Task.Yield(); - await TaskAsync_StateLeaf(); + await TaskAsync_CallstackFramesHaveDistinctStates_Leaf_Marker(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_StateLeaf() + private static async Task TaskAsync_CallstackFramesHaveDistinctStates_Leaf_Marker() { await Task.Delay(100); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_CallstackStatesMarker() + private static async Task TaskAsync_CallstackFramesHaveDistinctStates_Marker() { - await TaskAsync_StateRoot(); + await TaskAsync_CallstackFramesHaveDistinctStates_Root_Marker(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -533,15 +574,15 @@ public void TaskAsync_CallstackFramesHaveDistinctStates() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_CallstackStatesMarker()); + RunScenarioAndFlush(() => TaskAsync_CallstackFramesHaveDistinctStates_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CallstackStatesMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with marker"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CallstackFramesHaveDistinctStates_Marker)); + Assert.NotEmpty(markerCallstacks); // The deepest callstack (on the final Delay resume) should have 4 frames: // Leaf (state=3), Middle (state=2), Root (state=1), Marker (state=0) @@ -556,39 +597,35 @@ public void TaskAsync_CallstackFramesHaveDistinctStates() Assert.Equal(0, states[3]); // Marker: suspended at 1st await (state=0) } - // --- Yield at each level scenario --- - // Each frame yields after calling its child, causing separate resume events - // with progressively shrinking callstacks as outer frames complete. - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_YieldEachLevel_Marker() + private static async Task TaskAsync_YieldAtEachLevel_CallstackShrinks_Level1_Marker() { - await TaskAsync_YieldEachLevel_Level1(); + await TaskAsync_YieldAtEachLevel_CallstackShrinks_Level2_Marker(); + await Task.Yield(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_YieldEachLevel_Level1() + private static async Task TaskAsync_YieldAtEachLevel_CallstackShrinks_Level2_Marker() { - await TaskAsync_YieldEachLevel_Level2(); + await TaskAsync_YieldAtEachLevel_CallstackShrinks_Level3_Marker(); await Task.Yield(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_YieldEachLevel_Level2() + private static async Task TaskAsync_YieldAtEachLevel_CallstackShrinks_Level3_Marker() { - await TaskAsync_YieldEachLevel_Level3(); + await Task.Delay(100); await Task.Yield(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_YieldEachLevel_Level3() + private static async Task TaskAsync_YieldAtEachLevel_CallstackShrinks_Marker() { - await Task.Delay(100); - await Task.Yield(); + await TaskAsync_YieldAtEachLevel_CallstackShrinks_Level1_Marker(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -596,14 +633,14 @@ public void TaskAsync_YieldAtEachLevel_CallstackShrinks() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_YieldEachLevel_Marker()); + RunScenarioAndFlush(() => TaskAsync_YieldAtEachLevel_CallstackShrinks_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_YieldEachLevel_Marker)); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_YieldAtEachLevel_CallstackShrinks_Marker)); // After Task.Delay resumes: full chain (Level3, Level2, Level1, Marker) = 4 frames // After Level3's yield resumes: Level3 completes, chain is (Level2, Level1, Marker) = 3 frames @@ -613,35 +650,13 @@ public void TaskAsync_YieldAtEachLevel_CallstackShrinks() Assert.Contains(markerCallstacks, cs => cs.FrameCount == 2); } - // --- Append callstack race scenario --- - // Forces the chain-growth race where the parent registers as a continuation - // AFTER the child's dispatcher has already walked the callstack but BEFORE - // the dispatcher hits its next suspend/complete point. This is the exact - // window the AppendAsyncCallstack mechanism is designed to fill in. - // - // Uses a SemaphoreSlim to deterministically order events independent of TP - // scheduling latency (a pure Thread.Sleep approach is unreliable because the - // TP dispatch latency for D1 can exceed the parent's sleep window). - // - // Order of events: - // 1. Parent calls Child; Child suspends at first Yield; D1 is created and queued. - // 2. Parent calls s_appendRace_proceed.Wait() — blocks. - // 3. D1 picked up by TP. D1.Resume walks chain → 1 frame (Child), because - // Parent hasn't done `await t` yet (it's still in Wait()). - // 4. D1 calls Child.MoveNext; Child resumes past the await and calls Release(). - // 5. Parent unblocks, does `await t` — registers Parent on Child.Task. - // 6. Meanwhile Child does Thread.Sleep(200) holding D1 alive. - // 7. Child hits second Yield → SuspendAsyncContext on D1 → Append check sees - // Parent now registered → emits AppendAsyncCallstack with Parent's frame. - private static SemaphoreSlim s_appendRace_proceed; [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_AppendRace_Child() + private static async Task TaskAsync_AppendCallstack_FiresOnLateParentRegistration_Child_Marker() { await Task.Yield(); - // Inside D1.MoveNext now; Resume callstack walk already happened. s_appendRace_proceed.Release(); Thread.Sleep(200); await Task.Yield(); @@ -649,9 +664,9 @@ static async Task TaskAsync_AppendRace_Child() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_AppendRace_Parent() + private static async Task TaskAsync_AppendCallstack_FiresOnLateParentRegistration_Parent_Marker() { - Task t = TaskAsync_AppendRace_Child(); + Task t = TaskAsync_AppendCallstack_FiresOnLateParentRegistration_Child_Marker(); s_appendRace_proceed.Wait(); await t; } @@ -659,44 +674,30 @@ static async Task TaskAsync_AppendRace_Parent() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] public void TaskAsync_AppendCallstack_FiresOnLateParentRegistration() { - // System.Diagnostics.Debugger.Launch(); s_appendRace_proceed = new SemaphoreSlim(0, 1); var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_AppendRace_Parent()); + RunScenarioAndFlush(() => TaskAsync_AppendCallstack_FiresOnLateParentRegistration_Parent_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The initial Resume on the TP thread should walk only Child (race: Parent not registered yet). - var childOnlyResumes = stream - .OfType(AsyncEventID.ResumeAsyncCallstack) - .Where(e => e.FrameCount == 1 && e.HasMarkerFrame(nameof(TaskAsync_AppendRace_Child))) - .ToList(); + // The initial Resume should only include TaskAsync_AppendRace_Child (race: Parent not registered yet). + var childOnlyResumes = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_AppendCallstack_FiresOnLateParentRegistration_Child_Marker)); Assert.NotEmpty(childOnlyResumes); - // After Parent registers and Child hits its next suspend/complete hook, + // After Parent registers and Child hits its next complete hook, // an AppendAsyncCallstack should fire with the Parent frame. - var appendsWithParent = stream - .OfType(AsyncEventID.AppendAsyncCallstack) - .Where(e => e.HasMarkerFrame(nameof(TaskAsync_AppendRace_Parent))) - .ToList(); + var appendsWithParent = stream.CallstacksWithMarker(AsyncEventID.AppendAsyncCallstack, nameof(TaskAsync_AppendCallstack_FiresOnLateParentRegistration_Parent_Marker)); Assert.NotEmpty(appendsWithParent); } - // --- Negative: Append should NOT fire when the chain is already complete at Resume time --- - // The deep-chain marker awaits Level1→Level2→Level3, where Level3 awaits Task.Delay(100). - // The 100ms delay gives all parent continuations ample time to register before the - // dispatcher walks the chain. The walker should terminate at a non-box (Task.Run wrapper) - // and set LastContinuation = null, so subsequent ResumeAsyncMethod/Suspend/Complete hooks - // for the inline cascade short-circuit and emit no Append events. - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_CompleteChain_NoAppendMarker() + private static async Task TaskAsync_CompleteChain_DoesNotEmitAppendEvents_Marker() { await TaskAsync_DeepChain(); } @@ -706,7 +707,7 @@ public void TaskAsync_CompleteChain_DoesNotEmitAppendEvents() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_CompleteChain_NoAppendMarker()); + RunScenarioAndFlush(() => TaskAsync_CompleteChain_DoesNotEmitAppendEvents_Marker()); }); // DumpAllEvents(events); @@ -714,28 +715,17 @@ public void TaskAsync_CompleteChain_DoesNotEmitAppendEvents() var stream = ParseAllEvents(events); // Sanity: the marker frame must appear in the initial Resume callstack (full chain captured). - var markerCallstacks = stream - .OfType(AsyncEventID.ResumeAsyncCallstack) - .Where(e => e.HasMarkerFrame(nameof(TaskAsync_CompleteChain_NoAppendMarker))) - .ToList(); - Assert.True(markerCallstacks.Count > 0, - $"Expected initial Resume callstack to contain {nameof(TaskAsync_CompleteChain_NoAppendMarker)} (full chain at walk time)"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CompleteChain_DoesNotEmitAppendEvents_Marker)); + Assert.NotEmpty(markerCallstacks); - // No Append events should fire — the chain was complete at Resume time. - var appendEvents = stream - .OfType(AsyncEventID.AppendAsyncCallstack) + // No Append events should fire on this context — the chain was complete at Resume time. + ulong chainTaskId = markerCallstacks[0].TaskId; + var appendEvents = stream.ForTask(chainTaskId) + .Where(e => e.EventId == AsyncEventID.AppendAsyncCallstack) .ToList(); - Assert.True(appendEvents.Count == 0, - $"Expected zero AppendAsyncCallstack events for a complete-chain scenario, got {appendEvents.Count}"); + Assert.Empty(appendEvents); } - // --- Custom SynchronizationContext scenario --- - // Validates that when a non-default SynchronizationContext is active during an await, - // the dispatcher wrapping path is taken (TaskAwaiter.UnsafeOnCompletedInternal wraps the box) - // and the continuation flows through the custom context's Post back into the dispatcher's - // MoveNext. Standard Resume/Suspend/Complete events should fire normally; the marker frame - // must be visible in the Resume callstack. - private sealed class InlinePostSynchronizationContext : SynchronizationContext { private int _postCount; @@ -752,15 +742,11 @@ public override void Post(SendOrPostCallback d, object? state) [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_SyncContextMarker() + private static async Task TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker() { // Install a non-default SynchronizationContext on this thread so the await captures it. // The await's continuation will be routed via SynchronizationContextAwaitTaskContinuation, // which wraps the box in an AsyncTaskDispatcher and posts back to the context. - // - // Note: the await may resume on a different thread (the SyncContext's Post may run on - // the timer thread or another worker). Only restore the previous context if we resumed - // on the same thread, to avoid polluting an unrelated thread's SynchronizationContext. int callerThreadId = Environment.CurrentManagedThreadId; SynchronizationContext? prev = SynchronizationContext.Current; SynchronizationContext.SetSynchronizationContext(s_taskAsyncSyncContextCtx); @@ -784,7 +770,7 @@ public void TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack() var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_SyncContextMarker()); + RunScenarioAndFlush(() => TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker()); }); // DumpAllEvents(events); @@ -795,12 +781,11 @@ public void TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack() var stream = ParseAllEvents(events); - // The marker frame should appear in the Resume callstack (or via Append if the chain raced). - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_SyncContextMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected merged Resume callstack containing {nameof(TaskAsync_SyncContextMarker)}"); + // The marker frame should appear in the Resume callstack. + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker)); + Assert.NotEmpty(markerCallstacks); - // Verify the standard Create → Resume → Complete sequence fired for our context. + // Verify the standard Create → Resume → Complete sequence fired for our context. ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -814,13 +799,6 @@ public void TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack() Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); } - // --- Custom TaskScheduler scenario --- - // Validates that when a non-default TaskScheduler is active (the marker runs on a custom - // scheduler via Task.Factory.StartNew(..., scheduler)), the dispatcher wrapping path is taken - // and the continuation flows through the custom scheduler's QueueTask back into the dispatcher's - // MoveNext via TaskSchedulerAwaitTaskContinuation. Standard Resume/Suspend/Complete events - // should fire normally. - private sealed class InlineRunTaskScheduler : TaskScheduler { private int _queuedCount; @@ -839,11 +817,8 @@ protected override void QueueTask(Task task) [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_TaskSchedulerMarker() + private static async Task TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker() { - // We rely on the caller having scheduled this method on a custom TaskScheduler via - // Task.Factory.StartNew, so TaskScheduler.InternalCurrent is the custom scheduler at - // the moment of await. The await's continuation gets routed through that scheduler. await Task.Delay(100); } @@ -854,14 +829,10 @@ public void TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack() var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - // Schedule the marker on our custom scheduler so TaskScheduler.InternalCurrent - // is the custom scheduler when its first await is registered. Unwrap+GetResult - // blocks until the async chain completes, then RunScenarioAndFlush's pattern is - // inlined here (we can't reuse RunScenarioAndFlush because it uses Task.Run). try { Task.Factory.StartNew( - () => TaskAsync_TaskSchedulerMarker(), + () => TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap().GetAwaiter().GetResult(); @@ -875,20 +846,16 @@ public void TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack() // DumpAllEvents(events); - // The custom scheduler must have received at least one QueueTask call (for the outer - // task). The continuation may or may not also be queued depending on whether the runtime - // inlines it on the timer thread; what matters for this test is that the dispatcher's - // events fired for the async context. + // The custom scheduler must have received at least one QueueTask call. Assert.True(scheduler.QueuedCount >= 1, $"Expected custom TaskScheduler to receive at least one QueueTask call, got {scheduler.QueuedCount}"); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_TaskSchedulerMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected merged Resume callstack containing {nameof(TaskAsync_TaskSchedulerMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker)); + Assert.NotEmpty(markerCallstacks); - // Verify standard Create → Resume → Complete sequence for our context. + // Verify standard Create → Resume → Complete sequence for our context. ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -902,946 +869,873 @@ public void TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack() Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); } - // --- ValueTask scenario methods --- - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_Level1() + private static async Task TaskAsync_NoEventsWhenDisabled_Marker() { - await ValueTaskAsync_Level2(); + await Task.Delay(50); } - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_Level2() + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_NoEventsWhenDisabled() { - await ValueTaskAsync_Level3(); - } + for (int i = 0; i < 50; i++) + { + RunScenario(() => TaskAsync_NoEventsWhenDisabled_Marker()); + } - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_Level3() - { - await Task.Delay(100); + // Now attach a listener but don't perform any V1 async work. + var events = CollectEvents(CoreKeywords, () => { }); + + var ids = ParseAllEvents(events).EventIds; + int contextEvents = ids.Count(id => + id == AsyncEventID.CreateAsyncContext || + id == AsyncEventID.ResumeAsyncContext || + id == AsyncEventID.SuspendAsyncContext || + id == AsyncEventID.CompleteAsyncContext); + + Assert.Equal(0, contextEvents); } - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_Marker() + public static IEnumerable TaskAsyncKeywordGatekeepingData() { - await ValueTaskAsync_Level1(); + yield return new object[] { (long)CreateAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CreateAsyncContext } }; + yield return new object[] { (long)ResumeAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncContext } }; + yield return new object[] { (long)CompleteAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CompleteAsyncContext } }; + yield return new object[] { (long)UnwindAsyncExceptionKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.UnwindAsyncException } }; + yield return new object[] { (long)ResumeAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncCallstack, AsyncEventID.AppendAsyncCallstack } }; + yield return new object[] { (long)ResumeAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncMethod } }; + yield return new object[] { (long)CompleteAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CompleteAsyncMethod } }; } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_EventSequenceOrderMarker() + private static async Task TaskAsync_KeywordGatekeeping_Marker() { - await ValueTaskAsync_Marker(); + // Exercise multiple event types: exception unwind, multiple completes, method invocations. + try + { + await TaskAsync_InnerThrows(); + } + catch (InvalidOperationException) { } + await Task.Delay(50); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void ValueTaskAsync_EventSequenceOrder() + [ConditionalTheory(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + [MemberData(nameof(TaskAsyncKeywordGatekeepingData))] + public void TaskAsync_KeywordGatekeeping(long keywordValue, AsyncEventID[] allowedEventIds) { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + EventKeywords kw = (EventKeywords)keywordValue; + var allowed = new HashSet(allowedEventIds); + + var events = CollectEvents(kw, () => { - RunScenarioAndFlush(() => ValueTaskAsync_EventSequenceOrderMarker().AsTask()); + RunScenarioAndFlush(() => TaskAsync_NoEventsWhenDisabled_Marker()); }); - // DumpAllEvents(events); - var stream = ParseAllEvents(events); + var unexpected = stream.EventIds.Where(id => !allowed.Contains(id)).ToList(); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_EventSequenceOrderMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(ValueTaskAsync_EventSequenceOrderMarker)}"); + Assert.True(unexpected.Count == 0, + $"Keyword 0x{(long)kw:X}: unexpected event IDs [{string.Join(", ", unexpected)}], allowed [{string.Join(", ", allowed)}]"); + } - ulong taskId = markerCallstacks[0].TaskId; - var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_WhenAllBranchA() + { + await Task.Delay(100); + } - int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); - Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_WhenAllBranchB() + { + await Task.Delay(120); + } - int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); - Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_WhenAllBranchC() + { + await Task.Delay(140); + } - int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); - Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_WhenAll_TracksAllBranchesAndJoin_Marker() + { + await Task.WhenAll(TaskAsync_WhenAllBranchA(), TaskAsync_WhenAllBranchB(), TaskAsync_WhenAllBranchC()); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void ValueTaskAsync_MethodEventsEmitted() + public void TaskAsync_WhenAll_TracksAllBranchesAndJoin() { - var events = CollectEvents(MethodKeywords | CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => ValueTaskAsync_Marker().AsTask()); + RunScenarioAndFlush(() => TaskAsync_WhenAll_TracksAllBranchesAndJoin_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // ValueTask chain of 4 methods: Marker → Level1 → Level2 → Level3 - var methodEvents = stream.All - .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod) - .Select(e => e.EventId) - .ToList(); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAll_TracksAllBranchesAndJoin_Marker)); + Assert.NotEmpty(markerCallstacks); - int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); - int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); + // Each branch is its own async chain; its inner await of Task.Delay produces a Resume callstack containing the branch frame. + var branchACallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAllBranchA)); + var branchBCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAllBranchB)); + var branchCCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAllBranchC)); + Assert.True(branchACallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchA)}"); + Assert.True(branchBCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchB)}"); + Assert.True(branchCCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchC)}"); - Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events for ValueTask chain, got {resumeCount}"); - Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events for ValueTask chain, got {completeCount}"); - } + // Every Create must be balanced by a Complete. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void ValueTaskAsync_CallstackDepthMatchesChainDepth() - { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => - { - RunScenarioAndFlush(() => ValueTaskAsync_Marker().AsTask()); - }); + Assert.True(createCount >= 4, + $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); - // DumpAllEvents(events); + // The outer marker's chain should fire the standard Create → Resume → Complete sequence on its own TaskId, in that order. + ulong markerTaskId = markerCallstacks[0].TaskId; + var markerIds = stream.ForTask(markerTaskId).Select(e => e.EventId).ToList(); - var stream = ParseAllEvents(events); + int createIdx = markerIds.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext for the WhenAll outer marker"); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_Marker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ValueTaskAsync_Marker"); + int resumeIdx = markerIds.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create on the outer marker"); - // ValueTaskAsync_Marker → Level1 → Level2 → Level3: deepest should have 4 frames - Assert.Equal(4, markerCallstacks[0].FrameCount); + int completeIdx = markerIds.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume on the outer marker"); + + // The outer should be created exactly once. + int createCountForMarker = markerIds.Count(id => id == AsyncEventID.CreateAsyncContext); + Assert.Equal(1, createCountForMarker); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void ValueTaskAsync_CallstackFramesHaveDistinctMethodIds() + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_WhenAnyFast() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => - { - RunScenarioAndFlush(() => ValueTaskAsync_Marker().AsTask()); - }); - - // DumpAllEvents(events); - - var stream = ParseAllEvents(events); - - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_Marker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ValueTaskAsync_Marker"); - - var methodIds = markerCallstacks[0].Frames.Select(f => f.MethodId).ToList(); - Assert.Equal(methodIds.Count, methodIds.Distinct().Count()); + await Task.Delay(50); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_InnerThrows() + private static async Task TaskAsync_WhenAnySlow1() { - await Task.Delay(100); - throw new InvalidOperationException("valuetask inner throw"); + await Task.Delay(400); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_ExceptionHandled() + private static async Task TaskAsync_WhenAnySlow2() { - try - { - await ValueTaskAsync_InnerThrows(); - } - catch (InvalidOperationException) - { - } + await Task.Delay(600); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker() + private static async Task TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes_Marker() { - await ValueTaskAsync_ExceptionHandled(); + Task fast = TaskAsync_WhenAnyFast(); + Task slow1 = TaskAsync_WhenAnySlow1(); + Task slow2 = TaskAsync_WhenAnySlow2(); + + await Task.WhenAny(fast, slow1, slow2); + await Task.WhenAll(slow1, slow2); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void ValueTaskAsync_HandledException_EmitsUnwindAndComplete() + public void TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker().AsTask()); + RunScenarioAndFlush(() => TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(ValueTaskAsync_HandledException_EmitsUnwindAndCompleteMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes_Marker)); + Assert.NotEmpty(markerCallstacks); - ulong taskId = markerCallstacks[0].TaskId; - var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + // All branches — including the slow ones whose completion the outer is no longer + // strictly waiting on after WhenAny returned — must produce their own Resume + // callstacks. This proves their dispatcher lifetimes are tracked independently. + var fastCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAnyFast)); + var slow1Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAnySlow1)); + var slow2Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAnySlow2)); + Assert.True(fastCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnyFast)}"); + Assert.True(slow1Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow1)}"); + Assert.True(slow2Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow2)}"); - int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); - Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + // Every Create must be balanced by a Complete. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); - int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); - Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + Assert.True(createCount >= 4, + $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); - int unwindIdx = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); - Assert.True(unwindIdx > resumeIdx, "Expected UnwindAsyncException after Resume"); + // The outer marker's chain: exactly one Create, at least two Resumes (one after + // WhenAny, one after WhenAll on the slow branches), then Complete. + ulong markerTaskId = markerCallstacks[0].TaskId; + var markerEvents = stream.ForTask(markerTaskId); + var markerIds = markerEvents.Select(e => e.EventId).ToList(); - int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx + 1); - Assert.True(completeIdx > unwindIdx, "Expected CompleteAsyncContext after Unwind"); - } + int resumeCountForMarker = markerIds.Count(id => id == AsyncEventID.ResumeAsyncContext); + Assert.True(resumeCountForMarker >= 1, + $"Expected outer marker to be resumed at least once, got {resumeCountForMarker}"); - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_UnhandledOuter() - { - await ValueTaskAsync_UnhandledInner(); + int completeCountForMarker = markerIds.Count(id => id == AsyncEventID.CompleteAsyncContext); + Assert.True(completeCountForMarker >= 1, "Expected at least one CompleteAsyncContext for the outer marker"); + + // First event for the marker is its Create; last is a Complete. + Assert.Equal(AsyncEventID.CreateAsyncContext, markerIds[0]); + Assert.Equal(AsyncEventID.CompleteAsyncContext, markerIds[^1]); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_UnhandledInner() + private static async Task TaskAsync_RecursiveChain(int depth) { - await Task.Delay(100); - throw new InvalidOperationException("valuetask unhandled inner"); + if (depth <= 1) + { + await Task.Delay(100); + return; + } + await TaskAsync_RecursiveChain(depth - 1); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async ValueTask ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker() + private static async Task TaskAsync_CallstackDepthCappedAtMaxFrames_Marker(int depth) { - await ValueTaskAsync_UnhandledOuter(); + await TaskAsync_RecursiveChain(depth); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete() + public void TaskAsync_CallstackDepthCappedAtMaxFrames() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => + // Build a chain deeper than the 255-frame cap (byte FrameCount). The deepest + // ResumeAsyncCallstack should clamp at byte.MaxValue without crashing. + const int requestedDepth = 300; + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - try - { - RunScenarioAndFlush(() => ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker().AsTask()); - } - catch (InvalidOperationException) - { - } + RunScenarioAndFlush(() => TaskAsync_CallstackDepthCappedAtMaxFrames_Marker(requestedDepth)); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)); - Assert.True(markerCallstacks.Count > 0, $"Expected at least one callstack with {nameof(ValueTaskAsync_UnhandledException_EmitsUnwindAndCompleteMarker)}"); + // With the cap at byte.MaxValue, the initial Resume walks the first 255 frames from + // the leaf. The marker sits at the top of the chain (deeper than 255), so it appears + // in a subsequent AppendAsyncCallstack carrying the remaining frames. Use the merged + // view to validate the chain as a whole. + var mergedMarker = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CallstackDepthCappedAtMaxFrames_Marker)); + Assert.NotEmpty(mergedMarker); - ulong taskId = markerCallstacks[0].TaskId; - var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + var deepest = mergedMarker.MaxBy(cs => cs.Frames.Count); + Assert.NotNull(deepest); - int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); - Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); - - int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); - Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + // Walker should have captured at least the full requested depth across Resume+Appends. + Assert.True(deepest.Frames.Count >= requestedDepth, + $"Expected merged frame count >= {requestedDepth}, got {deepest.Frames.Count}"); - int unwindIdx1 = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); - Assert.True(unwindIdx1 > resumeIdx, "Expected first UnwindAsyncException after Resume"); + // Each individual event clamps its own FrameCount at byte.MaxValue (wire format limit). + ulong chainTaskId = deepest.TaskId; + var perEventCallstacks = stream.ForTask(chainTaskId) + .Where(e => e.EventId is AsyncEventID.ResumeAsyncCallstack or AsyncEventID.AppendAsyncCallstack) + .ToList(); + Assert.NotEmpty(perEventCallstacks); + Assert.All(perEventCallstacks, cs => Assert.True(cs.FrameCount <= byte.MaxValue)); - int unwindIdx2 = ids.IndexOf(AsyncEventID.UnwindAsyncException, unwindIdx1 + 1); - Assert.True(unwindIdx2 > unwindIdx1, "Expected second UnwindAsyncException after first Unwind"); + // At least one event must have hit the cap (otherwise the test isn't exercising it). + Assert.Contains(perEventCallstacks, cs => cs.FrameCount == byte.MaxValue); - int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx2 + 1); - Assert.True(completeIdx > unwindIdx2, "Expected CompleteAsyncContext after second Unwind"); + // Every captured frame should resolve to a managed method. + foreach (var (methodId, _) in deepest.Frames) + { + Assert.True(methodId != 0, "Frame has zero MethodId"); + var method = GetMethodNameFromMethodId(deepest.CallstackType, methodId); + Assert.True(method is not null, $"MethodId 0x{methodId:X} does not resolve to a managed method"); + } } - // --- Negative: no events when profiler is disabled --- - // Validates that the InstrumentCheckPoint + AsyncProfiler flag short-circuit kicks in - // before the listener is attached, so async work done with no listener leaves no - // context-level events behind. After attachment, only background metadata events should - // be present (no Create/Resume/Suspend/Complete from prior or current work). - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_NoEventsWhenDisabledScenario() + private static async Task TaskAsync_CallstackStressWithVaryingDepths_Marker(int depth) { - await Task.Delay(50); + await TaskAsync_RecursiveChain(depth); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_NoEventsWhenDisabled() + public void TaskAsync_CallstackStressWithVaryingDepths() { - // Run async work WITHOUT a listener attached. No keywords enabled → no events emitted. - for (int i = 0; i < 50; i++) + const int iterations = 50; + int[] depths = new int[iterations]; + var rng = new Random(42); + for (int i = 0; i < iterations; i++) + depths[i] = rng.Next(1, 60); + + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenario(() => TaskAsync_NoEventsWhenDisabledScenario()); - } + RunScenarioAndFlush(async () => + { + for (int i = 0; i < iterations; i++) + await TaskAsync_CallstackStressWithVaryingDepths_Marker(depths[i]); + }); + }); - // Now attach a listener but don't perform any V1 async work — verify no stale events - // from the previous work leaked through. - var events = CollectEvents(CoreKeywords, () => { /* no-op */ }); + // DumpAllEvents(events); - var ids = ParseAllEvents(events).EventIds; - int contextEvents = ids.Count(id => - id == AsyncEventID.CreateAsyncContext || - id == AsyncEventID.ResumeAsyncContext || - id == AsyncEventID.SuspendAsyncContext || - id == AsyncEventID.CompleteAsyncContext); + var stream = ParseAllEvents(events); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CallstackStressWithVaryingDepths_Marker)); + Assert.NotEmpty(markerCallstacks); - Assert.Equal(0, contextEvents); - } + // Every emitted callstack must have valid frame data. + foreach (var cs in markerCallstacks) + { + Assert.True(cs.FrameCount > 0, "Callstack has 0 frames"); + Assert.Equal((int)cs.FrameCount, cs.Frames.Count); + for (int f = 0; f < cs.Frames.Count; f++) + { + var (methodId, _) = cs.Frames[f]; + Assert.True(methodId != 0, $"Frame {f} has zero MethodId"); + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); + } + } - // --- Keyword gatekeeping --- - // Validates that each individual keyword only enables its corresponding event type. - // Auto-emitted infrastructure events (ResetAsyncThreadContext, AsyncProfilerMetadata) - // are always allowed. ResumeAsyncCallstackKeyword controls both ResumeAsyncCallstack - // AND AppendAsyncCallstack (V1 emits Append events under the same keyword as Resume). + // We expect at least one marker callstack per iteration. + Assert.True(markerCallstacks.Count >= iterations, + $"Expected at least {iterations} callstacks with marker, got {markerCallstacks.Count}"); - public static IEnumerable TaskAsyncKeywordGatekeepingData() - { - yield return new object[] { (long)CreateAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CreateAsyncContext } }; - yield return new object[] { (long)ResumeAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncContext } }; - yield return new object[] { (long)SuspendAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.SuspendAsyncContext } }; - yield return new object[] { (long)CompleteAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CompleteAsyncContext } }; - yield return new object[] { (long)UnwindAsyncExceptionKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.UnwindAsyncException } }; - yield return new object[] { (long)ResumeAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncCallstack, AsyncEventID.AppendAsyncCallstack } }; - yield return new object[] { (long)ResumeAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.ResumeAsyncMethod } }; - yield return new object[] { (long)CompleteAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.AsyncProfilerMetadata, AsyncEventID.CompleteAsyncMethod } }; + // Verify multiple buffer flushes occurred — proves the buffer machinery is exercised. + int bufferCount = 0; + ForEachEventBufferPayload(events, _ => bufferCount++); + Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_KeywordGatekeepingMarker() + private static async Task TaskAsync_WaitThenYield(Task gate) { - // Exercise multiple event types: exception unwind, multiple suspends, method invocations. - try - { - await TaskAsync_InnerThrows(); - } - catch (InvalidOperationException) { } - await Task.Delay(50); + await gate; + await Task.Yield(); } - [ConditionalTheory(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - [MemberData(nameof(TaskAsyncKeywordGatekeepingData))] - public void TaskAsync_KeywordGatekeeping(long keywordValue, AsyncEventID[] allowedEventIds) + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete_Marker() { - EventKeywords kw = (EventKeywords)keywordValue; - var allowed = new HashSet(allowedEventIds); + await Task.Yield(); - var events = CollectEvents(kw, () => + var tcs = new TaskCompletionSource(); + Task b1 = TaskAsync_WaitThenYield(tcs.Task); + Task b2 = TaskAsync_WaitThenYield(tcs.Task); + + tcs.SetResult(); + + await Task.WhenAll(b1, b2); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete() + { + var events = CollectEvents(CoreKeywords | ResumeAsyncCallstackKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_KeywordGatekeepingMarker()); + RunScenarioAndFlush(() => TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete_Marker()); }); + // DumpAllEvents(events); + var stream = ParseAllEvents(events); - var unexpected = stream.EventIds.Where(id => !allowed.Contains(id)).ToList(); - Assert.True(unexpected.Count == 0, - $"Keyword 0x{(long)kw:X}: unexpected event IDs [{string.Join(", ", unexpected)}], allowed [{string.Join(", ", allowed)}]"); - } + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + int resumeCount = stream.OfType(AsyncEventID.ResumeAsyncContext).Count(); + int suspendCount = stream.OfType(AsyncEventID.SuspendAsyncContext).Count(); - // --- Fork/join (WhenAll) test --- - // Validates V1 dispatcher behavior under a fork-join pattern: a single outer task awaits - // multiple parallel branches via Task.WhenAll. Each branch is its own async chain that - // completes on a (potentially) different ThreadPool thread. The outer resumes only after - // all branches have completed. This exercises: - // 1. Multi-branch chain tracking — each branch produces its own Create/Resume/Complete. - // 2. Concurrent Append safety — branches may complete on different threads simultaneously. - // 3. Outer resume after fan-in — the marker's Resume callstack reconstructs correctly - // after WhenAll's join releases the outer continuation. + // At least one root Create event. + Assert.True(createCount >= 1, + $"Expected at least one CreateAsyncContext event, got {createCount}"); - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAllBranchA() => await Task.Delay(100); + Assert.Equal(createCount, completeCount); + Assert.Equal(resumeCount, completeCount); + + // Does not emit Suspend events. + Assert.Equal(0, suspendCount); + + Assert.True(createCount >= 3, + $"Expected fan-out chain to produce at least 3 CreateAsyncContext events (root + 2 child wraps), got {createCount}"); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); + } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAllBranchB() => await Task.Delay(120); + private static async Task TaskAsync_ConfigureAwaitFalseLeaf() + { + await Task.Delay(100).ConfigureAwait(false); + } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAllBranchC() => await Task.Delay(140); + private static async Task TaskAsync_ConfigureAwaitFalseMid() + { + await TaskAsync_ConfigureAwaitFalseLeaf().ConfigureAwait(false); + } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAllMarker() + private static async Task TaskAsync_ConfigureAwaitFalse_Marker() { - await Task.WhenAll( - TaskAsync_WhenAllBranchA(), - TaskAsync_WhenAllBranchB(), - TaskAsync_WhenAllBranchC()); + await TaskAsync_ConfigureAwaitFalseMid().ConfigureAwait(false); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_WhenAll_TracksAllBranchesAndJoin() + public void TaskAsync_ConfigureAwaitFalse() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_WhenAllMarker()); + RunScenarioAndFlush(() => TaskAsync_ConfigureAwaitFalse_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The outer marker must resume after WhenAll's join releases it. Its callstack - // should contain the marker frame (proves the outer dispatcher was tracked and - // the resume callstack reconstruction works through the WhenAll join point). - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected at least one Resume callstack containing {nameof(TaskAsync_WhenAllMarker)} after WhenAll join"); - - // Each branch is its own async chain; its inner await of Task.Delay produces a - // Resume callstack containing the branch frame. - var branchACallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllBranchA)); - var branchBCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllBranchB)); - var branchCCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAllBranchC)); - Assert.True(branchACallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchA)}"); - Assert.True(branchBCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchB)}"); - Assert.True(branchCCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchC)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_ConfigureAwaitFalse_Marker)); + Assert.NotEmpty(markerCallstacks); - // Every Create must be balanced by a Complete — fork-join must not leak dispatcher - // contexts, and concurrent branch completion must not double-Create. + // The deepest callstack should include all 3 chain frames. + var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; + var frameNames = deepest.Frames + .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) + .Where(n => n is not null) + .ToList(); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseLeaf), frameNames); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseMid), frameNames); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalse_Marker), frameNames); + + // Every Create must be balanced by a Complete. int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); Assert.Equal(createCount, completeCount); - - // We expect at least 4 Create events: 3 branches + 1 outer marker (the outer's - // await of WhenAll wraps its box). More is fine — internal infrastructure tasks - // (WhenAll's join task, Task.Delay continuations) may also wrap depending on - // SyncContext/Scheduler state. The lower bound proves all our user-visible chains - // were tracked. - Assert.True(createCount >= 4, - $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); - - // The outer marker's chain should fire the standard Create → Resume → Complete - // sequence on its own TaskId, in that order. - ulong markerTaskId = markerCallstacks[0].TaskId; - var markerIds = stream.ForTask(markerTaskId).Select(e => e.EventId).ToList(); - - int createIdx = markerIds.IndexOf(AsyncEventID.CreateAsyncContext); - Assert.True(createIdx >= 0, "Expected CreateAsyncContext for the WhenAll outer marker"); - - int resumeIdx = markerIds.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); - Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create on the outer marker"); - - int completeIdx = markerIds.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); - Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume on the outer marker"); - - // The outer should be created exactly once (no double-wrap regression). - int createCountForMarker = markerIds.Count(id => id == AsyncEventID.CreateAsyncContext); - Assert.Equal(1, createCountForMarker); } - // --- Fork/join (WhenAny) test --- - // Validates V1 dispatcher behavior under WhenAny: outer resumes when the FIRST branch - // completes; the remaining branches continue running in the background. This is - // structurally different from WhenAll because: - // 1. Outer is resumed mid-fan-in, while sibling branches are still alive. - // 2. The outer dispatcher may be resumed MORE THAN ONCE (here: once after WhenAny, - // then again after WhenAll on the slow branches), exercising the resume-of-same- - // context cycle without re-Creating the dispatcher. - // 3. Branch dispatcher lifetimes are independent of the outer's WhenAny return — - // we still observe their Create/Resume/Complete events when they finish later. - - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAnyFast() => await Task.Delay(50); - - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAnySlow1() => await Task.Delay(400); - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAnySlow2() => await Task.Delay(600); + private static async Task TaskAsync_FaultedInner() + { + await Task.Delay(50); + throw new InvalidOperationException("test fault"); + } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_WhenAnyMarker() + private static async Task TaskAsync_FaultedTask_Marker() { - Task fast = TaskAsync_WhenAnyFast(); - Task slow1 = TaskAsync_WhenAnySlow1(); - Task slow2 = TaskAsync_WhenAnySlow2(); - - await Task.WhenAny(fast, slow1, slow2); - - // Ensure the slow branches actually complete before the scenario ends so their - // Create/Resume/Complete events are observable in the trace. This also forces a - // second suspend/resume cycle on the outer marker. - await Task.WhenAll(slow1, slow2); + try + { + await TaskAsync_FaultedInner(); + } + catch (InvalidOperationException) + { + } } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes() + public void TaskAsync_FaultedTask() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | UnwindAsyncExceptionKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_WhenAnyMarker()); + RunScenarioAndFlush(() => TaskAsync_FaultedTask_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The outer marker is resumed at least once (after WhenAny releases it). Its - // callstack must contain the marker frame. - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnyMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected at least one Resume callstack containing {nameof(TaskAsync_WhenAnyMarker)} after WhenAny"); - - // All branches — including the slow ones whose completion the outer is no longer - // strictly waiting on after WhenAny returned — must produce their own Resume - // callstacks. This proves their dispatcher lifetimes are tracked independently. - var fastCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnyFast)); - var slow1Callstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnySlow1)); - var slow2Callstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_WhenAnySlow2)); - Assert.True(fastCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnyFast)}"); - Assert.True(slow1Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow1)}"); - Assert.True(slow2Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow2)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_FaultedTask_Marker)); + Assert.NotEmpty(markerCallstacks); - // Every Create must be balanced by a Complete — concurrent fan-in with independent - // sibling lifetimes must not leak or double-count dispatcher contexts. + // Every dispatcher that was Created must Complete, even on the fault path. int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); Assert.Equal(createCount, completeCount); - // At least 4 Creates: 3 branches + 1 outer marker. - Assert.True(createCount >= 4, - $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); + int unwindCount = stream.OfType(AsyncEventID.UnwindAsyncException).Count(); + Assert.True(unwindCount > 0, + "Expected at least one UnwindAsyncException event for the faulted inner task"); + } - // The outer marker's chain: exactly one Create, at least two Resumes (one after - // WhenAny, one after WhenAll on the slow branches), then Complete. This validates - // resume-of-same-context cycles without re-Creating the dispatcher (no double-wrap). - ulong markerTaskId = markerCallstacks[0].TaskId; - var markerEvents = stream.ForTask(markerTaskId); - var markerIds = markerEvents.Select(e => e.EventId).ToList(); + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_CancelledInner(CancellationToken ct) + { + await Task.Delay(5000, ct); + } - int createCountForMarker = markerIds.Count(id => id == AsyncEventID.CreateAsyncContext); - Assert.Equal(1, createCountForMarker); + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_TaskCancellation_Marker() + { + using var cts = new CancellationTokenSource(); + Task inner = TaskAsync_CancelledInner(cts.Token); + cts.CancelAfter(50); + try + { + await inner; + } + catch (OperationCanceledException) + { + } + } - int resumeCountForMarker = markerIds.Count(id => id == AsyncEventID.ResumeAsyncContext); - // Resume count is timing-sensitive: ideally the outer suspends/resumes twice (after - // WhenAny, then after the subsequent WhenAll on the slow branches). But under load, - // by the time WhenAny returns and we reach the WhenAll, the slow tasks may have - // already completed — in which case WhenAll returns synchronously without a second - // suspend/resume. Either shape is correct runtime behavior; we only require >=1 - // (proves the outer was resumed at all). - Assert.True(resumeCountForMarker >= 1, - $"Expected outer marker to be resumed at least once, got {resumeCountForMarker}"); + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void TaskAsync_TaskCancellation() + { + var events = CollectEvents(ResumeAsyncCallstackKeyword | UnwindAsyncExceptionKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => TaskAsync_TaskCancellation_Marker()); + }); - // Note: We don't assert an exact count of CompleteAsyncContext for the marker. V1's - // Suspend/Complete events don't carry an explicit TaskId in the wire format; the parser - // recovers it from the active context. The parser pops on Complete but NOT on Suspend, - // so when the marker suspends (awaiting WhenAll on the slow branches) and a sibling - // branch's Complete then fires, the parser misattributes that Complete to the still- - // active marker context. So the parsed Complete count for the marker may be >1 even - // though the runtime emitted exactly one Complete for it. The overall Create==Complete - // balance check above already covers the no-leak guarantee. - int completeCountForMarker = markerIds.Count(id => id == AsyncEventID.CompleteAsyncContext); - Assert.True(completeCountForMarker >= 1, "Expected at least one CompleteAsyncContext for the outer marker"); + // DumpAllEvents(events); - // First event for the marker is its Create; last is a Complete. - Assert.Equal(AsyncEventID.CreateAsyncContext, markerIds[0]); - Assert.Equal(AsyncEventID.CompleteAsyncContext, markerIds[^1]); - } + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_TaskCancellation_Marker)); + Assert.NotEmpty(markerCallstacks); - // --- Callstack cap / overflow / stress tests --- - // Mirrors the V2 versions but uses V1 (Task-based) recursive chains. The walker shares - // the same buffer/cap infrastructure (byte FrameCount, rent-on-overflow fallback) so these - // tests guard the same code paths from the V1 entry point. + // Every Create must be balanced by a Complete on the cancellation path. + int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); + int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); + Assert.Equal(createCount, completeCount); - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_RecursiveChain(int depth) - { - if (depth <= 1) - { - await Task.Delay(100); - return; - } - await TaskAsync_RecursiveChain(depth - 1); + // At least 2 Creates: inner cancelled task + outer marker. Both must Complete. + Assert.True(createCount >= 2, + $"Expected at least 2 CreateAsyncContext events (inner + marker), got {createCount}"); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_RecursiveChainMarker(int depth) + private static async ValueTask ValueTaskAsync_EventSequenceOrder_Marker() { - await TaskAsync_RecursiveChain(depth); + await ValueTaskAsync_Level1(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_CallstackDepthCappedAtMaxFrames() + public void ValueTaskAsync_EventSequenceOrder() { - // Build a chain deeper than the 255-frame cap (byte FrameCount). The deepest - // ResumeAsyncCallstack should clamp at byte.MaxValue without crashing. - const int requestedDepth = 300; - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_RecursiveChainMarker(requestedDepth)); + RunScenarioAndFlush(() => ValueTaskAsync_EventSequenceOrder_Marker().AsTask()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var callstacks = stream.OfType(AsyncEventID.ResumeAsyncCallstack).ToList(); - Assert.True(callstacks.Count >= 1, "Expected at least one callstack"); - // Walker caps frames at byte.MaxValue. Requested depth is 300, capped to 255. - var deepest = callstacks.MaxBy(cs => cs.FrameCount); - Assert.Equal(byte.MaxValue, deepest!.FrameCount); - Assert.Equal((int)deepest.FrameCount, deepest.Frames.Count); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ValueTaskAsync_EventSequenceOrder_Marker)); + Assert.NotEmpty(markerCallstacks); - // Every captured frame should resolve to a managed method. - foreach (var (methodId, _) in deepest.Frames) - { - Assert.True(methodId != 0, "Frame has zero MethodId"); - var method = GetMethodNameFromMethodId(deepest.CallstackType, methodId); - Assert.True(method is not null, $"MethodId 0x{methodId:X} does not resolve to a managed method"); - } + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_CallstackStressWithVaryingDepths() + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async ValueTask ValueTaskAsync_MethodEventsEmitted_Marker() { - // Stress test: many V1 async invocations with varying chain depths. Varying sizes - // place some callstacks at buffer boundaries, naturally exercising the overflow/rewind - // path in the shared callstack emission code. - const int iterations = 50; - int[] depths = new int[iterations]; - var rng = new Random(42); - for (int i = 0; i < iterations; i++) - depths[i] = rng.Next(1, 60); + await ValueTaskAsync_Level1(); + } - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_MethodEventsEmitted() + { + var events = CollectEvents(MethodKeywords | CoreKeywords, () => { - RunScenarioAndFlush(async () => - { - for (int i = 0; i < iterations; i++) - await TaskAsync_RecursiveChainMarker(depths[i]); - }); + RunScenarioAndFlush(() => ValueTaskAsync_MethodEventsEmitted_Marker().AsTask()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var callstacks = stream - .OfType(AsyncEventID.ResumeAsyncCallstack) - .Where(e => e.HasMarkerFrame(nameof(TaskAsync_RecursiveChainMarker))) - .ToList(); - // Every emitted callstack must have valid frame data. - foreach (var cs in callstacks) - { - Assert.True(cs.FrameCount > 0, "Callstack has 0 frames"); - Assert.Equal((int)cs.FrameCount, cs.Frames.Count); - for (int f = 0; f < cs.Frames.Count; f++) - { - var (methodId, _) = cs.Frames[f]; - Assert.True(methodId != 0, $"Frame {f} has zero MethodId"); - var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); - Assert.True(method is not null, $"Frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); - } - } + var methodEvents = stream.All + .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod) + .Select(e => e.EventId) + .ToList(); - // We expect at least one marker callstack per iteration (some may not be the deepest - // due to mid-chain dispatcher walks). Use >= as the strict count varies with timing. - Assert.True(callstacks.Count >= iterations, - $"Expected at least {iterations} callstacks with marker, got {callstacks.Count}"); + int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); + int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); - // Verify multiple buffer flushes occurred — proves the buffer machinery is exercised. - int bufferCount = 0; - ForEachEventBufferPayload(events, _ => bufferCount++); - Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); + // Marker → Level1 → Level2 → Level3 + Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events for ValueTask chain, got {resumeCount}"); + Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events for ValueTask chain, got {completeCount}"); } - // --- Single-threaded compatible test --- - // Validates that V1 dispatcher events fire correctly on platforms without threading - // support (single-threaded WASM). Uses TaskCompletionSource as a deterministic - // suspension/resume primitive instead of Task.Delay / Task.Run, so the test runs - // cleanly on the single-threaded runtime. - // - // The chain suspends on `await gate`; SetResult drives synchronous resumption that - // unwinds the entire chain in-order on the calling thread. This exercises the V1 - // inline-cascade code path (1 Create for the whole chain, full callstack reconstruction). - // - // Coverage in one test: Create/Resume/Complete events fire, callstack reconstruction - // works across all chain levels, marker frame visibility, no double-wrap, balanced - // Create/Complete (no leaks). The IsRuntimeAsyncSupported gate (looser than the - // IsRuntimeAsyncAndThreadingSupported gate used by the rest of the V1 suite) lets this - // test run on single-threaded WASM. [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_SingleThreadInner(Task gate) + private static async ValueTask ValueTaskAsync_CallstackDepthMatchesChainDepth_Marker() { - await gate; + await ValueTaskAsync_Level1(); } - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_SingleThreadMid(Task gate) + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_CallstackDepthMatchesChainDepth() { - await TaskAsync_SingleThreadInner(gate); + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + { + RunScenarioAndFlush(() => ValueTaskAsync_CallstackDepthMatchesChainDepth_Marker().AsTask()); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ValueTaskAsync_CallstackDepthMatchesChainDepth_Marker)); + Assert.NotEmpty(markerCallstacks); + + // Marker → Level1 → Level2 → Level3 + Assert.Equal(4, markerCallstacks[0].FrameCount); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_SingleThreadMarker(Task gate) + private static async ValueTask ValueTaskAsync_CallstackFramesHaveDistinctMethodIds_Marker() { - await TaskAsync_SingleThreadMid(gate); + await ValueTaskAsync_Level1(); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] - public async Task TaskAsync_SingleThreadCompatible_ChainEventsAndCallstack() + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void ValueTaskAsync_CallstackFramesHaveDistinctMethodIds() { - var events = await CollectEventsAsync(ResumeAsyncCallstackKeyword | CoreKeywords | MethodKeywords, async () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - var tcs = new TaskCompletionSource(); - Task chain = TaskAsync_SingleThreadMarker(tcs.Task); - // chain is now suspended: Inner awaits gate, Mid awaits Inner, Marker awaits Mid. - // SetResult (default, NOT RunContinuationsAsynchronously which requires ThreadPool) - // runs continuations synchronously inline on this thread, driving the entire chain - // to completion in-order without involving any timer or worker thread. - tcs.SetResult(); - await chain; + RunScenarioAndFlush(() => ValueTaskAsync_CallstackFramesHaveDistinctMethodIds_Marker().AsTask()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The marker frame must appear in a Resume callstack — proves the chain was walkable. - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_SingleThreadMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected at least one Resume callstack containing {nameof(TaskAsync_SingleThreadMarker)}"); - - // All 3 chain frames must be reconstructable in the deepest callstack — proves the - // inline-cascade walker crosses every level without dropping any. - var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; - var frameNames = deepest.Frames - .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) - .Where(n => n is not null) - .ToList(); - Assert.Contains(nameof(TaskAsync_SingleThreadInner), frameNames); - Assert.Contains(nameof(TaskAsync_SingleThreadMid), frameNames); - Assert.Contains(nameof(TaskAsync_SingleThreadMarker), frameNames); - - // No dispatcher leak: every Create balanced by a Complete on the synchronous cascade path. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); - - // Inline-cascade optimization: exactly 1 Create for the entire chain (the leaf's - // non-box TCS wrapping). A regression that re-introduced per-level wrapping would - // push this higher. This is the same strong invariant as the ConfigureAwait(false) - // test, validated here on the single-threaded code path. - Assert.Equal(1, createCount); - - // Method-level instrumentation: each async method's MoveNext invocation emits a - // Resume/Complete pair (distinct from the dispatcher-level Resume/Complete events - // above). For our 3-method chain, every method must fire at least one Resume and - // at least one Complete. - int methodResumeCount = stream.OfType(AsyncEventID.ResumeAsyncMethod).Count(); - int methodCompleteCount = stream.OfType(AsyncEventID.CompleteAsyncMethod).Count(); - Assert.True(methodResumeCount >= 3, - $"Expected at least 3 ResumeAsyncMethod events (one per chain level), got {methodResumeCount}"); - Assert.True(methodCompleteCount >= 3, - $"Expected at least 3 CompleteAsyncMethod events (one per chain level), got {methodCompleteCount}"); - - // Method-level events should be balanced: every method resume must have a matching - // complete on this synchronous, exception-free path. - Assert.Equal(methodResumeCount, methodCompleteCount); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ValueTaskAsync_CallstackFramesHaveDistinctMethodIds_Marker)); + Assert.NotEmpty(markerCallstacks); - // No AppendAsyncCallstack events should be emitted in this scenario. The original - // Resume callstack already contained the full 3-frame chain (Inner -> Mid -> Marker), - // and Marker's parent chain never grew during the synchronous cascade (the test never - // awaits Marker's task before the cascade completes). Any Append here would be a - // regression of the duplicate-emission bug where the entering-box overload of - // Append re-emitted the LastContinuation box that was already in the trace. - int appendCount = stream.OfType(AsyncEventID.AppendAsyncCallstack).Count(); - Assert.Equal(0, appendCount); + var methodIds = markerCallstacks[0].Frames.Select(f => f.MethodId).ToList(); + Assert.Equal(methodIds.Count, methodIds.Distinct().Count()); } - // --- ConfigureAwait(false) chain test --- - // Validates that ConfigureAwait(false) at every level of a chain does NOT break the - // dispatcher cascade or cause the box to be wrapped more than once. ConfigureAwait(false) - // routes through ConfiguredTaskAwaitable instead of TaskAwaiter, which has its own - // UnsafeOnCompletedInternal path. A regression here would either drop chain frames or - // emit multiple Create events per logical async method. - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_ConfigureAwaitFalseLeaf() + private static async ValueTask ValueTaskAsync_HandledException_EmitsUnwindAndComplete_InnerThrows_Marker() { - await Task.Delay(100).ConfigureAwait(false); + await Task.Delay(100); + throw new InvalidOperationException("valuetask inner throw"); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_ConfigureAwaitFalseMid() + private static async ValueTask ValueTaskAsync_HandledException_EmitsUnwindAndComplete_Handled_Marker() { - await TaskAsync_ConfigureAwaitFalseLeaf().ConfigureAwait(false); + try + { + await ValueTaskAsync_HandledException_EmitsUnwindAndComplete_InnerThrows_Marker(); + } + catch (InvalidOperationException) + { + } } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_ConfigureAwaitFalseMarker() + private static async ValueTask ValueTaskAsync_HandledException_EmitsUnwindAndComplete_Marker() { - await TaskAsync_ConfigureAwaitFalseMid().ConfigureAwait(false); + await ValueTaskAsync_HandledException_EmitsUnwindAndComplete_Handled_Marker(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_ConfigureAwaitFalse_DoesNotBreakCascadeOrDoubleWrap() + public void ValueTaskAsync_HandledException_EmitsUnwindAndComplete() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_ConfigureAwaitFalseMarker()); + RunScenarioAndFlush(() => ValueTaskAsync_HandledException_EmitsUnwindAndComplete_Marker().AsTask()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The full chain (Leaf -> Mid -> Marker) must appear in the Resume callstack. - // For ConfigureAwait(false) box-to-box chains with no SyncContext/Scheduler, the - // runtime takes the inline-cascade optimization: a single dispatcher walks the - // entire chain instead of wrapping each level. This is the OPTIMAL trace shape — - // minimum dispatcher overhead, full chain visibility via the callstack. - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_ConfigureAwaitFalseMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected at least one Resume callstack containing {nameof(TaskAsync_ConfigureAwaitFalseMarker)} (cascade not broken)"); - - // The deepest callstack should include all 3 chain frames — proves the chain walk - // crossed every ConfigureAwait(false) level without dropping any. - var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; - var frameNames = deepest.Frames - .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) - .Where(n => n is not null) - .ToList(); - Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseLeaf), frameNames); - Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseMid), frameNames); - Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseMarker), frameNames); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ValueTaskAsync_HandledException_EmitsUnwindAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); - // Every Create must be balanced by a Complete — no leaks. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); - // Strongest no-double-wrap check: the cascade optimization should produce exactly - // 1 dispatcher for the entire chain (the leaf's non-box Task.Delay wrapping). A - // regression that re-introduced per-level wrapping would push this to 3+. - Assert.Equal(1, createCount); + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx > resumeIdx, "Expected UnwindAsyncException after Resume"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx + 1); + Assert.True(completeIdx > unwindIdx, "Expected CompleteAsyncContext after Unwind"); } - // --- Faulted task test --- - // Validates that an async method that throws (and whose exception is caught upstream) - // still produces a clean trace: balanced Create/Complete events, an UnwindAsyncException - // event reflecting the unwound frames, and the marker's Resume callstack present. [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_FaultedInner() + private static async ValueTask ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_UnhandledOuter_Marker() { - await Task.Delay(50); - throw new InvalidOperationException("test fault"); + await ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_UnhandledInner_Marker(); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_FaultedMarker() + private static async ValueTask ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_UnhandledInner_Marker() { - try - { - await TaskAsync_FaultedInner(); - } - catch (InvalidOperationException) - { - } + await Task.Delay(100); + throw new InvalidOperationException("valuetask unhandled inner"); + } + + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async ValueTask ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_Marker() + { + await ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_UnhandledOuter_Marker(); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_FaultedTask_BalancedEventsAndUnwindEmitted() + public void ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | UnwindAsyncExceptionKeyword | CoreKeywords, () => + var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords | UnwindAsyncExceptionKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_FaultedMarker()); + try + { + RunScenarioAndFlush(() => ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_Marker().AsTask()); + } + catch (InvalidOperationException) + { + } }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The marker must still resume and complete — exception propagation does not orphan - // the dispatcher. - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_FaultedMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected at least one Resume callstack containing {nameof(TaskAsync_FaultedMarker)}"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ValueTaskAsync_UnhandledException_EmitsUnwindAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); - // No leak: every dispatcher that was Created must Complete, even on the fault path. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); - // The runtime emits UnwindAsyncException when an async method completes with an - // exception (AsyncTaskMethodBuilder.SetException path). - int unwindCount = stream.OfType(AsyncEventID.UnwindAsyncException).Count(); - Assert.True(unwindCount > 0, - "Expected at least one UnwindAsyncException event for the faulted inner task"); + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx1 = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx1 > resumeIdx, "Expected first UnwindAsyncException after Resume"); + + int unwindIdx2 = ids.IndexOf(AsyncEventID.UnwindAsyncException, unwindIdx1 + 1); + Assert.True(unwindIdx2 > unwindIdx1, "Expected second UnwindAsyncException after first Unwind"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx2 + 1); + Assert.True(completeIdx > unwindIdx2, "Expected CompleteAsyncContext after second Unwind"); } - // --- Cancellation test --- - // Validates that cancellation (OperationCanceledException flowing through the chain) - // produces a well-formed trace with balanced events and the cancelled chain's marker - // visible in a Resume callstack. + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_SingleThread_ChainEventsAndCallstack_Inner_Marker(Task gate) + { + await gate; + } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_CancelledInner(CancellationToken ct) + private static async Task TaskAsync_SingleThread_ChainEventsAndCallstack_Mid_Marker(Task gate) { - await Task.Delay(5000, ct); + await TaskAsync_SingleThread_ChainEventsAndCallstack_Inner_Marker(gate); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task TaskAsync_CancelledMarker() + private static async Task TaskAsync_SingleThread_ChainEventsAndCallstack_Marker(Task gate) { - using var cts = new CancellationTokenSource(); - Task inner = TaskAsync_CancelledInner(cts.Token); - cts.CancelAfter(50); - try - { - await inner; - } - catch (OperationCanceledException) - { - } + await TaskAsync_SingleThread_ChainEventsAndCallstack_Mid_Marker(gate); } - [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_Cancellation_BalancedEvents() + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task TaskAsync_SingleThread_ChainEventsAndCallstack() { - var events = CollectEvents(ResumeAsyncCallstackKeyword | UnwindAsyncExceptionKeyword | CoreKeywords, () => + var events = await CollectEventsAsync(ResumeAsyncCallstackKeyword | CoreKeywords | MethodKeywords, async () => { - RunScenarioAndFlush(() => TaskAsync_CancelledMarker()); + var tcs = new TaskCompletionSource(); + Task chain = TaskAsync_SingleThread_ChainEventsAndCallstack_Marker(tcs.Task); + tcs.SetResult(); + await chain; }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - // The marker must resume and produce a callstack — cancellation propagation does - // not orphan the dispatcher chain. - var markerCallstacks = stream.MergedResumeCallstacksWithMarker(nameof(TaskAsync_CancelledMarker)); - Assert.True(markerCallstacks.Count > 0, - $"Expected at least one Resume callstack containing {nameof(TaskAsync_CancelledMarker)}"); + // The marker frame must appear in a Resume callstack — proves the chain was walkable. + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_SingleThread_ChainEventsAndCallstack_Marker)); + Assert.NotEmpty(markerCallstacks); + + var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; + var frameNames = deepest.Frames + .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) + .Where(n => n is not null) + .ToList(); + Assert.Contains(nameof(TaskAsync_SingleThread_ChainEventsAndCallstack_Inner_Marker), frameNames); + Assert.Contains(nameof(TaskAsync_SingleThread_ChainEventsAndCallstack_Mid_Marker), frameNames); + Assert.Contains(nameof(TaskAsync_SingleThread_ChainEventsAndCallstack_Marker), frameNames); - // No leak: every Create must be balanced by a Complete on the cancellation path. + // Create balanced by Complete on the synchronous cascade path. int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); Assert.Equal(createCount, completeCount); - // At least 2 Creates: inner cancelled task + outer marker. Both must Complete. - Assert.True(createCount >= 2, - $"Expected at least 2 CreateAsyncContext events (inner + marker), got {createCount}"); + // Inline-cascade optimization: exactly 1 Create for the entire chain. + Assert.Equal(1, createCount); + + int methodResumeCount = stream.OfType(AsyncEventID.ResumeAsyncMethod).Count(); + int methodCompleteCount = stream.OfType(AsyncEventID.CompleteAsyncMethod).Count(); + + // Method-level events should be balanced. + Assert.Equal(methodResumeCount, methodCompleteCount); + + // No AppendAsyncCallstack events should be emitted in this scenario. + int appendCount = stream.OfType(AsyncEventID.AppendAsyncCallstack).Count(); + Assert.Equal(0, appendCount); } } } 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 new file mode 100644 index 00000000000000..4f0f70f8272e75 --- /dev/null +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Runtime.CompilerServices/AsyncProfilerV2Tests.cs @@ -0,0 +1,1537 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace System.Threading.Tasks.Tests +{ + /// + /// Tests for V2 (runtime-async) async profiler event emission. All scenario methods use + /// [RuntimeAsyncMethodGeneration(true)] to ensure they exercise the runtime-async path. + /// V2 emits Create/Resume/Suspend/Complete callstacks natively from the runtime dispatch + /// loop, unlike V1 which uses the AsyncStateMachineBox dispatcher wrapper. + /// + public partial class AsyncProfilerTests + { + // --- V2 (runtime-async) scenario helpers --- + // Named with RuntimeAsync_* prefix to mirror the V1 TaskAsync_* convention and the + // RuntimeAsync_* test method prefix. All use RuntimeAsyncMethodGeneration(true) to + // force the runtime-async code path. + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_SingleYield() + { + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_ChainedYield() + { + await RuntimeAsync_InnerYield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_InnerYield() + { + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_OuterCatches() + { + try + { + await RuntimeAsync_InnerThrows(); + } + catch (InvalidOperationException) + { + } + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_InnerThrows() + { + await Task.Yield(); + throw new InvalidOperationException("inner"); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_DeepOuterCatches() + { + try + { + await RuntimeAsync_DeepMiddle(); + } + catch (InvalidOperationException) + { + } + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_DeepMiddle() + { + await RuntimeAsync_DeepInnerThrows(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_DeepInnerThrows() + { + await Task.Yield(); + throw new InvalidOperationException("deep inner"); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_DeepUnhandledOuter() + { + await RuntimeAsync_DeepUnhandledMiddle(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_DeepUnhandledMiddle() + { + await RuntimeAsync_DeepUnhandledInnerThrows(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_DeepUnhandledInnerThrows() + { + await Task.Yield(); + throw new InvalidOperationException("deep unhandled"); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_RecursiveChain(int depth) + { + if (depth <= 1) + { + await Task.Yield(); + return; + } + await RuntimeAsync_RecursiveChain(depth - 1); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_WrapperTestA(List<(string MethodName, int WrapperSlot)> captures) + { + await RuntimeAsync_WrapperTestB(captures); + captures.Add((nameof(RuntimeAsync_WrapperTestA), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestA)))); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_WrapperTestB(List<(string MethodName, int WrapperSlot)> captures) + { + await RuntimeAsync_WrapperTestC(captures); + captures.Add((nameof(RuntimeAsync_WrapperTestB), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestB)))); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task RuntimeAsync_WrapperTestC(List<(string MethodName, int WrapperSlot)> captures) + { + await Task.Yield(); + captures.Add((nameof(RuntimeAsync_WrapperTestC), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestC)))); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_EventBufferHeaderFormat() + { + var events = await CollectEventsAsync(CoreKeywords, RuntimeAsync_SingleYield); + + // DumpAllEvents(events); + + int buffersChecked = 0; + ForEachEventBufferPayload(events, buffer => + { + EventBufferHeader? parsed = ParseEventBufferHeader(buffer); + Assert.NotNull(parsed); + EventBufferHeader header = parsed.Value; + + Assert.Equal(1, header.Version); + Assert.Equal((uint)buffer.Length, header.TotalSize); + Assert.True(header.AsyncThreadContextId > 0, "Async thread context ID should be positive"); + Assert.True(header.OsThreadId != 0, "OS thread ID should be non-zero"); + Assert.True(header.StartTimestamp > 0, "Start timestamp should be positive"); + Assert.True(header.EndTimestamp >= header.StartTimestamp, $"End timestamp ({header.EndTimestamp}) should be >= start timestamp ({header.StartTimestamp})"); + + int eventCount = 0; + ParseEventBuffer(buffer, (AsyncEventID eventId, ReadOnlySpan buf, ref int idx) => + { + eventCount++; + return SkipEventPayload(eventId, buf, ref idx); + }); + + Assert.Equal(header.EventCount, (uint)eventCount); + Assert.True(header.EventCount > 0, "Expected at least one event in buffer"); + + buffersChecked++; + }); + + Assert.True(buffersChecked > 0, "Expected at least one buffer"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_EventsEmitted() + { + var events = await CollectEventsAsync(AllKeywords, RuntimeAsync_SingleYield); + + // DumpAllEvents(events); + + Assert.True(events.Events.Count > 0, "Expected at least one AsyncEvents event to be emitted"); + Assert.Contains(events.Events, e => e.EventId == AsyncEventsId); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SuspendResumeCompleteMarker() + { + await Task.Yield(); + await RuntimeAsync_SingleYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_SuspendResumeCompleteEvents() + { + var events = await CollectEventsAsync(CallstackKeywords, SuspendResumeCompleteMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // Find our context via marker callstack. + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(SuspendResumeCompleteMarker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with SuspendResumeCompleteMarker"); + + ulong taskId = markerCallstacks[0].TaskId; + var taskEvts = stream.ForTask(taskId); + var ids = taskEvts.Select(e => e.EventId).ToList(); + + Assert.Contains(AsyncEventID.ResumeAsyncContext, ids); + Assert.Contains(AsyncEventID.SuspendAsyncContext, ids); + Assert.Contains(AsyncEventID.CompleteAsyncContext, ids); + } + + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task ContextLifecycleMarker() + { + await Task.Yield(); + await RuntimeAsync_SingleYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ContextEventIdLifecycle() + { + var events = await CollectEventsAsync(CallstackKeywords, ContextLifecycleMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // Find events in the context that contains our marker method. + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ContextLifecycleMarker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ContextLifecycleMarker"); + + ulong taskId = markerCallstacks[0].TaskId; + Assert.True(taskId > 0, "Context ID should be non-zero"); + + var taskEvts = stream.ForTask(taskId); + var ids = taskEvts.Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext in context events"); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after CreateAsyncContext"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ResumeCompleteMethodEvents() + { + var events = await CollectEventsAsync(MethodKeywords, RuntimeAsync_ChainedYield); + + // DumpAllEvents(events); + + var ids = ParseAllEvents(events).EventIds; + + Assert.Contains(AsyncEventID.ResumeAsyncMethod, ids); + Assert.Contains(AsyncEventID.CompleteAsyncMethod, ids); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task EventSequenceOrderMarker() + { + await Task.Yield(); + await RuntimeAsync_SingleYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_EventSequenceOrder() + { + var events = await CollectEventsAsync(CallstackKeywords, EventSequenceOrderMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // Find our context via marker callstack. + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(EventSequenceOrderMarker)); + Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with EventSequenceOrderMarker"); + + ulong taskId = markerCallstacks[0].TaskId; + var taskEvts = stream.ForTask(taskId); + var ids = taskEvts.Select(e => e.EventId).ToList(); + + // Verify the expected lifecycle sequence exists in order. + int resumeIdx1 = ids.IndexOf(AsyncEventID.ResumeAsyncContext); + Assert.True(resumeIdx1 >= 0, "Expected first ResumeAsyncContext"); + + int suspendIdx = ids.IndexOf(AsyncEventID.SuspendAsyncContext, resumeIdx1 + 1); + Assert.True(suspendIdx > resumeIdx1, "Expected SuspendAsyncContext after first Resume"); + + int resumeIdx2 = ids.IndexOf(AsyncEventID.ResumeAsyncContext, suspendIdx + 1); + Assert.True(resumeIdx2 > suspendIdx, "Expected second ResumeAsyncContext after Suspend"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx2 + 1); + Assert.True(completeIdx > resumeIdx2, "Expected CompleteAsyncContext after second Resume"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CreateAsyncContextEmittedOnFirstAwait() + { + var events = await CollectEventsAsync(CreateAsyncContextKeyword | CompleteAsyncContextKeyword, RuntimeAsync_SingleYield); + + // DumpAllEvents(events); + + var ids = ParseAllEvents(events).EventIds; + Assert.Contains(AsyncEventID.CreateAsyncContext, ids); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CreateCallstackMarker() + { + await RuntimeAsync_SingleYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait() + { + var events = await CollectEventsAsync(CallstackKeywords, CreateCallstackMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateCallstackMarker)); + + Assert.NotEmpty(createCallstacks); + Assert.All(createCallstacks, cs => + { + Assert.True(cs.FrameCount > 0, "Expected at least one frame in create callstack"); + Assert.True(cs.TaskId != 0, "Expected non-zero task ID in create callstack"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); + }); + } + + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CreateCallstackDepthMarker() + { + await RuntimeAsync_ChainedYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CreateCallstackDepthMatchesChain() + { + var events = await CollectEventsAsync(CallstackKeywords, CreateCallstackDepthMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateCallstackDepthMarker)); + + // The expected [NoInlining] frames in order (innermost first): + // RuntimeAsync_InnerYield -> RuntimeAsync_ChainedYield -> CreateCallstackDepthMarker + Assert.NotEmpty(createCallstacks); + string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_ChainedYield), nameof(CreateCallstackDepthMarker)]; + Assert.True( + HasCallstackWithExpectedFrames(createCallstacks, expectedFrames), + $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SuspendCallstackMarker() + { + await Task.Yield(); + await RuntimeAsync_SingleYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait() + { + var events = await CollectEventsAsync(CallstackKeywords, SuspendCallstackMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendCallstackMarker)); + + Assert.NotEmpty(suspendCallstacks); + Assert.All(suspendCallstacks, cs => + { + Assert.True(cs.FrameCount > 0, "Expected at least one frame in suspend callstack"); + Assert.True(cs.TaskId != 0, "Expected non-zero task ID in suspend callstack"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); + }); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SuspendDepthMarker() + { + await Task.Yield(); + await RuntimeAsync_ChainedYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_SuspendCallstackDepthMatchesChain() + { + var events = await CollectEventsAsync(CallstackKeywords, SuspendDepthMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendDepthMarker)); + + // The expected [NoInlining] frames in order (innermost first): + // RuntimeAsync_InnerYield -> RuntimeAsync_ChainedYield -> SuspendDepthMarker + Assert.NotEmpty(suspendCallstacks); + string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_ChainedYield), nameof(SuspendDepthMarker)]; + Assert.True( + HasCallstackWithExpectedFrames(suspendCallstacks, expectedFrames), + $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SuspendPrecedesCompleteMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_SuspendCallstackPrecedesComplete() + { + var events = await CollectEventsAsync(CallstackKeywords, SuspendPrecedesCompleteMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // Find the suspend callstack via marker to get the context ID + var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendPrecedesCompleteMarker)); + Assert.True(suspendStacks.Count >= 1, $"Expected at least one suspend callstack with marker, got {suspendStacks.Count}"); + + ulong taskId = suspendStacks[0].TaskId; + Assert.True(taskId > 0, "Expected non-zero context ID"); + + var taskEvts = stream.ForTask(taskId); + var ids = taskEvts.Select(e => e.EventId).ToList(); + + int suspendIdx = ids.IndexOf(AsyncEventID.SuspendAsyncCallstack); + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext); + + Assert.True(suspendIdx >= 0, "Expected SuspendAsyncCallstack in context events"); + Assert.True(completeIdx >= 0, "Expected CompleteAsyncContext in context events"); + Assert.True(suspendIdx < completeIdx, $"SuspendAsyncCallstack (index {suspendIdx}) should precede CompleteAsyncContext (index {completeIdx})"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SuspendDeeperMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_SuspendCallstackDeeperThanInitialResume() + { + var events = await CollectEventsAsync(CallstackKeywords, SuspendDeeperMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(SuspendDeeperMarker)); + var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendDeeperMarker)); + + Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker, got {resumeStacks.Count}"); + Assert.True(suspendStacks.Count >= 1, $"Expected at least one suspend callstack with marker, got {suspendStacks.Count}"); + + // First resume (after initial Yield) should be shallow, first suspend (RuntimeAsync_InnerYield's Yield) should be deeper + var firstResume = resumeStacks[0]; + var firstSuspend = suspendStacks[0]; + + Assert.True(firstSuspend.FrameCount > firstResume.FrameCount, $"First suspend callstack depth ({firstSuspend.FrameCount}) should be deeper than first resume callstack depth ({firstResume.FrameCount})"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CreatePrecedesResumeMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CreateCallstackPrecedesResumeCallstack() + { + var events = await CollectEventsAsync(CallstackKeywords, CreatePrecedesResumeMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreatePrecedesResumeMarker)); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CreatePrecedesResumeMarker)); + + Assert.NotEmpty(createStacks); + Assert.NotEmpty(resumeStacks); + + // For each task that has both Create and Resume callstacks, verify Create timestamp precedes Resume. + int matchedPairs = 0; + foreach (var create in createStacks) + { + var matchingResume = resumeStacks.FirstOrDefault(r => r.TaskId == create.TaskId); + if (matchingResume is null) + continue; + + matchedPairs++; + Assert.True(create.Timestamp <= matchingResume.Timestamp, $"For task {create.TaskId}: CreateAsyncCallstack (ts {create.Timestamp}) should precede ResumeAsyncCallstack (ts {matchingResume.Timestamp})"); + } + + Assert.True(matchedPairs >= 1, $"Expected at least one matching Create/Resume callstack pair, but found {matchedPairs}"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CreateResumeMatchMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CreateAndFirstResumeCallstacksMatch() + { + var events = await CollectEventsAsync(CallstackKeywords, CreateResumeMatchMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateResumeMatchMarker)); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CreateResumeMatchMarker)); + + Assert.NotEmpty(createStacks); + Assert.NotEmpty(resumeStacks); + + // For each create callstack, find the first resume with the same task ID and verify frames match. + int matchedPairs = 0; + foreach (var create in createStacks) + { + var matchingResume = resumeStacks.FirstOrDefault(r => r.TaskId == create.TaskId); + if (matchingResume is null) + continue; + + matchedPairs++; + Assert.Equal(create.Frames.Count, matchingResume.Frames.Count); + for (int i = 0; i < create.Frames.Count; i++) + { + Assert.Equal(create.Frames[i].MethodId, matchingResume.Frames[i].MethodId); + } + } + + Assert.True(matchedPairs >= 1, $"Expected at least one matching Create/Resume callstack pair, but found {matchedPairs}"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CallstackOnResumeMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackEmittedOnResume() + { + var events = await CollectEventsAsync(CallstackKeywords, CallstackOnResumeMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackOnResumeMarker)); + + Assert.NotEmpty(callstacks); + Assert.All(callstacks, cs => + { + Assert.True(cs.FrameCount > 0, "Expected at least one frame in callstack"); + Assert.True(cs.TaskId != 0, "Expected non-zero task ID in resume callstack"); + Assert.True(cs.Frames[0].MethodId != 0, "Expected non-zero MethodId in first frame"); + }); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CallstackDepthMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackDepthMatchesChain() + { + var events = await CollectEventsAsync(CallstackKeywords, CallstackDepthMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackDepthMarker)); + + // The expected [NoInlining] frames in order (innermost first): + // RuntimeAsync_InnerYield -> CallstackDepthMarker + Assert.NotEmpty(callstacks); + string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(CallstackDepthMarker)]; + Assert.True( + HasCallstackWithExpectedFrames(callstacks, expectedFrames), + $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SimulationNormalMarker() + { + await Task.Yield(); + await RuntimeAsync_InnerYield(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackSimulation_NormalCompletion() + { + var events = await CollectEventsAsync(CallstackKeywords, SimulationNormalMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + AssertCallstackSimulationReachesZero(stream, nameof(SimulationNormalMarker)); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SimulationHandledMarker() + { + await RuntimeAsync_DeepOuterCatches(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackSimulation_HandledException() + { + var events = await CollectEventsAsync(CallstackKeywords, SimulationHandledMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + AssertCallstackSimulationReachesZero(stream, nameof(SimulationHandledMarker)); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SimulationUnhandledMarker() + { + await RuntimeAsync_DeepUnhandledOuter(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task SimulationUnhandledMarkerCatcher() + { + try + { + await SimulationUnhandledMarker(); + } + catch (InvalidOperationException) + { + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackSimulation_UnhandledException() + { + var events = await CollectEventsAsync(CallstackKeywords, SimulationUnhandledMarkerCatcher); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + AssertCallstackSimulationReachesZero(stream, nameof(SimulationUnhandledMarker)); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task UnhandledUnwindMarker() + { + await RuntimeAsync_DeepUnhandledOuter(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task UnhandledUnwindCatcher() + { + try + { + await UnhandledUnwindMarker(); + } + catch (InvalidOperationException) + { + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_UnhandledExceptionUnwind() + { + var events = await CollectEventsAsync(CallstackKeywords, UnhandledUnwindCatcher); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(UnhandledUnwindMarker)); + Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{nameof(UnhandledUnwindMarker)}'"); + + ulong taskId = resumeStacks[0].TaskId; + + var taskEvts = stream.ForTask(taskId); + var eventIds = taskEvts.Select(e => e.EventId).ToList(); + + Assert.Contains(AsyncEventID.ResumeAsyncContext, eventIds); + Assert.Contains(AsyncEventID.UnwindAsyncException, eventIds); + Assert.Contains(AsyncEventID.CompleteAsyncContext, eventIds); + + // Verify unwind frame count for this task + // UnhandledUnwindMarker -> RuntimeAsync_DeepUnhandledOuter -> RuntimeAsync_DeepUnhandledMiddle -> RuntimeAsync_DeepUnhandledInnerThrows, 4 frames deep after the initial resume. + var unwindEvents = taskEvts.Where(e => e.EventId == AsyncEventID.UnwindAsyncException).ToList(); + Assert.NotEmpty(unwindEvents); + Assert.All(unwindEvents, e => Assert.Equal(4u, e.UnwindFrameCount)); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task HandledUnwindMarker() + { + await RuntimeAsync_DeepOuterCatches(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_HandledExceptionUnwind() + { + var events = await CollectEventsAsync(CallstackKeywords, HandledUnwindMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(HandledUnwindMarker)); + Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{nameof(HandledUnwindMarker)}'"); + + ulong taskId = resumeStacks[0].TaskId; + + var taskEvts = stream.ForTask(taskId); + var eventIds = taskEvts.Select(e => e.EventId).ToList(); + + Assert.Contains(AsyncEventID.ResumeAsyncContext, eventIds); + Assert.Contains(AsyncEventID.UnwindAsyncException, eventIds); + Assert.Contains(AsyncEventID.CompleteAsyncContext, eventIds); + + // Verify unwind frame count for this task + // RuntimeAsync_DeepMiddle -> RuntimeAsync_DeepInnerThrows, 2 frames deep after the initial resume. + var unwindEvents = taskEvts.Where(e => e.EventId == AsyncEventID.UnwindAsyncException).ToList(); + Assert.NotEmpty(unwindEvents); + Assert.All(unwindEvents, e => Assert.Equal(2u, e.UnwindFrameCount)); + } + + // Requires threading: + // Wrapper index tests use RunScenarioAndFlush (Task.Run) to escape xunit's + // AsyncTestSyncContext. With a SynchronizationContext present, each level in the + // async chain re-dispatches through the sync context, creating a separate + // DispatchContinuations call that resets the wrapper index to 0. Task.Run ensures + // the entire continuation chain executes in a single dispatch loop where the + // wrapper index increments sequentially across resumptions. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_WrapperIndexMatchesCallstack() + { + var captures = new List<(string MethodName, int WrapperSlot)>(); + + var events = CollectEvents(ResumeAsyncCallstackKeyword, () => + { + RunScenarioAndFlush(async () => + { + await RuntimeAsync_WrapperTestA(captures); + }); + }); + + // DumpAllEvents(events); + + Assert.True(captures.Count == 3, $"Expected 3 wrapper captures, got {captures.Count}"); + + Assert.All(captures, c => Assert.True(c.WrapperSlot >= 0, $"{c.MethodName} did not find wrapper frame on stack (slot={c.WrapperSlot})")); + + int slotC = captures.First(c => c.MethodName == nameof(RuntimeAsync_WrapperTestC)).WrapperSlot; + int slotB = captures.First(c => c.MethodName == nameof(RuntimeAsync_WrapperTestB)).WrapperSlot; + int slotA = captures.First(c => c.MethodName == nameof(RuntimeAsync_WrapperTestA)).WrapperSlot; + + Assert.Equal(slotC + 1, slotB); + Assert.Equal(slotB + 1, slotA); + } + + // Requires threading: + // Same comment as RuntimeAsync_WrapperIndexMatchesCallstack regarding Task.Run and wrapper index behavior. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_WrapperIndexResetEmitted() + { + var events = CollectEvents(AllKeywords, () => + { + // Recursive chain 34 levels deep crosses the 32-slot boundary, + // triggering at least one ResetAsyncContinuationWrapperIndex event. + RunScenarioAndFlush(async () => + { + await RuntimeAsync_RecursiveChain(34); + }); + }); + + // DumpAllEvents(events); + + var ids = ParseAllEvents(events).EventIds; + + Assert.Contains(AsyncEventID.ResetAsyncContinuationWrapperIndex, ids); + } + + // Requires threading: + // Same comment as RuntimeAsync_WrapperIndexMatchesCallstack regarding Task.Run and wrapper index behavior. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_WrapperIndexNoResetUnder32() + { + var events = CollectEvents(AllKeywords, () => + { + // A shallow chain stays within the first 32 slots — + // no reset event should be emitted. + RunScenarioAndFlush(async () => + { + await RuntimeAsync_RecursiveChain(2); + }); + }); + + // DumpAllEvents(events); + + var ids = ParseAllEvents(events).EventIds; + + Assert.DoesNotContain(AsyncEventID.ResetAsyncContinuationWrapperIndex, ids); + } + + // Requires threading: + // The periodic flush timer runs on a background thread. + // On single-threaded runtimes there is no background thread to fire the timer. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_PeriodicTimerFlush() + { + static bool IsRequestedEvent(AsyncEventID id) => + id == AsyncEventID.CreateAsyncContext || + id == AsyncEventID.ResumeAsyncContext || + id == AsyncEventID.SuspendAsyncContext || + id == AsyncEventID.CompleteAsyncContext; + + var events = CollectEvents(CoreKeywords, (collectedEvents, _) => + { + // Run scenario - do NOT flush explicitly afterwards. + RunScenario(async () => + { + await RuntimeAsync_SingleYield(); + }); + + // Wait for the periodic flush timer (1s interval) to detect the idle buffer and flush it automatically. + Thread.Sleep(1000); + + // Poll to make sure the expected buffer got flush. + bool flushed = SpinWait.SpinUntil(() => + { + var stream = ParseAllEvents(collectedEvents); + return stream.EventIds.Any(id => IsRequestedEvent(id)); + }, TimeSpan.FromSeconds(20)); + + Assert.True(flushed, "Expected periodic timer to flush buffer with core lifecycle events within timeout"); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + int coreEventCount = stream.EventIds.Count(id => IsRequestedEvent(id)); + + Assert.True(coreEventCount > 0, "Expected periodic timer to flush buffer with core lifecycle events"); + } + + // Requires threading: + // Verifies the background flush timer preserves the owning thread's OS thread ID, + // which needs both a dedicated worker thread and the timer thread. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_PeriodicTimerFlush_PreservesOwnerThreadId() + { + // This test verifies that when the background flush timer flushes a thread's buffer, + // the new header written afterwards preserves the owning thread's OS thread ID + // (not the timer thread's ID). + // + // Strategy: run async work on a dedicated thread so its profiler context gets events. + // Between two batches of work, wait for the flush timer to fire. Both buffer flushes + // from the dedicated thread should carry the same OsThreadId. + + ulong workerOsThreadId = 0; + var workerIdReady = new ManualResetEventSlim(false); + var firstBatchDone = new ManualResetEventSlim(false); + var firstFlushSeen = new ManualResetEventSlim(false); + var events = new CollectedEvents(); + + using (var listener = CreateListener(CoreKeywords)) + { + listener.RunWithCallback(e => + { + if (!workerIdReady.IsSet) + return; + if (e.EventId != AsyncEventsId || e.Payload is null || e.Payload.Count == 0) + return; + if (e.Payload[0] is not byte[] payload) + return; + EventBufferHeader? header = ParseEventBufferHeader(payload); + if (header is not null && header.Value.OsThreadId == workerOsThreadId) + events.Events.Enqueue(e); + }, () => + { + SendFlushCommand(); + + var thread = new Thread(() => + { + workerOsThreadId = GetCurrentOSThreadId(); + workerIdReady.Set(); + + // First batch: generate events on this thread's profiler context. + RuntimeAsync_SingleYield().GetAwaiter().GetResult(); + firstBatchDone.Set(); + + // Wait for the flush to deliver our first buffer before generating more events. + bool flushed = firstFlushSeen.Wait(TimeSpan.FromSeconds(20)); + Assert.True(flushed, "Expected first flush of core lifecycle events within timeout"); + + // Second batch: generate more events on the same thread's context. + RuntimeAsync_SingleYield().GetAwaiter().GetResult(); + }); + + thread.IsBackground = true; + thread.Start(); + + // Wait for the worker to finish its first batch, then force flush. + firstBatchDone.Wait(TimeSpan.FromSeconds(20)); + SendFlushCommand(); + + // Poll for first buffer from our worker thread. + bool firstFlush = SpinWait.SpinUntil(() => events.Events.Count >= 1, TimeSpan.FromSeconds(20)); + Assert.True(firstFlush, "Expected periodic timer to flush core lifecycle events within timeout"); + + firstFlushSeen.Set(); + + // Wait for the worker to finish its second batch. + bool joined = thread.Join(TimeSpan.FromSeconds(20)); + Assert.True(joined, "Expected worker thread to terminate within timeout after second batch of work"); + + // Force a flush to deliver the second batch. + SendFlushCommand(); + + // Poll for second buffer from our worker thread. + bool secondFlush = SpinWait.SpinUntil(() => events.Events.Count >= 2, TimeSpan.FromSeconds(20)); + Assert.True(secondFlush, "Expected periodic timer to flush core lifecycle events within timeout"); + }); + } + + // DumpAllEvents(events); + + Assert.True(workerOsThreadId != 0, "Failed to capture worker OS thread ID"); + + // The key assertion: find buffers that contain CreateAsyncContext events (our work batches). + // There must be at least 2 such buffers (one per RuntimeAsync_SingleYield() call), and ALL of them must + // have the worker's OsThreadId - proving the timer flush didn't corrupt the header. + var stream = ParseAllEvents(events); + var createEvents = stream.OfType(AsyncEventID.CreateAsyncContext).ToList(); + Assert.True(createEvents.Count >= 2, $"Expected at least 2 CreateAsyncContext events from the worker thread, got {createEvents.Count}"); + Assert.All(createEvents, e => Assert.Equal(workerOsThreadId, e.OsThreadId)); + } + + // Requires threading: + // Spawns a dedicated thread that exits, then waits for the background flush timer + // to detect and flush the orphaned thread-local buffer. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_DeadThreadFlush() + { + static bool IsRequestedEvent(AsyncEventID id) => + id == AsyncEventID.CreateAsyncContext || + id == AsyncEventID.ResumeAsyncContext || + id == AsyncEventID.SuspendAsyncContext || + id == AsyncEventID.CompleteAsyncContext; + + var events = CollectEvents(CoreKeywords, (collectedEvents, _) => + { + // Spawn a dedicated thread that runs async work then exits. + // Its thread-local buffer becomes orphaned when the thread dies. + var thread = new Thread(() => + { + RunScenario(async () => + { + await RuntimeAsync_SingleYield(); + }); + }); + + thread.IsBackground = true; + thread.Start(); + bool joined = thread.Join(TimeSpan.FromSeconds(20)); + Assert.True(joined, "Expected worker thread to terminate within timeout before waiting for orphaned buffer flush"); + + // Do NOT send a flush command. + // Wait for the periodic flush timer to detect the dead thread and flush its orphaned buffer. + Thread.Sleep(1000); + + // Poll to make sure the expected buffer got flush. + bool flushed = SpinWait.SpinUntil(() => + { + var stream = ParseAllEvents(collectedEvents); + return stream.EventIds.Any(id => IsRequestedEvent(id)); + }, TimeSpan.FromSeconds(20)); + + Assert.True(flushed, "Expected periodic timer to flush buffer with core lifecycle events within timeout"); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + int coreEventCount = stream.EventIds.Count(id => IsRequestedEvent(id)); + + Assert.True(coreEventCount > 0, "Expected periodic timer to flush dead thread's buffer"); + } + + // This test is sensitive to event noise - it asserts a specific clock event is absent. + // It cannot run in parallel with other async profiler scenarios that might produce + // clock events. Test parallelization is disabled via XunitAssemblyAttributes.cs. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_NoSyncClockEventBeforeInterval() + { + var events = await CollectEventsAsync(CoreKeywords, RuntimeAsync_SingleYield); + + var ids = ParseAllEvents(events).EventIds; + + Assert.DoesNotContain(AsyncEventID.AsyncProfilerSyncClock, ids); + } + + // This test is sensitive to event noise - it asserts zero context events appear + // after enabling the listener. It cannot run in parallel with other async profiler + // scenarios that might produce events on the same thread context. + // Test parallelization is already disabled via XunitAssemblyAttributes.cs. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_NoEventsWhenDisabled() + { + // Run async work WITHOUT a listener attached + for (int i = 0; i < 50; i++) + { + await RuntimeAsync_SingleYield(); + } + + // Now attach listener but don't run any RuntimeAsync work inside — + // just call a synchronous no-op. Verify no stale events from above leak through. + var events = await CollectEventsAsync(CoreKeywords, () => Task.CompletedTask); + + // There may be meta data related events, but there should be no suspend/resume/complete events from the earlier work. + var ids = ParseAllEvents(events).EventIds; + + int contextEvents = ids.Count(id => id == AsyncEventID.ResumeAsyncContext || id == AsyncEventID.SuspendAsyncContext || id == AsyncEventID.CompleteAsyncContext); + + Assert.Equal(0, contextEvents); + } + + public static IEnumerable KeywordGatekeepingData() + { + yield return new object[] { (long)CreateAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CreateAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)ResumeAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.ResumeAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)SuspendAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.SuspendAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)CompleteAsyncContextKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CompleteAsyncContext, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)UnwindAsyncExceptionKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.UnwindAsyncException, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)CreateAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CreateAsyncCallstack, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)ResumeAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.ResumeAsyncCallstack, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)SuspendAsyncCallstackKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.SuspendAsyncCallstack, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)ResumeAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.ResumeAsyncMethod, AsyncEventID.AsyncProfilerMetadata } }; + yield return new object[] { (long)CompleteAsyncMethodKeyword, new AsyncEventID[] { AsyncEventID.ResetAsyncThreadContext, AsyncEventID.CompleteAsyncMethod, AsyncEventID.AsyncProfilerMetadata } }; + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task KeywordGatekeepingMarker() + { + await RuntimeAsync_OuterCatches(); + await RuntimeAsync_ChainedYield(); + } + + // This test is sensitive to event noise - it asserts that ONLY the expected event + // types appear for a given keyword. It cannot run in parallel with other async + // profiler scenarios that might produce events on the same thread context. + // Test parallelization is already disabled via XunitAssemblyAttributes.cs. + [ConditionalTheory(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + [MemberData(nameof(KeywordGatekeepingData))] + public async Task RuntimeAsync_KeywordGatekeeping(long keywordValue, AsyncEventID[] allowedEventIds) + { + EventKeywords kw = (EventKeywords)keywordValue; + var allowed = new HashSet(allowedEventIds); + + // Run a scenario that exercises all event types: resume, suspend, + // complete, method events, callstacks, and exception unwinds. + // Only the events matching the enabled keyword should be emitted. + var events = await CollectEventsAsync(kw, KeywordGatekeepingMarker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var unexpected = stream.EventIds.Where(id => !allowed.Contains(id)).ToList(); + + Assert.True(unexpected.Count == 0, $"Keyword 0x{(long)kw:X}: unexpected event IDs [{string.Join(", ", unexpected)}], " + $"allowed [{string.Join(", ", allowed)}]"); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ResetAsyncThreadContextEvent() + { + var events = await CollectEventsAsync(CoreKeywords, RuntimeAsync_SingleYield); + + // DumpAllEvents(events); + + var ids = ParseAllEvents(events).EventIds; + + Assert.Contains(AsyncEventID.ResetAsyncThreadContext, ids); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_MetadataEventEmittedOnEnable() + { + var events = await CollectEventsAsync(AllKeywords, RuntimeAsync_SingleYield); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var metadataList = stream.MetadataEvents; + Assert.True(metadataList.Count >= 1, "Expected at least one metadata event in buffer"); + + MetadataFromBuffer meta = metadataList[0]; + Assert.True(meta.QpcFrequency > 0, $"QPC frequency should be positive, got {meta.QpcFrequency}"); + Assert.True(meta.QpcSync > 0, $"QPC sync timestamp should be positive, got {meta.QpcSync}"); + Assert.True(meta.UtcSync > 0, $"UTC sync timestamp should be positive, got {meta.UtcSync}"); + Assert.True(meta.EventBufferSize > 0, $"Event buffer size should be positive, got {meta.EventBufferSize}"); + Assert.True(meta.WrapperCount > 0, "Wrapper count should be positive"); + } + + // Requires threading: + // Spawns 8 threads with a Barrier to verify metadata is + // emitted exactly once under concurrent enable pressure. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_MetadataEventEmittedOnceAcrossThreads() + { + const int threadCount = 8; + + var events = CollectEvents(AllKeywords, () => + { + using var barrier = new Barrier(threadCount); + var tasks = new Task[threadCount]; + for (int i = 0; i < threadCount; i++) + { + tasks[i] = Task.Factory.StartNew(() => + { + barrier.SignalAndWait(); + RuntimeAsync_SingleYield().GetAwaiter().GetResult(); + }, TaskCreationOptions.LongRunning); + } + Task.WaitAll(tasks); + SendFlushCommand(); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var metadataList = stream.MetadataEvents; + Assert.True(metadataList.Count == 1, $"Expected exactly 1 metadata event across {threadCount} threads, got {metadataList.Count}"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task NativeIPDeltaRoundtripMarker() + { + await RuntimeAsync_ChainedYield(); + await RuntimeAsync_DeepOuterCatches(); + await RuntimeAsync_RecursiveChain(10); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip() + { + // Verify that delta-encoded NativeIPs in callstacks roundtrip correctly, + // including both positive and negative deltas. With multiple distinct async + // methods at different JIT-assigned addresses, the deltas between consecutive + // NativeIPs will naturally span both directions. This exercises the full + // zigzag + LEB128 encode/decode path through the production serializer. + var events = await CollectEventsAsync(CallstackKeywords, NativeIPDeltaRoundtripMarker); + + var stream = ParseAllEvents(events); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(NativeIPDeltaRoundtripMarker)); + Assert.NotEmpty(callstacks); + + // Find callstacks with 3+ frames — enough depth for meaningful deltas. + var deepCallstacks = callstacks.Where(cs => cs.FrameCount >= 3).ToList(); + + Assert.True(deepCallstacks.Count > 0, "Expected at least one callstack with 3+ frames for delta verification"); + + bool hasPositiveDelta = false; + bool hasNegativeDelta = false; + + foreach (var cs in deepCallstacks) + { + for (int i = 0; i < cs.Frames.Count; i++) + { + var (methodId, _) = cs.Frames[i]; + Assert.True(methodId != 0, $"Frame {i} has zero MethodId"); + + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Frame {i}: MethodId 0x{methodId:X} does not resolve to a managed method"); + + if (i > 0) + { + long delta = (long)(cs.Frames[i].MethodId - cs.Frames[i - 1].MethodId); + if (delta > 0) + hasPositiveDelta = true; + else if (delta < 0) + hasNegativeDelta = true; + } + } + } + + // With multiple distinct async methods at different addresses, we expect + // both positive and negative deltas. If the JIT happens to lay out all + // methods monotonically (extremely unlikely), at minimum we must see + // non-zero deltas proving the encoding works. + Assert.True(hasPositiveDelta || hasNegativeDelta, "Expected at least one non-zero NativeIP delta across all callstack frames"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static async Task CallstackStressMarker(int depth) + { + await RuntimeAsync_RecursiveChain(depth); + } + + // Requires threading: + // The recursive async chain must execute in a single dispatch + // loop (no sync context) to produce full-depth callstacks. Under xunit's + // AsyncTestSyncContext, each await re-dispatches, fragmenting the chain. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_CallstackStressWithVaryingDepths() + { + // Stress test: run many async calls with varying callstack depths. + // Varying sizes mean some callstacks will land at buffer boundaries, + // naturally exercising the overflow/rewind path in callstack emission. + // lambda -> CallstackStressMarker(d) -> RuntimeAsync_RecursiveChain(d) produces d + 2 frames. + const int iterations = 200; + int[] depths = new int[iterations]; + var rng = new Random(42); + for (int i = 0; i < iterations; i++) + depths[i] = rng.Next(1, 120); + + var events = CollectEvents(CallstackKeywords, () => + { + RunScenarioAndFlush(async () => + { + for (int i = 0; i < iterations; i++) + await CallstackStressMarker(depths[i]); + }); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackStressMarker)); + + // Verify all callstacks have valid frame data that resolves to managed methods. + foreach (var cs in callstacks) + { + Assert.True(cs.FrameCount > 0, "Callstack has 0 frames"); + Assert.Equal((int)cs.FrameCount, cs.Frames.Count); + for (int f = 0; f < cs.Frames.Count; f++) + { + var (methodId, _) = cs.Frames[f]; + Assert.True(methodId != 0, $"Frame {f} has zero MethodId"); + + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); + } + } + + // One resume callstack per iteration (marker filters out noise). + // lambda -> CallstackStressMarker -> RuntimeAsync_RecursiveChain(d) produces d + 2 frames. + Assert.True(callstacks.Count >= iterations, $"Expected at least {iterations} callstacks with marker, got {callstacks.Count}"); + + for (int i = 0; i < iterations; i++) + { + // lambda + CallstackStressMarker + RuntimeAsync_RecursiveChain(d) = d + 2 + int expected = depths[i] + 2; + int actual = callstacks[i].FrameCount; + Assert.True(actual == expected, $"Iteration {i}: expected depth {expected} (lambda -> CallstackStressMarker -> RuntimeAsync_RecursiveChain({depths[i]})), got {actual}"); + } + + // Verify multiple buffer flushes occurred. + int bufferCount = 0; + ForEachEventBufferPayload(events, _ => bufferCount++); + Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); + } + + // Requires threading: + // Deep recursive chains must execute in a single dispatch loop (no sync context) + // to produce full-depth callstacks that trigger overflow. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_CallstackOverflowPathProducesValidFrames() + { + // Targeted test: run random-depth callstacks until we detect the overflow + // path was exercised, then validate the affected callstack. + // The overflow path fires when a large callstack doesn't fit inline in the + // remaining buffer space — the code rewinds, flushes the partial buffer, + // and re-writes the callstack as the first event in a fresh buffer. + // + // To prove overflow occurred we check consecutive buffer pairs: + // Buffer N: not full (has remaining capacity, but not enough for the next callstack) + // Buffer N+1: first event is a large callstack + // This proves the runtime detected insufficient space, rewound, and flushed. + bool overflowDetected = false; + var rng = new Random(42); + + for (int attempt = 0; attempt < 10 && !overflowDetected; attempt++) + { + int iterations = 500; + int[] depths = new int[iterations]; + for (int i = 0; i < iterations; i++) + depths[i] = rng.Next(50, 250); + + var events = CollectEvents(AllKeywords, () => + { + RunScenarioAndFlush(async () => + { + for (int i = 0; i < iterations; i++) + await RuntimeAsync_RecursiveChain(depths[i]); + }); + }); + + // Get buffer capacity from metadata. + var stream = ParseAllEvents(events); + var metadataList = stream.MetadataEvents; + if (metadataList.Count == 0) + continue; + uint bufferCapacity = metadataList[0].EventBufferSize; + + // Collect per-buffer info grouped by async thread context. + // Consecutive buffers from the same context represent the overflow sequence. + var buffersByContext = new Dictionary>(); + ForEachEventBufferPayload(events, buffer => + { + EventBufferHeader? header = ParseEventBufferHeader(buffer); + if (header is null) + return; + + uint contextId = header.Value.AsyncThreadContextId; + + // Parse the first event in this buffer. + int index = HeaderSize; + if (index >= buffer.Length) + return; + + AsyncEventID firstId = (AsyncEventID)buffer[index++]; + // Skip timestamp delta + ReadCompressedUInt64(buffer, ref index); + + byte frameCount = 0; + if (firstId == AsyncEventID.ResumeAsyncCallstack || + firstId == AsyncEventID.CreateAsyncCallstack || + firstId == AsyncEventID.SuspendAsyncCallstack) + { + // Callstack payload: type(1) + callstackId(1) + frameCount(1) + compressed taskId + frames... + index++; // type byte + index++; // callstack ID (reserved) + if (index < buffer.Length) + frameCount = buffer[index]; + } + + if (!buffersByContext.TryGetValue(contextId, out var list)) + { + list = new List<(int, AsyncEventID, byte)>(); + buffersByContext[contextId] = list; + } + list.Add((buffer.Length, firstId, frameCount)); + }); + + // Look for overflow evidence within the same thread context: + // buffer N not full, buffer N+1 starts with large callstack. + foreach (var bufferInfos in buffersByContext.Values) + { + for (int i = 0; i < bufferInfos.Count - 1; i++) + { + var current = bufferInfos[i]; + var next = bufferInfos[i + 1]; + + Assert.True((uint)current.UsedSize <= bufferCapacity, $"Buffer used size {current.UsedSize} exceeds capacity {bufferCapacity}."); + + uint remaining = bufferCapacity - (uint)current.UsedSize; + bool currentNotFull = remaining > 0; + bool nextStartsWithLargeCallstack = + (next.FirstEventId == AsyncEventID.ResumeAsyncCallstack || + next.FirstEventId == AsyncEventID.CreateAsyncCallstack || + next.FirstEventId == AsyncEventID.SuspendAsyncCallstack) && + next.FirstFrameCount > 30; + + if (currentNotFull && nextStartsWithLargeCallstack) + { + overflowDetected = true; + break; + } + } + + if (overflowDetected) + break; + } + + // Validate all large callstacks in the stream have correct frames. + if (overflowDetected) + { + var largeCallstacks = stream.OfType(AsyncEventID.ResumeAsyncCallstack) + .Where(e => e.FrameCount > 30) + .ToList(); + + foreach (var cs in largeCallstacks) + { + Assert.Equal((int)cs.FrameCount, cs.Frames.Count); + for (int f = 0; f < cs.Frames.Count; f++) + { + var (methodId, _) = cs.Frames[f]; + Assert.True(methodId != 0, $"Overflow callstack frame {f} has zero MethodId"); + + var method = GetMethodNameFromMethodId(cs.CallstackType, methodId); + Assert.True(method is not null, $"Overflow callstack frame {f}: MethodId 0x{methodId:X} does not resolve to a managed method"); + } + } + } + } + + Assert.True(overflowDetected, "Failed to trigger callstack buffer overflow after 10 attempts — " + + "no consecutive buffer pair found where buffer N has remaining capacity and buffer N+1 starts with a large callstack"); + } + + // Requires threading: + // Deep recursive chains must execute in a single dispatch + // loop (no sync context) to produce chains exceeding the 255-frame cap. + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] + public void RuntimeAsync_CallstackDepthCappedAtMaxFrames() + { + // Verify that callstack depth is capped when the continuation chain + // exceeds the maximum frame count (255, limited by byte storage). + // RuntimeAsync_RecursiveChain(300) produces a 300-deep chain + 1 lambda = 301 frames. + const int requestedDepth = 300; + + var events = CollectEvents(ResumeAsyncCallstackKeyword, () => + { + RunScenarioAndFlush(async () => + { + await RuntimeAsync_RecursiveChain(requestedDepth); + }); + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + var callstacks = stream.OfType(AsyncEventID.ResumeAsyncCallstack).ToList(); + Assert.True(callstacks.Count >= 1, "Expected at least one callstack"); + + // Find the callstack from our deep RuntimeAsync_RecursiveChain call. + // The max frame count is capped at 255 (byte.MaxValue) since the + // CaptureRuntimeAsyncCallstackState.Count is a byte. + // RuntimeAsync_RecursiveChain(300) + 1 lambda = 301 frames, capped to 255. + var deepest = callstacks.MaxBy(cs => cs.FrameCount); + Assert.Equal(255, (int)deepest!.FrameCount); + Assert.Equal((int)deepest.FrameCount, deepest.Frames.Count); + + // Verify all frames are valid. + foreach (var (methodId, _) in deepest.Frames) + { + Assert.True(methodId != 0, "Frame has zero MethodId"); + var method = GetMethodNameFromMethodId(deepest.CallstackType, methodId); + Assert.True(method is not null, $"MethodId 0x{methodId:X} does not resolve to a managed method"); + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_MetadataMatchesWrapperMethods() + { + var events = await CollectEventsAsync(AllKeywords, RuntimeAsync_SingleYield); + + var stream = ParseAllEvents(events); + var metadataList = stream.MetadataEvents; + Assert.True(metadataList.Count >= 1, "Expected at least one metadata event in buffer"); + + MetadataFromBuffer meta = metadataList[0]; + Assert.True(meta.WrapperCount > 0, "Expected positive wrapper count in metadata"); + + // On CoreCLR, verify via reflection that the contract-defined template produces names matching real methods. + // This catches accidental renames of wrapper methods without updating the contract. + if (PlatformDetection.IsCoreCLR) + { + Type? wrapperType = typeof(System.Runtime.CompilerServices.AsyncTaskMethodBuilder) + .Assembly.GetType("System.Runtime.CompilerServices.AsyncProfiler+ContinuationWrapper"); + Assert.NotNull(wrapperType); + for (int i = 0; i < meta.WrapperCount; i++) + { + string expectedName = string.Format(WrapperNameTemplate, i); + var method = wrapperType.GetMethod(expectedName, + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + Assert.True(method is not null, $"Expected method '{expectedName}' not found on ContinuationWrapper type"); + } + + // Verify that the wrapper count matches the actual number of wrapper methods on the type. + int actualCount = wrapperType.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public) + .Count(m => m.Name.StartsWith(WrapperNamePrefix, StringComparison.Ordinal)); + Assert.Equal(meta.WrapperCount, actualCount); + } + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj index d7f3c76fcd2b44..d1680d8f7b2a61 100644 --- a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/System.Threading.Tasks.Tests.csproj @@ -62,6 +62,7 @@ + From b766a8597720f34deee4f15bee11230f35de6d28 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Thu, 4 Jun 2026 18:52:58 +0200 Subject: [PATCH 14/19] Some more test cleanup. --- .../AsyncProfilerTests.cs | 12 +- .../AsyncProfilerV2Tests.cs | 232 +++++++++--------- 2 files changed, 124 insertions(+), 120 deletions(-) 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 eaaa3f21922174..fcce97699e4299 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 @@ -519,12 +519,12 @@ public List CallstacksWithMarker(AsyncEventID callstackEventId, str // AppendAsyncCallstack events for the same context, up until the next Suspend/Complete on that // context. // - /// V1 dispatchers may emit a partial Resume callstack when the parent continuation hasn't yet - /// registered (race between dispatcher pickup and parent's AwaitUnsafeOnCompleted). Frames that - /// register later are emitted as AppendAsyncCallstack at the next hook point. Merging produces - /// the complete chain that was observable during the dispatcher's lifetime. - /// - /// Returns one ParsedEvent per Resume, with Frames and FrameCount reflecting the merged total. + // V1 dispatchers may emit a partial Resume callstack when the parent continuation hasn't yet + // registered (race between dispatcher pickup and parent's AwaitUnsafeOnCompleted). Frames that + // register later are emitted as AppendAsyncCallstack at the next hook point. Merging produces + // the complete chain that was observable during the dispatcher's lifetime. + // + // Returns one ParsedEvent per Resume, with Frames and FrameCount reflecting the merged total. public List MergedResumeCallstacks() { var result = new List(); 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 4f0f70f8272e75..cd498654ec1336 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 @@ -12,43 +12,36 @@ namespace System.Threading.Tasks.Tests { - /// - /// Tests for V2 (runtime-async) async profiler event emission. All scenario methods use - /// [RuntimeAsyncMethodGeneration(true)] to ensure they exercise the runtime-async path. - /// V2 emits Create/Resume/Suspend/Complete callstacks natively from the runtime dispatch - /// loop, unlike V1 which uses the AsyncStateMachineBox dispatcher wrapper. - /// + // Tests for V2 (runtime-async) async profiler event emission. All scenario methods use + // [RuntimeAsyncMethodGeneration(true)] to ensure they exercise the runtime-async path. + // V2 emits Create/Resume/Suspend/Complete callstacks natively from the runtime dispatch + // loop, unlike V1 which uses the AsyncStateMachineBox dispatcher wrapper. public partial class AsyncProfilerTests { - // --- V2 (runtime-async) scenario helpers --- - // Named with RuntimeAsync_* prefix to mirror the V1 TaskAsync_* convention and the - // RuntimeAsync_* test method prefix. All use RuntimeAsyncMethodGeneration(true) to - // force the runtime-async code path. - [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_SingleYield() + private static async Task RuntimeAsync_SingleYield() { await Task.Yield(); } [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_ChainedYield() + private static async Task RuntimeAsync_ChainedYield() { await RuntimeAsync_InnerYield(); } [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_InnerYield() + private static async Task RuntimeAsync_InnerYield() { await Task.Yield(); } [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_OuterCatches() + private static async Task RuntimeAsync_OuterCatches() { try { @@ -62,7 +55,7 @@ static async Task RuntimeAsync_OuterCatches() [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_InnerThrows() + private static async Task RuntimeAsync_InnerThrows() { await Task.Yield(); throw new InvalidOperationException("inner"); @@ -70,7 +63,7 @@ static async Task RuntimeAsync_InnerThrows() [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_DeepOuterCatches() + private static async Task RuntimeAsync_DeepOuterCatches() { try { @@ -83,14 +76,14 @@ static async Task RuntimeAsync_DeepOuterCatches() [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_DeepMiddle() + private static async Task RuntimeAsync_DeepMiddle() { await RuntimeAsync_DeepInnerThrows(); } [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_DeepInnerThrows() + private static async Task RuntimeAsync_DeepInnerThrows() { await Task.Yield(); throw new InvalidOperationException("deep inner"); @@ -98,21 +91,21 @@ static async Task RuntimeAsync_DeepInnerThrows() [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_DeepUnhandledOuter() + private static async Task RuntimeAsync_DeepUnhandledOuter() { await RuntimeAsync_DeepUnhandledMiddle(); } [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_DeepUnhandledMiddle() + private static async Task RuntimeAsync_DeepUnhandledMiddle() { await RuntimeAsync_DeepUnhandledInnerThrows(); } [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_DeepUnhandledInnerThrows() + private static async Task RuntimeAsync_DeepUnhandledInnerThrows() { await Task.Yield(); throw new InvalidOperationException("deep unhandled"); @@ -120,7 +113,7 @@ static async Task RuntimeAsync_DeepUnhandledInnerThrows() [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_RecursiveChain(int depth) + private static async Task RuntimeAsync_RecursiveChain(int depth) { if (depth <= 1) { @@ -132,7 +125,7 @@ static async Task RuntimeAsync_RecursiveChain(int depth) [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_WrapperTestA(List<(string MethodName, int WrapperSlot)> captures) + private static async Task RuntimeAsync_WrapperTestA(List<(string MethodName, int WrapperSlot)> captures) { await RuntimeAsync_WrapperTestB(captures); captures.Add((nameof(RuntimeAsync_WrapperTestA), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestA)))); @@ -140,7 +133,7 @@ static async Task RuntimeAsync_WrapperTestA(List<(string MethodName, int Wrapper [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_WrapperTestB(List<(string MethodName, int WrapperSlot)> captures) + private static async Task RuntimeAsync_WrapperTestB(List<(string MethodName, int WrapperSlot)> captures) { await RuntimeAsync_WrapperTestC(captures); captures.Add((nameof(RuntimeAsync_WrapperTestB), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestB)))); @@ -148,7 +141,7 @@ static async Task RuntimeAsync_WrapperTestB(List<(string MethodName, int Wrapper [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] - static async Task RuntimeAsync_WrapperTestC(List<(string MethodName, int WrapperSlot)> captures) + private static async Task RuntimeAsync_WrapperTestC(List<(string MethodName, int WrapperSlot)> captures) { await Task.Yield(); captures.Add((nameof(RuntimeAsync_WrapperTestC), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestC)))); @@ -204,7 +197,7 @@ public async Task RuntimeAsync_EventsEmitted() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendResumeCompleteMarker() + private static async Task RuntimeAsync_SuspendResumeCompleteEvents_Marker() { await Task.Yield(); await RuntimeAsync_SingleYield(); @@ -213,15 +206,15 @@ static async Task SuspendResumeCompleteMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_SuspendResumeCompleteEvents() { - var events = await CollectEventsAsync(CallstackKeywords, SuspendResumeCompleteMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_SuspendResumeCompleteEvents_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); // Find our context via marker callstack. - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(SuspendResumeCompleteMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with SuspendResumeCompleteMarker"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_SuspendResumeCompleteEvents_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; var taskEvts = stream.ForTask(taskId); @@ -235,7 +228,7 @@ public async Task RuntimeAsync_SuspendResumeCompleteEvents() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task ContextLifecycleMarker() + private static async Task RuntimeAsync_ContextEventIdLifecycle_Marker() { await Task.Yield(); await RuntimeAsync_SingleYield(); @@ -244,15 +237,15 @@ static async Task ContextLifecycleMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_ContextEventIdLifecycle() { - var events = await CollectEventsAsync(CallstackKeywords, ContextLifecycleMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_ContextEventIdLifecycle_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); // Find events in the context that contains our marker method. - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ContextLifecycleMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with ContextLifecycleMarker"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ContextEventIdLifecycle_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; Assert.True(taskId > 0, "Context ID should be non-zero"); @@ -281,7 +274,7 @@ public async Task RuntimeAsync_ResumeCompleteMethodEvents() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task EventSequenceOrderMarker() + private static async Task RuntimeAsync_EventSequenceOrder_Marker() { await Task.Yield(); await RuntimeAsync_SingleYield(); @@ -290,15 +283,15 @@ static async Task EventSequenceOrderMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_EventSequenceOrder() { - var events = await CollectEventsAsync(CallstackKeywords, EventSequenceOrderMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_EventSequenceOrder_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); // Find our context via marker callstack. - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(EventSequenceOrderMarker)); - Assert.True(markerCallstacks.Count > 0, "Expected at least one callstack with EventSequenceOrderMarker"); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_EventSequenceOrder_Marker)); + Assert.NotEmpty(markerCallstacks); ulong taskId = markerCallstacks[0].TaskId; var taskEvts = stream.ForTask(taskId); @@ -331,7 +324,7 @@ public async Task RuntimeAsync_CreateAsyncContextEmittedOnFirstAwait() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreateCallstackMarker() + private static async Task RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait_Marker() { await RuntimeAsync_SingleYield(); } @@ -339,12 +332,12 @@ static async Task CreateCallstackMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait() { - var events = await CollectEventsAsync(CallstackKeywords, CreateCallstackMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateCallstackMarker)); + var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait_Marker)); Assert.NotEmpty(createCallstacks); Assert.All(createCallstacks, cs => @@ -358,7 +351,7 @@ public async Task RuntimeAsync_CreateAsyncCallstackEmittedOnFirstAwait() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreateCallstackDepthMarker() + private static async Task RuntimeAsync_CreateCallstackDepthMatchesChain_Marker() { await RuntimeAsync_ChainedYield(); } @@ -366,17 +359,17 @@ static async Task CreateCallstackDepthMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CreateCallstackDepthMatchesChain() { - var events = await CollectEventsAsync(CallstackKeywords, CreateCallstackDepthMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CreateCallstackDepthMatchesChain_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateCallstackDepthMarker)); + var createCallstacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(RuntimeAsync_CreateCallstackDepthMatchesChain_Marker)); // The expected [NoInlining] frames in order (innermost first): - // RuntimeAsync_InnerYield -> RuntimeAsync_ChainedYield -> CreateCallstackDepthMarker + // RuntimeAsync_InnerYield -> RuntimeAsync_ChainedYield -> RuntimeAsync_CreateCallstackDepthMatchesChain_Marker Assert.NotEmpty(createCallstacks); - string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_ChainedYield), nameof(CreateCallstackDepthMarker)]; + string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_ChainedYield), nameof(RuntimeAsync_CreateCallstackDepthMatchesChain_Marker)]; Assert.True( HasCallstackWithExpectedFrames(createCallstacks, expectedFrames), $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); @@ -384,7 +377,7 @@ public async Task RuntimeAsync_CreateCallstackDepthMatchesChain() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendCallstackMarker() + private static async Task RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait_Marker() { await Task.Yield(); await RuntimeAsync_SingleYield(); @@ -393,12 +386,12 @@ static async Task SuspendCallstackMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait() { - var events = await CollectEventsAsync(CallstackKeywords, SuspendCallstackMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendCallstackMarker)); + var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait_Marker)); Assert.NotEmpty(suspendCallstacks); Assert.All(suspendCallstacks, cs => @@ -411,7 +404,7 @@ public async Task RuntimeAsync_SuspendAsyncCallstackEmittedOnAwait() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendDepthMarker() + private static async Task RuntimeAsync_SuspendCallstackDepthMatchesChain_Marker() { await Task.Yield(); await RuntimeAsync_ChainedYield(); @@ -420,17 +413,17 @@ static async Task SuspendDepthMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_SuspendCallstackDepthMatchesChain() { - var events = await CollectEventsAsync(CallstackKeywords, SuspendDepthMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_SuspendCallstackDepthMatchesChain_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendDepthMarker)); + var suspendCallstacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(RuntimeAsync_SuspendCallstackDepthMatchesChain_Marker)); // The expected [NoInlining] frames in order (innermost first): - // RuntimeAsync_InnerYield -> RuntimeAsync_ChainedYield -> SuspendDepthMarker + // RuntimeAsync_InnerYield -> RuntimeAsync_ChainedYield -> RuntimeAsync_SuspendCallstackDepthMatchesChain_Marker Assert.NotEmpty(suspendCallstacks); - string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_ChainedYield), nameof(SuspendDepthMarker)]; + string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_ChainedYield), nameof(RuntimeAsync_SuspendCallstackDepthMatchesChain_Marker)]; Assert.True( HasCallstackWithExpectedFrames(suspendCallstacks, expectedFrames), $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); @@ -438,7 +431,7 @@ public async Task RuntimeAsync_SuspendCallstackDepthMatchesChain() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendPrecedesCompleteMarker() + private static async Task RuntimeAsync_SuspendCallstackPrecedesComplete_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -447,15 +440,15 @@ static async Task SuspendPrecedesCompleteMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_SuspendCallstackPrecedesComplete() { - var events = await CollectEventsAsync(CallstackKeywords, SuspendPrecedesCompleteMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_SuspendCallstackPrecedesComplete_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); // Find the suspend callstack via marker to get the context ID - var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendPrecedesCompleteMarker)); - Assert.True(suspendStacks.Count >= 1, $"Expected at least one suspend callstack with marker, got {suspendStacks.Count}"); + var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(RuntimeAsync_SuspendCallstackPrecedesComplete_Marker)); + Assert.NotEmpty(suspendStacks); ulong taskId = suspendStacks[0].TaskId; Assert.True(taskId > 0, "Expected non-zero context ID"); @@ -473,7 +466,7 @@ public async Task RuntimeAsync_SuspendCallstackPrecedesComplete() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SuspendDeeperMarker() + private static async Task RuntimeAsync_SuspendCallstackDeeperThanInitialResume_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -482,16 +475,16 @@ static async Task SuspendDeeperMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_SuspendCallstackDeeperThanInitialResume() { - var events = await CollectEventsAsync(CallstackKeywords, SuspendDeeperMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_SuspendCallstackDeeperThanInitialResume_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(SuspendDeeperMarker)); - var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(SuspendDeeperMarker)); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_SuspendCallstackDeeperThanInitialResume_Marker)); + var suspendStacks = stream.CallstacksWithMarker(AsyncEventID.SuspendAsyncCallstack, nameof(RuntimeAsync_SuspendCallstackDeeperThanInitialResume_Marker)); - Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker, got {resumeStacks.Count}"); - Assert.True(suspendStacks.Count >= 1, $"Expected at least one suspend callstack with marker, got {suspendStacks.Count}"); + Assert.NotEmpty(resumeStacks); + Assert.NotEmpty(suspendStacks); // First resume (after initial Yield) should be shallow, first suspend (RuntimeAsync_InnerYield's Yield) should be deeper var firstResume = resumeStacks[0]; @@ -502,7 +495,7 @@ public async Task RuntimeAsync_SuspendCallstackDeeperThanInitialResume() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreatePrecedesResumeMarker() + private static async Task RuntimeAsync_CreateCallstackPrecedesResumeCallstack_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -511,13 +504,13 @@ static async Task CreatePrecedesResumeMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CreateCallstackPrecedesResumeCallstack() { - var events = await CollectEventsAsync(CallstackKeywords, CreatePrecedesResumeMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CreateCallstackPrecedesResumeCallstack_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreatePrecedesResumeMarker)); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CreatePrecedesResumeMarker)); + var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(RuntimeAsync_CreateCallstackPrecedesResumeCallstack_Marker)); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CreateCallstackPrecedesResumeCallstack_Marker)); Assert.NotEmpty(createStacks); Assert.NotEmpty(resumeStacks); @@ -539,7 +532,7 @@ public async Task RuntimeAsync_CreateCallstackPrecedesResumeCallstack() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CreateResumeMatchMarker() + private static async Task RuntimeAsync_CreateAndFirstResumeCallstacksMatch_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -548,13 +541,13 @@ static async Task CreateResumeMatchMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CreateAndFirstResumeCallstacksMatch() { - var events = await CollectEventsAsync(CallstackKeywords, CreateResumeMatchMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CreateAndFirstResumeCallstacksMatch_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(CreateResumeMatchMarker)); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CreateResumeMatchMarker)); + var createStacks = stream.CallstacksWithMarker(AsyncEventID.CreateAsyncCallstack, nameof(RuntimeAsync_CreateAndFirstResumeCallstacksMatch_Marker)); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CreateAndFirstResumeCallstacksMatch_Marker)); Assert.NotEmpty(createStacks); Assert.NotEmpty(resumeStacks); @@ -580,7 +573,7 @@ public async Task RuntimeAsync_CreateAndFirstResumeCallstacksMatch() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CallstackOnResumeMarker() + private static async Task RuntimeAsync_CallstackEmittedOnResume_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -589,12 +582,12 @@ static async Task CallstackOnResumeMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CallstackEmittedOnResume() { - var events = await CollectEventsAsync(CallstackKeywords, CallstackOnResumeMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackEmittedOnResume_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackOnResumeMarker)); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CallstackEmittedOnResume_Marker)); Assert.NotEmpty(callstacks); Assert.All(callstacks, cs => @@ -607,7 +600,7 @@ public async Task RuntimeAsync_CallstackEmittedOnResume() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CallstackDepthMarker() + private static async Task RuntimeAsync_CallstackDepthMatchesChain_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -616,17 +609,17 @@ static async Task CallstackDepthMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CallstackDepthMatchesChain() { - var events = await CollectEventsAsync(CallstackKeywords, CallstackDepthMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackDepthMatchesChain_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackDepthMarker)); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CallstackDepthMatchesChain_Marker)); // The expected [NoInlining] frames in order (innermost first): - // RuntimeAsync_InnerYield -> CallstackDepthMarker + // RuntimeAsync_InnerYield -> RuntimeAsync_CallstackDepthMatchesChain_Marker Assert.NotEmpty(callstacks); - string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(CallstackDepthMarker)]; + string[] expectedFrames = [nameof(RuntimeAsync_InnerYield), nameof(RuntimeAsync_CallstackDepthMatchesChain_Marker)]; Assert.True( HasCallstackWithExpectedFrames(callstacks, expectedFrames), $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); @@ -634,7 +627,7 @@ public async Task RuntimeAsync_CallstackDepthMatchesChain() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationNormalMarker() + private static async Task RuntimeAsync_CallstackSimulation_NormalCompletion_Marker() { await Task.Yield(); await RuntimeAsync_InnerYield(); @@ -643,17 +636,17 @@ static async Task SimulationNormalMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CallstackSimulation_NormalCompletion() { - var events = await CollectEventsAsync(CallstackKeywords, SimulationNormalMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackSimulation_NormalCompletion_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - AssertCallstackSimulationReachesZero(stream, nameof(SimulationNormalMarker)); + AssertCallstackSimulationReachesZero(stream, nameof(RuntimeAsync_CallstackSimulation_NormalCompletion_Marker)); } [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationHandledMarker() + private static async Task RuntimeAsync_CallstackSimulation_HandledException_Marker() { await RuntimeAsync_DeepOuterCatches(); } @@ -661,28 +654,28 @@ static async Task SimulationHandledMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CallstackSimulation_HandledException() { - var events = await CollectEventsAsync(CallstackKeywords, SimulationHandledMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackSimulation_HandledException_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - AssertCallstackSimulationReachesZero(stream, nameof(SimulationHandledMarker)); + AssertCallstackSimulationReachesZero(stream, nameof(RuntimeAsync_CallstackSimulation_HandledException_Marker)); } [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationUnhandledMarker() + private static async Task RuntimeAsync_CallstackSimulation_UnhandledException_Marker() { await RuntimeAsync_DeepUnhandledOuter(); } [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task SimulationUnhandledMarkerCatcher() + private static async Task RuntimeAsync_CallstackSimulation_UnhandledException_Catcher_Marker() { try { - await SimulationUnhandledMarker(); + await RuntimeAsync_CallstackSimulation_UnhandledException_Marker(); } catch (InvalidOperationException) { @@ -692,28 +685,28 @@ static async Task SimulationUnhandledMarkerCatcher() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_CallstackSimulation_UnhandledException() { - var events = await CollectEventsAsync(CallstackKeywords, SimulationUnhandledMarkerCatcher); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackSimulation_UnhandledException_Catcher_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - AssertCallstackSimulationReachesZero(stream, nameof(SimulationUnhandledMarker)); + AssertCallstackSimulationReachesZero(stream, nameof(RuntimeAsync_CallstackSimulation_UnhandledException_Marker)); } [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task UnhandledUnwindMarker() + private static async Task RuntimeAsync_UnhandledExceptionUnwind_Marker() { await RuntimeAsync_DeepUnhandledOuter(); } [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(false)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task UnhandledUnwindCatcher() + private static async Task RuntimeAsync_UnhandledExceptionUnwind_Catcher_Marker() { try { - await UnhandledUnwindMarker(); + await RuntimeAsync_UnhandledExceptionUnwind_Marker(); } catch (InvalidOperationException) { @@ -723,13 +716,13 @@ static async Task UnhandledUnwindCatcher() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_UnhandledExceptionUnwind() { - var events = await CollectEventsAsync(CallstackKeywords, UnhandledUnwindCatcher); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_UnhandledExceptionUnwind_Catcher_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(UnhandledUnwindMarker)); - Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{nameof(UnhandledUnwindMarker)}'"); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_UnhandledExceptionUnwind_Marker)); + Assert.NotEmpty(resumeStacks); ulong taskId = resumeStacks[0].TaskId; @@ -741,7 +734,7 @@ public async Task RuntimeAsync_UnhandledExceptionUnwind() Assert.Contains(AsyncEventID.CompleteAsyncContext, eventIds); // Verify unwind frame count for this task - // UnhandledUnwindMarker -> RuntimeAsync_DeepUnhandledOuter -> RuntimeAsync_DeepUnhandledMiddle -> RuntimeAsync_DeepUnhandledInnerThrows, 4 frames deep after the initial resume. + // Marker -> RuntimeAsync_DeepUnhandledOuter -> RuntimeAsync_DeepUnhandledMiddle -> RuntimeAsync_DeepUnhandledInnerThrows, 4 frames deep after the initial resume. var unwindEvents = taskEvts.Where(e => e.EventId == AsyncEventID.UnwindAsyncException).ToList(); Assert.NotEmpty(unwindEvents); Assert.All(unwindEvents, e => Assert.Equal(4u, e.UnwindFrameCount)); @@ -749,7 +742,7 @@ public async Task RuntimeAsync_UnhandledExceptionUnwind() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task HandledUnwindMarker() + private static async Task RuntimeAsync_HandledExceptionUnwind_Marker() { await RuntimeAsync_DeepOuterCatches(); } @@ -757,13 +750,13 @@ static async Task HandledUnwindMarker() [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_HandledExceptionUnwind() { - var events = await CollectEventsAsync(CallstackKeywords, HandledUnwindMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_HandledExceptionUnwind_Marker); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(HandledUnwindMarker)); - Assert.True(resumeStacks.Count >= 1, $"Expected at least one resume callstack with marker '{nameof(HandledUnwindMarker)}'"); + var resumeStacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_HandledExceptionUnwind_Marker)); + Assert.NotEmpty(resumeStacks); ulong taskId = resumeStacks[0].TaskId; @@ -925,14 +918,25 @@ public void RuntimeAsync_PeriodicTimerFlush_PreservesOwnerThreadId() listener.RunWithCallback(e => { if (!workerIdReady.IsSet) + { return; + } + if (e.EventId != AsyncEventsId || e.Payload is null || e.Payload.Count == 0) + { return; + } + if (e.Payload[0] is not byte[] payload) + { return; + } + EventBufferHeader? header = ParseEventBufferHeader(payload); if (header is not null && header.Value.OsThreadId == workerOsThreadId) + { events.Events.Enqueue(e); + } }, () => { SendFlushCommand(); @@ -1098,7 +1102,7 @@ public static IEnumerable KeywordGatekeepingData() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task KeywordGatekeepingMarker() + private static async Task RuntimeAsync_KeywordGatekeeping_Marker() { await RuntimeAsync_OuterCatches(); await RuntimeAsync_ChainedYield(); @@ -1118,7 +1122,7 @@ public async Task RuntimeAsync_KeywordGatekeeping(long keywordValue, AsyncEventI // Run a scenario that exercises all event types: resume, suspend, // complete, method events, callstacks, and exception unwinds. // Only the events matching the enabled keyword should be emitted. - var events = await CollectEventsAsync(kw, KeywordGatekeepingMarker); + var events = await CollectEventsAsync(kw, RuntimeAsync_KeywordGatekeeping_Marker); // DumpAllEvents(events); @@ -1192,7 +1196,7 @@ public void RuntimeAsync_MetadataEventEmittedOnceAcrossThreads() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task NativeIPDeltaRoundtripMarker() + private static async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip_Marker() { await RuntimeAsync_ChainedYield(); await RuntimeAsync_DeepOuterCatches(); @@ -1207,10 +1211,10 @@ public async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip() // methods at different JIT-assigned addresses, the deltas between consecutive // NativeIPs will naturally span both directions. This exercises the full // zigzag + LEB128 encode/decode path through the production serializer. - var events = await CollectEventsAsync(CallstackKeywords, NativeIPDeltaRoundtripMarker); + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackNativeIPDeltaRoundtrip_Marker); var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(NativeIPDeltaRoundtripMarker)); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CallstackNativeIPDeltaRoundtrip_Marker)); Assert.NotEmpty(callstacks); // Find callstacks with 3+ frames — enough depth for meaningful deltas. @@ -1251,7 +1255,7 @@ public async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip() [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - static async Task CallstackStressMarker(int depth) + private static async Task RuntimeAsync_CallstackStressWithVaryingDepths_Marker(int depth) { await RuntimeAsync_RecursiveChain(depth); } @@ -1266,7 +1270,7 @@ public void RuntimeAsync_CallstackStressWithVaryingDepths() // Stress test: run many async calls with varying callstack depths. // Varying sizes mean some callstacks will land at buffer boundaries, // naturally exercising the overflow/rewind path in callstack emission. - // lambda -> CallstackStressMarker(d) -> RuntimeAsync_RecursiveChain(d) produces d + 2 frames. + // lambda -> Marker(d) -> RuntimeAsync_RecursiveChain(d) produces d + 2 frames. const int iterations = 200; int[] depths = new int[iterations]; var rng = new Random(42); @@ -1278,14 +1282,14 @@ public void RuntimeAsync_CallstackStressWithVaryingDepths() RunScenarioAndFlush(async () => { for (int i = 0; i < iterations; i++) - await CallstackStressMarker(depths[i]); + await RuntimeAsync_CallstackStressWithVaryingDepths_Marker(depths[i]); }); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(CallstackStressMarker)); + var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CallstackStressWithVaryingDepths_Marker)); // Verify all callstacks have valid frame data that resolves to managed methods. foreach (var cs in callstacks) @@ -1303,15 +1307,15 @@ public void RuntimeAsync_CallstackStressWithVaryingDepths() } // One resume callstack per iteration (marker filters out noise). - // lambda -> CallstackStressMarker -> RuntimeAsync_RecursiveChain(d) produces d + 2 frames. + // lambda -> Marker -> RuntimeAsync_RecursiveChain(d) produces d + 2 frames. Assert.True(callstacks.Count >= iterations, $"Expected at least {iterations} callstacks with marker, got {callstacks.Count}"); for (int i = 0; i < iterations; i++) { - // lambda + CallstackStressMarker + RuntimeAsync_RecursiveChain(d) = d + 2 + // lambda + Marker + RuntimeAsync_RecursiveChain(d) = d + 2 int expected = depths[i] + 2; int actual = callstacks[i].FrameCount; - Assert.True(actual == expected, $"Iteration {i}: expected depth {expected} (lambda -> CallstackStressMarker -> RuntimeAsync_RecursiveChain({depths[i]})), got {actual}"); + Assert.True(actual == expected, $"Iteration {i}: expected depth {expected} (lambda -> Marker -> RuntimeAsync_RecursiveChain({depths[i]})), got {actual}"); } // Verify multiple buffer flushes occurred. From 1d1537bc9e7e908a0d0f711850dc7150c87c2422 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Thu, 4 Jun 2026 19:20:34 +0200 Subject: [PATCH 15/19] Add ValueTask tests to asyncv2. --- .../AsyncProfilerV2Tests.cs | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) 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 cd498654ec1336..1df0a059246a74 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 @@ -147,6 +147,27 @@ private static async Task RuntimeAsync_WrapperTestC(List<(string MethodName, int captures.Add((nameof(RuntimeAsync_WrapperTestC), GetCurrentWrapperSlot(nameof(RuntimeAsync_WrapperTestC)))); } + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_Level1() + { + await RuntimeAsync_ValueTask_Level2(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_Level2() + { + await RuntimeAsync_ValueTask_Level3(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_Level3() + { + await Task.Yield(); + } + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] public async Task RuntimeAsync_EventBufferHeaderFormat() { @@ -1537,5 +1558,228 @@ public async Task RuntimeAsync_MetadataMatchesWrapperMethods() Assert.Equal(meta.WrapperCount, actualCount); } } + + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_EventSequenceOrder_Marker() + { + await RuntimeAsync_ValueTask_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ValueTask_EventSequenceOrder() + { + var events = await CollectEventsAsync(CallstackKeywords, async () => await RuntimeAsync_ValueTask_EventSequenceOrder_Marker()); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ValueTask_EventSequenceOrder_Marker)); + Assert.NotEmpty(markerCallstacks); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_MethodEventsEmitted_Marker() + { + await RuntimeAsync_ValueTask_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ValueTask_MethodEventsEmitted() + { + var events = await CollectEventsAsync(MethodKeywords | CoreKeywords, async () => await RuntimeAsync_ValueTask_MethodEventsEmitted_Marker()); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var methodEvents = stream.All + .Where(e => e.EventId is AsyncEventID.ResumeAsyncMethod or AsyncEventID.CompleteAsyncMethod) + .Select(e => e.EventId) + .ToList(); + + int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); + int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); + + // Marker -> Level1 -> Level2 -> Level3 + Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events for ValueTask chain, got {resumeCount}"); + Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events for ValueTask chain, got {completeCount}"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_CallstackDepthMatchesChainDepth_Marker() + { + await RuntimeAsync_ValueTask_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ValueTask_CallstackDepthMatchesChainDepth() + { + var events = await CollectEventsAsync(CallstackKeywords, async () => await RuntimeAsync_ValueTask_CallstackDepthMatchesChainDepth_Marker()); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ValueTask_CallstackDepthMatchesChainDepth_Marker)); + Assert.NotEmpty(markerCallstacks); + + // Async lambda -> Marker -> Level1 -> Level2 -> Level3. + Assert.Equal(5, markerCallstacks[0].FrameCount); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_CallstackFramesHaveDistinctMethodIds_Marker() + { + await RuntimeAsync_ValueTask_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ValueTask_CallstackFramesHaveDistinctMethodIds() + { + var events = await CollectEventsAsync(CallstackKeywords, async () => await RuntimeAsync_ValueTask_CallstackFramesHaveDistinctMethodIds_Marker()); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ValueTask_CallstackFramesHaveDistinctMethodIds_Marker)); + Assert.NotEmpty(markerCallstacks); + + var methodIds = markerCallstacks[0].Frames.Select(f => f.MethodId).ToList(); + Assert.Equal(methodIds.Count, methodIds.Distinct().Count()); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_HandledException_InnerThrows_Marker() + { + await Task.Yield(); + throw new InvalidOperationException("valuetask inner throw"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_HandledException_Handled_Marker() + { + try + { + await RuntimeAsync_ValueTask_HandledException_InnerThrows_Marker(); + } + catch (InvalidOperationException) + { + } + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_HandledException_EmitsUnwindAndComplete_Marker() + { + await RuntimeAsync_ValueTask_HandledException_Handled_Marker(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ValueTask_HandledException_EmitsUnwindAndComplete() + { + var events = await CollectEventsAsync(CallstackKeywords | UnwindAsyncExceptionKeyword, async () => await RuntimeAsync_ValueTask_HandledException_EmitsUnwindAndComplete_Marker()); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ValueTask_HandledException_EmitsUnwindAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx > resumeIdx, "Expected UnwindAsyncException after Resume"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx + 1); + Assert.True(completeIdx > unwindIdx, "Expected CompleteAsyncContext after Unwind"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_UnhandledException_UnhandledOuter_Marker() + { + await RuntimeAsync_ValueTask_UnhandledException_UnhandledInner_Marker(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_UnhandledException_UnhandledInner_Marker() + { + await Task.Yield(); + throw new InvalidOperationException("valuetask unhandled inner"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async ValueTask RuntimeAsync_ValueTask_UnhandledException_EmitsUnwindAndComplete_Marker() + { + await RuntimeAsync_ValueTask_UnhandledException_UnhandledOuter_Marker(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ValueTask_UnhandledException_EmitsUnwindAndComplete() + { + var events = await CollectEventsAsync(CallstackKeywords | UnwindAsyncExceptionKeyword, async () => + { + try + { + await RuntimeAsync_ValueTask_UnhandledException_EmitsUnwindAndComplete_Marker(); + } + catch (InvalidOperationException) + { + } + }); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ValueTask_UnhandledException_EmitsUnwindAndComplete_Marker)); + Assert.NotEmpty(markerCallstacks); + + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + Assert.True(createIdx >= 0, "Expected CreateAsyncContext"); + + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int unwindIdx = ids.IndexOf(AsyncEventID.UnwindAsyncException, resumeIdx + 1); + Assert.True(unwindIdx > resumeIdx, "Expected UnwindAsyncException after Resume"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, unwindIdx + 1); + Assert.True(completeIdx > unwindIdx, "Expected CompleteAsyncContext after Unwind"); + } } } From 2274a6bd16441c550c8379715c20320cb6b06900 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Fri, 5 Jun 2026 12:08:28 +0200 Subject: [PATCH 16/19] Port some asyncv1 tests to asyncv2. --- .../AsyncProfilerTests.cs | 51 +- .../AsyncProfilerV1Tests.cs | 248 ++++----- .../AsyncProfilerV2Tests.cs | 480 +++++++++++++++++- 3 files changed, 635 insertions(+), 144 deletions(-) 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 fcce97699e4299..0918a9c5807040 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 @@ -855,7 +855,7 @@ private static void RunScenarioAndFlush(Func scenario) // V1 (task-based) async: the dispatcher's finally block emits CompleteAsyncContext // after inner.MoveNext() returns, but MoveNext() already set the task result which // unblocks this thread. Brief sleep ensures the pool thread's finally completes. - // V2 (runtime-async) does not have this issue — Complete fires inside the dispatch + // V2 (runtime-async) does not have this issue -- Complete fires inside the dispatch // loop before the task is signaled. // // Clear SynchronizationContext so RuntimeAsync continuations don't capture @@ -1023,6 +1023,55 @@ private static void AssertCallstackSimulationReachesZero(ParsedEventStream strea Assert.Equal(0, stackDepth); } + private static void AssertExactlyOneCreateAndComplete(ParsedEventStream stream, ulong taskId, string chainName) + { + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + int creates = ids.Count(id => id == AsyncEventID.CreateAsyncContext); + int completes = ids.Count(id => id == AsyncEventID.CompleteAsyncContext); + Assert.True(creates == 1, $"Expected exactly 1 CreateAsyncContext for {chainName} (TaskId {taskId}), got {creates}"); + Assert.True(completes == 1, $"Expected exactly 1 CompleteAsyncContext for {chainName} (TaskId {taskId}), got {completes}"); + } + + // V1-friendly variant: V1's per-MoveNext dispatcher model emits one Create per await + // suspension, so a method with N awaits produces N dispatchers / N Creates on the same + // TaskId. The invariant we can still assert is creates == completes (both >= 1). + private static void AssertCreateEqualsCompleteForTask(ParsedEventStream stream, ulong taskId, string chainName) + { + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + int creates = ids.Count(id => id == AsyncEventID.CreateAsyncContext); + int completes = ids.Count(id => id == AsyncEventID.CompleteAsyncContext); + Assert.True(creates >= 1, $"Expected at least 1 CreateAsyncContext for {chainName} (TaskId {taskId}), got {creates}"); + Assert.True(creates == completes, $"Expected CreateAsyncContext count == CompleteAsyncContext count for {chainName} (TaskId {taskId}), got {creates} creates and {completes} completes"); + } + + private sealed class InlinePostSynchronizationContext : SynchronizationContext + { + private int _postCount; + public int PostCount => _postCount; + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref _postCount); + d(state); + } + } + + private sealed class InlineRunTaskScheduler : TaskScheduler + { + private int _queuedCount; + public int QueuedCount => _queuedCount; + + protected override void QueueTask(Task task) + { + Interlocked.Increment(ref _queuedCount); + TryExecuteTask(task); + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + + protected override IEnumerable? GetScheduledTasks() => null; + } + private static class Deserializer { public static void ReadInt32(ReadOnlySpan buffer, ref int index, out int value) 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 319cf61502b977..88370dbd8ddf11 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 @@ -89,6 +89,18 @@ private static async Task TaskAsync_UnhandledExceptionInner() throw new InvalidOperationException("unhandled inner"); } + [RuntimeAsyncMethodGeneration(false)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task TaskAsync_RecursiveChain(int depth) + { + if (depth <= 1) + { + await Task.Delay(100); + return; + } + await TaskAsync_RecursiveChain(depth - 1); + } + [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] private static async ValueTask ValueTaskAsync_Level1() @@ -395,7 +407,7 @@ or AsyncEventID.CompleteAsyncMethod Assert.Equal(1, sequence.Count(id => id == AsyncEventID.UnwindAsyncException)); // Around the Unwind: the throwing method's Resume precedes it, and the catching - // method's Resume → Complete pair follows. + // method's Resume -> Complete pair follows. int unwindIdx = sequence.IndexOf(AsyncEventID.UnwindAsyncException); Assert.Equal(AsyncEventID.ResumeAsyncMethod, sequence[unwindIdx - 1]); Assert.Equal(AsyncEventID.ResumeAsyncMethod, sequence[unwindIdx + 1]); @@ -507,7 +519,7 @@ public void TaskAsync_CallstackDepthMatchesChainDepth() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CallstackDepthMatchesChainDepth_Marker)); Assert.NotEmpty(markerCallstacks); - // TaskAsync_CallstackDepthMarker → Level1 → Level2 → Level3: deepest callstack should have exactly 4 frames + // TaskAsync_CallstackDepthMarker -> Level1 -> Level2 -> Level3: deepest callstack should have exactly 4 frames Assert.Equal(4, markerCallstacks[0].FrameCount); } @@ -718,7 +730,7 @@ public void TaskAsync_CompleteChain_DoesNotEmitAppendEvents() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CompleteChain_DoesNotEmitAppendEvents_Marker)); Assert.NotEmpty(markerCallstacks); - // No Append events should fire on this context — the chain was complete at Resume time. + // No Append events should fire on this context -- the chain was complete at Resume time. ulong chainTaskId = markerCallstacks[0].TaskId; var appendEvents = stream.ForTask(chainTaskId) .Where(e => e.EventId == AsyncEventID.AppendAsyncCallstack) @@ -726,18 +738,6 @@ public void TaskAsync_CompleteChain_DoesNotEmitAppendEvents() Assert.Empty(appendEvents); } - private sealed class InlinePostSynchronizationContext : SynchronizationContext - { - private int _postCount; - public int PostCount => _postCount; - - public override void Post(SendOrPostCallback d, object? state) - { - Interlocked.Increment(ref _postCount); - d(state); - } - } - private static InlinePostSynchronizationContext? s_taskAsyncSyncContextCtx; [RuntimeAsyncMethodGeneration(false)] @@ -785,7 +785,7 @@ public void TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker)); Assert.NotEmpty(markerCallstacks); - // Verify the standard Create → Resume → Complete sequence fired for our context. + // Verify the standard Create -> Resume -> Complete sequence fired for our context. ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -799,22 +799,6 @@ public void TaskAsync_CustomSyncContext_EmitsContextEventsAndCallstack() Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); } - private sealed class InlineRunTaskScheduler : TaskScheduler - { - private int _queuedCount; - public int QueuedCount => _queuedCount; - - protected override void QueueTask(Task task) - { - Interlocked.Increment(ref _queuedCount); - TryExecuteTask(task); - } - - protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; - - protected override IEnumerable? GetScheduledTasks() => null; - } - [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] private static async Task TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker() @@ -855,7 +839,7 @@ public void TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker)); Assert.NotEmpty(markerCallstacks); - // Verify standard Create → Resume → Complete sequence for our context. + // Verify standard Create -> Resume -> Complete sequence for our context. ulong taskId = markerCallstacks[0].TaskId; var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); @@ -942,64 +926,65 @@ public void TaskAsync_KeywordGatekeeping(long keywordValue, AsyncEventID[] allow [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAllBranchA() + private static async Task TaskAsync_WhenAll_TracksAllBranches_BranchA_Marker() { await Task.Delay(100); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAllBranchB() + private static async Task TaskAsync_WhenAll_TracksAllBranches_BranchB_Marker() { await Task.Delay(120); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAllBranchC() + private static async Task TaskAsync_WhenAll_TracksAllBranches_BranchC_Marker() { await Task.Delay(140); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAll_TracksAllBranchesAndJoin_Marker() + private static async Task TaskAsync_WhenAll_TracksAllBranches_Marker() { - await Task.WhenAll(TaskAsync_WhenAllBranchA(), TaskAsync_WhenAllBranchB(), TaskAsync_WhenAllBranchC()); + await Task.WhenAll( + TaskAsync_WhenAll_TracksAllBranches_BranchA_Marker(), + TaskAsync_WhenAll_TracksAllBranches_BranchB_Marker(), + TaskAsync_WhenAll_TracksAllBranches_BranchC_Marker()); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_WhenAll_TracksAllBranchesAndJoin() + public void TaskAsync_WhenAll_TracksAllBranches() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_WhenAll_TracksAllBranchesAndJoin_Marker()); + RunScenarioAndFlush(() => TaskAsync_WhenAll_TracksAllBranches_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAll_TracksAllBranchesAndJoin_Marker)); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAll_TracksAllBranches_Marker)); Assert.NotEmpty(markerCallstacks); // Each branch is its own async chain; its inner await of Task.Delay produces a Resume callstack containing the branch frame. - var branchACallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAllBranchA)); - var branchBCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAllBranchB)); - var branchCCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAllBranchC)); - Assert.True(branchACallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchA)}"); - Assert.True(branchBCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchB)}"); - Assert.True(branchCCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAllBranchC)}"); - - // Every Create must be balanced by a Complete. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); - - Assert.True(createCount >= 4, - $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); - - // The outer marker's chain should fire the standard Create → Resume → Complete sequence on its own TaskId, in that order. + var branchACallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAll_TracksAllBranches_BranchA_Marker)); + var branchBCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAll_TracksAllBranches_BranchB_Marker)); + var branchCCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAll_TracksAllBranches_BranchC_Marker)); + Assert.NotEmpty(branchACallstacks); + Assert.NotEmpty(branchBCallstacks); + Assert.NotEmpty(branchCCallstacks); + + // Each tracked chain (3 branches + outer marker) must see exactly one Create and one Complete on its own TaskId. + AssertExactlyOneCreateAndComplete(stream, branchACallstacks[0].TaskId, nameof(TaskAsync_WhenAll_TracksAllBranches_BranchA_Marker)); + AssertExactlyOneCreateAndComplete(stream, branchBCallstacks[0].TaskId, nameof(TaskAsync_WhenAll_TracksAllBranches_BranchB_Marker)); + AssertExactlyOneCreateAndComplete(stream, branchCCallstacks[0].TaskId, nameof(TaskAsync_WhenAll_TracksAllBranches_BranchC_Marker)); + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(TaskAsync_WhenAll_TracksAllBranches_Marker)); + + // The outer marker's chain should fire the standard Create -> Resume -> Complete sequence on its own TaskId, in that order. ulong markerTaskId = markerCallstacks[0].TaskId; var markerIds = stream.ForTask(markerTaskId).Select(e => e.EventId).ToList(); @@ -1019,69 +1004,67 @@ public void TaskAsync_WhenAll_TracksAllBranchesAndJoin() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAnyFast() + private static async Task TaskAsync_WhenAny_TracksAllBranches_Fast_Marker() { await Task.Delay(50); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAnySlow1() + private static async Task TaskAsync_WhenAny_TracksAllBranches_Slow1_Marker() { await Task.Delay(400); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAnySlow2() + private static async Task TaskAsync_WhenAny_TracksAllBranches_Slow2_Marker() { await Task.Delay(600); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes_Marker() + private static async Task TaskAsync_WhenAny_TracksAllBranches_Marker() { - Task fast = TaskAsync_WhenAnyFast(); - Task slow1 = TaskAsync_WhenAnySlow1(); - Task slow2 = TaskAsync_WhenAnySlow2(); + Task fast = TaskAsync_WhenAny_TracksAllBranches_Fast_Marker(); + Task slow1 = TaskAsync_WhenAny_TracksAllBranches_Slow1_Marker(); + Task slow2 = TaskAsync_WhenAny_TracksAllBranches_Slow2_Marker(); await Task.WhenAny(fast, slow1, slow2); await Task.WhenAll(slow1, slow2); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes() + public void TaskAsync_WhenAny_TracksAllBranches() { var events = CollectEvents(ResumeAsyncCallstackKeyword | CoreKeywords, () => { - RunScenarioAndFlush(() => TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes_Marker()); + RunScenarioAndFlush(() => TaskAsync_WhenAny_TracksAllBranches_Marker()); }); // DumpAllEvents(events); var stream = ParseAllEvents(events); - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes_Marker)); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAny_TracksAllBranches_Marker)); Assert.NotEmpty(markerCallstacks); - // All branches — including the slow ones whose completion the outer is no longer - // strictly waiting on after WhenAny returned — must produce their own Resume + // All branches - including the slow ones whose completion the outer is no longer + // strictly waiting on after WhenAny returned -- must produce their own Resume // callstacks. This proves their dispatcher lifetimes are tracked independently. - var fastCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAnyFast)); - var slow1Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAnySlow1)); - var slow2Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAnySlow2)); - Assert.True(fastCallstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnyFast)}"); - Assert.True(slow1Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow1)}"); - Assert.True(slow2Callstacks.Count > 0, $"Expected Resume callstack for {nameof(TaskAsync_WhenAnySlow2)}"); - - // Every Create must be balanced by a Complete. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); - - Assert.True(createCount >= 4, - $"Expected at least 4 CreateAsyncContext events (3 branches + outer), got {createCount}"); + var fastCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAny_TracksAllBranches_Fast_Marker)); + var slow1Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAny_TracksAllBranches_Slow1_Marker)); + var slow2Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WhenAny_TracksAllBranches_Slow2_Marker)); + Assert.NotEmpty(fastCallstacks); + Assert.NotEmpty(slow1Callstacks); + Assert.NotEmpty(slow2Callstacks); + + // Each tracked chain (3 branches + outer marker) must see exactly one Create and one Complete on its own TaskId. + AssertExactlyOneCreateAndComplete(stream, fastCallstacks[0].TaskId, nameof(TaskAsync_WhenAny_TracksAllBranches_Fast_Marker)); + AssertExactlyOneCreateAndComplete(stream, slow1Callstacks[0].TaskId, nameof(TaskAsync_WhenAny_TracksAllBranches_Slow1_Marker)); + AssertExactlyOneCreateAndComplete(stream, slow2Callstacks[0].TaskId, nameof(TaskAsync_WhenAny_TracksAllBranches_Slow2_Marker)); + AssertCreateEqualsCompleteForTask(stream, markerCallstacks[0].TaskId, nameof(TaskAsync_WhenAny_TracksAllBranches_Marker)); // The outer marker's chain: exactly one Create, at least two Resumes (one after // WhenAny, one after WhenAll on the slow branches), then Complete. @@ -1095,22 +1078,6 @@ public void TaskAsync_WhenAny_TracksAllBranchesWithIndependentLifetimes() int completeCountForMarker = markerIds.Count(id => id == AsyncEventID.CompleteAsyncContext); Assert.True(completeCountForMarker >= 1, "Expected at least one CompleteAsyncContext for the outer marker"); - - // First event for the marker is its Create; last is a Complete. - Assert.Equal(AsyncEventID.CreateAsyncContext, markerIds[0]); - Assert.Equal(AsyncEventID.CompleteAsyncContext, markerIds[^1]); - } - - [RuntimeAsyncMethodGeneration(false)] - [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_RecursiveChain(int depth) - { - if (depth <= 1) - { - await Task.Delay(100); - return; - } - await TaskAsync_RecursiveChain(depth - 1); } [RuntimeAsyncMethodGeneration(false)] @@ -1219,7 +1186,7 @@ public void TaskAsync_CallstackStressWithVaryingDepths() Assert.True(markerCallstacks.Count >= iterations, $"Expected at least {iterations} callstacks with marker, got {markerCallstacks.Count}"); - // Verify multiple buffer flushes occurred — proves the buffer machinery is exercised. + // Verify multiple buffer flushes occurred -- proves the buffer machinery is exercised. int bufferCount = 0; ForEachEventBufferPayload(events, _ => bufferCount++); Assert.True(bufferCount >= 3, $"Expected at least 3 buffer flushes, got {bufferCount}"); @@ -1227,7 +1194,7 @@ public void TaskAsync_CallstackStressWithVaryingDepths() [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_WaitThenYield(Task gate) + private static async Task TaskAsync_WaitThenYield_BalancesResumeAndComplete_WaitYield_Marker(Task gate) { await gate; await Task.Yield(); @@ -1235,13 +1202,13 @@ private static async Task TaskAsync_WaitThenYield(Task gate) [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete_Marker() + private static async Task TaskAsync_WaitThenYield_BalancesResumeAndComplete_Marker() { await Task.Yield(); var tcs = new TaskCompletionSource(); - Task b1 = TaskAsync_WaitThenYield(tcs.Task); - Task b2 = TaskAsync_WaitThenYield(tcs.Task); + Task b1 = TaskAsync_WaitThenYield_BalancesResumeAndComplete_WaitYield_Marker(tcs.Task); + Task b2 = TaskAsync_WaitThenYield_BalancesResumeAndComplete_WaitYield_Marker(tcs.Task); tcs.SetResult(); @@ -1249,11 +1216,11 @@ private static async Task TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAnd } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] - public void TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete() + public void TaskAsync_WaitThenYield_BalancesResumeAndComplete() { var events = CollectEvents(CoreKeywords | ResumeAsyncCallstackKeyword, () => { - RunScenarioAndFlush(() => TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete_Marker()); + RunScenarioAndFlush(() => TaskAsync_WaitThenYield_BalancesResumeAndComplete_Marker()); }); // DumpAllEvents(events); @@ -1278,29 +1245,29 @@ public void TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete() Assert.True(createCount >= 3, $"Expected fan-out chain to produce at least 3 CreateAsyncContext events (root + 2 child wraps), got {createCount}"); - var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_DoubleSuspendInOneMoveNext_BalancesResumeAndComplete_Marker)); + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_WaitThenYield_BalancesResumeAndComplete_Marker)); Assert.NotEmpty(markerCallstacks); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_ConfigureAwaitFalseLeaf() + private static async Task TaskAsync_ConfigureAwaitFalse_Leaf_Marker() { await Task.Delay(100).ConfigureAwait(false); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_ConfigureAwaitFalseMid() + private static async Task TaskAsync_ConfigureAwaitFalse_Mid_Marker() { - await TaskAsync_ConfigureAwaitFalseLeaf().ConfigureAwait(false); + await TaskAsync_ConfigureAwaitFalse_Leaf_Marker().ConfigureAwait(false); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] private static async Task TaskAsync_ConfigureAwaitFalse_Marker() { - await TaskAsync_ConfigureAwaitFalseMid().ConfigureAwait(false); + await TaskAsync_ConfigureAwaitFalse_Mid_Marker().ConfigureAwait(false); } [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncAndThreadingSupported))] @@ -1318,25 +1285,23 @@ public void TaskAsync_ConfigureAwaitFalse() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_ConfigureAwaitFalse_Marker)); Assert.NotEmpty(markerCallstacks); - // The deepest callstack should include all 3 chain frames. - var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; - var frameNames = deepest.Frames - .Select(f => GetMethodNameFromMethodId(deepest.CallstackType, f.MethodId)) + var frameNames = markerCallstacks[0].Frames + .Select(f => GetMethodNameFromMethodId(markerCallstacks[0].CallstackType, f.MethodId)) .Where(n => n is not null) .ToList(); - Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseLeaf), frameNames); - Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalseMid), frameNames); + + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalse_Leaf_Marker), frameNames); + Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalse_Mid_Marker), frameNames); Assert.Contains(nameof(TaskAsync_ConfigureAwaitFalse_Marker), frameNames); - // Every Create must be balanced by a Complete. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); + // ConfigureAwait(false) on a sequential await chain collapses Leaf -> Mid -> Marker into one + // continuation chain, so exactly one Create / one Complete is expected on the marker's TaskId. + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(TaskAsync_ConfigureAwaitFalse_Marker)); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_FaultedInner() + private static async Task TaskAsync_FaultedTask_Inner_Marker() { await Task.Delay(50); throw new InvalidOperationException("test fault"); @@ -1348,7 +1313,7 @@ private static async Task TaskAsync_FaultedTask_Marker() { try { - await TaskAsync_FaultedInner(); + await TaskAsync_FaultedTask_Inner_Marker(); } catch (InvalidOperationException) { @@ -1370,19 +1335,28 @@ public void TaskAsync_FaultedTask() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_FaultedTask_Marker)); Assert.NotEmpty(markerCallstacks); + var innerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_FaultedTask_Inner_Marker)); + Assert.NotEmpty(innerCallstacks); + // Every dispatcher that was Created must Complete, even on the fault path. int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); Assert.Equal(createCount, completeCount); - int unwindCount = stream.OfType(AsyncEventID.UnwindAsyncException).Count(); - Assert.True(unwindCount > 0, - "Expected at least one UnwindAsyncException event for the faulted inner task"); + // Both inner (faulting) and outer marker chains must see exactly one Create and one Complete on their own TaskId. + AssertExactlyOneCreateAndComplete(stream, innerCallstacks[0].TaskId, nameof(TaskAsync_FaultedTask_Inner_Marker)); + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(TaskAsync_FaultedTask_Marker)); + + // The unwind must be attributed to the inner faulting chain. + ulong innerTaskId = innerCallstacks[0].TaskId; + int unwindCountForInner = stream.ForTask(innerTaskId).Count(e => e.EventId == AsyncEventID.UnwindAsyncException); + Assert.True(unwindCountForInner >= 1, + $"Expected at least one UnwindAsyncException on the faulted inner chain (TaskId {innerTaskId}), got {unwindCountForInner}"); } [RuntimeAsyncMethodGeneration(false)] [MethodImpl(MethodImplOptions.NoInlining)] - private static async Task TaskAsync_CancelledInner(CancellationToken ct) + private static async Task TaskAsync_TaskCancellation_Inner_Marker(CancellationToken ct) { await Task.Delay(5000, ct); } @@ -1392,7 +1366,7 @@ private static async Task TaskAsync_CancelledInner(CancellationToken ct) private static async Task TaskAsync_TaskCancellation_Marker() { using var cts = new CancellationTokenSource(); - Task inner = TaskAsync_CancelledInner(cts.Token); + Task inner = TaskAsync_TaskCancellation_Inner_Marker(cts.Token); cts.CancelAfter(50); try { @@ -1418,14 +1392,12 @@ public void TaskAsync_TaskCancellation() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_TaskCancellation_Marker)); Assert.NotEmpty(markerCallstacks); - // Every Create must be balanced by a Complete on the cancellation path. - int createCount = stream.OfType(AsyncEventID.CreateAsyncContext).Count(); - int completeCount = stream.OfType(AsyncEventID.CompleteAsyncContext).Count(); - Assert.Equal(createCount, completeCount); + var innerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_TaskCancellation_Inner_Marker)); + Assert.NotEmpty(innerCallstacks); - // At least 2 Creates: inner cancelled task + outer marker. Both must Complete. - Assert.True(createCount >= 2, - $"Expected at least 2 CreateAsyncContext events (inner + marker), got {createCount}"); + // Inner cancelled task + outer marker must each see exactly one Create and one Complete on their own TaskId. + AssertExactlyOneCreateAndComplete(stream, innerCallstacks[0].TaskId, nameof(TaskAsync_TaskCancellation_Inner_Marker)); + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(TaskAsync_TaskCancellation_Marker)); } [RuntimeAsyncMethodGeneration(false)] @@ -1490,7 +1462,7 @@ public void ValueTaskAsync_MethodEventsEmitted() int resumeCount = methodEvents.Count(id => id == AsyncEventID.ResumeAsyncMethod); int completeCount = methodEvents.Count(id => id == AsyncEventID.CompleteAsyncMethod); - // Marker → Level1 → Level2 → Level3 + // Marker -> Level1 -> Level2 -> Level3 Assert.True(resumeCount >= 4, $"Expected at least 4 ResumeAsyncMethod events for ValueTask chain, got {resumeCount}"); Assert.True(completeCount >= 4, $"Expected at least 4 CompleteAsyncMethod events for ValueTask chain, got {completeCount}"); } @@ -1518,7 +1490,7 @@ public void ValueTaskAsync_CallstackDepthMatchesChainDepth() var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(ValueTaskAsync_CallstackDepthMatchesChainDepth_Marker)); Assert.NotEmpty(markerCallstacks); - // Marker → Level1 → Level2 → Level3 + // Marker -> Level1 -> Level2 -> Level3 Assert.Equal(4, markerCallstacks[0].FrameCount); } @@ -1706,7 +1678,7 @@ public async Task TaskAsync_SingleThread_ChainEventsAndCallstack() var stream = ParseAllEvents(events); - // The marker frame must appear in a Resume callstack — proves the chain was walkable. + // The marker frame must appear in a Resume callstack -- proves the chain was walkable. var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(TaskAsync_SingleThread_ChainEventsAndCallstack_Marker)); Assert.NotEmpty(markerCallstacks); 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 1df0a059246a74..54723cb2164db3 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 @@ -39,6 +39,34 @@ private static async Task RuntimeAsync_InnerYield() await Task.Yield(); } + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_DeepChain_Level3() + { + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_DeepChain_Level2() + { + await RuntimeAsync_DeepChain_Level3(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_DeepChain_Level1() + { + await RuntimeAsync_DeepChain_Level2(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_DeepChain() + { + await RuntimeAsync_DeepChain_Level1(); + } + [RuntimeAsyncMethodGeneration(true)] [MethodImpl(MethodImplOptions.NoInlining)] private static async Task RuntimeAsync_OuterCatches() @@ -646,6 +674,115 @@ public async Task RuntimeAsync_CallstackDepthMatchesChain() $"Expected callstack to contain frames [{string.Join(", ", expectedFrames)}] in order"); } + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_MethodEventCountMatchesChainDepth_Marker() + { + await RuntimeAsync_DeepChain(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_MethodEventCountMatchesChainDepth() + { + var events = await CollectEventsAsync(CallstackKeywords | MethodKeywords, RuntimeAsync_MethodEventCountMatchesChainDepth_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + // Marker -> DeepChain -> Level1 -> Level2 -> Level3 + const int ExpectedChainDepth = 5; + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_MethodEventCountMatchesChainDepth_Marker)); + Assert.NotEmpty(markerCallstacks); + + Assert.Equal(ExpectedChainDepth, markerCallstacks[0].Frames.Count); + + ulong chainTaskId = markerCallstacks[0].TaskId; + var chainEvents = stream.ForTask(chainTaskId); + + int resumeCount = chainEvents.Count(e => e.EventId == AsyncEventID.ResumeAsyncMethod); + Assert.Equal(ExpectedChainDepth, resumeCount); + + int completeCount = chainEvents.Count(e => e.EventId == AsyncEventID.CompleteAsyncMethod); + Assert.Equal(ExpectedChainDepth, completeCount); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_CallstackFramesHaveDistinctMethodIds_Marker() + { + await RuntimeAsync_DeepChain(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CallstackFramesHaveDistinctMethodIds() + { + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CallstackFramesHaveDistinctMethodIds_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CallstackFramesHaveDistinctMethodIds_Marker)); + Assert.NotEmpty(markerCallstacks); + + // Frames in the same callstack should have distinct methodIds (one per async method in the chain). + var deepest = markerCallstacks.MaxBy(cs => cs.FrameCount)!; + var methodIds = deepest.Frames.Select(f => f.MethodId).ToList(); + Assert.Equal(methodIds.Count, methodIds.Distinct().Count()); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Level3() + { + await Task.Delay(100); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Level2() + { + await RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Level3(); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Level1() + { + await RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Level2(); + await Task.Yield(); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Marker() + { + await RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Level1(); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_YieldAtEachLevel_CallstackShrinks() + { + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_YieldAtEachLevel_CallstackShrinks_Marker)); + + // After Task.Delay resumes: full chain (Level3, Level2, Level1, Marker) = 4 frames + // After Level3's yield resumes: Level3 completes, chain is (Level2, Level1, Marker) = 3 frames + // After Level2's yield resumes: Level2 completes, chain is (Level1, Marker) = 2 frames + Assert.Contains(markerCallstacks, cs => cs.FrameCount == 4); + Assert.Contains(markerCallstacks, cs => cs.FrameCount == 3); + Assert.Contains(markerCallstacks, cs => cs.FrameCount == 2); + } + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static async Task RuntimeAsync_CallstackSimulation_NormalCompletion_Marker() @@ -858,7 +995,7 @@ public void RuntimeAsync_WrapperIndexNoResetUnder32() { var events = CollectEvents(AllKeywords, () => { - // A shallow chain stays within the first 32 slots — + // A shallow chain stays within the first 32 slots -- // no reset event should be emitted. RunScenarioAndFlush(async () => { @@ -1095,7 +1232,7 @@ public async Task RuntimeAsync_NoEventsWhenDisabled() await RuntimeAsync_SingleYield(); } - // Now attach listener but don't run any RuntimeAsync work inside — + // Now attach listener but don't run any RuntimeAsync work inside -- // just call a synchronous no-op. Verify no stale events from above leak through. var events = await CollectEventsAsync(CoreKeywords, () => Task.CompletedTask); @@ -1238,7 +1375,7 @@ public async Task RuntimeAsync_CallstackNativeIPDeltaRoundtrip() var callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CallstackNativeIPDeltaRoundtrip_Marker)); Assert.NotEmpty(callstacks); - // Find callstacks with 3+ frames — enough depth for meaningful deltas. + // Find callstacks with 3+ frames -- enough depth for meaningful deltas. var deepCallstacks = callstacks.Where(cs => cs.FrameCount >= 3).ToList(); Assert.True(deepCallstacks.Count > 0, "Expected at least one callstack with 3+ frames for delta verification"); @@ -1354,7 +1491,7 @@ public void RuntimeAsync_CallstackOverflowPathProducesValidFrames() // Targeted test: run random-depth callstacks until we detect the overflow // path was exercised, then validate the affected callstack. // The overflow path fires when a large callstack doesn't fit inline in the - // remaining buffer space — the code rewinds, flushes the partial buffer, + // remaining buffer space -- the code rewinds, flushes the partial buffer, // and re-writes the callstack as the first event in a fresh buffer. // // To prove overflow occurred we check consecutive buffer pairs: @@ -1479,7 +1616,7 @@ public void RuntimeAsync_CallstackOverflowPathProducesValidFrames() } } - Assert.True(overflowDetected, "Failed to trigger callstack buffer overflow after 10 attempts — " + + Assert.True(overflowDetected, "Failed to trigger callstack buffer overflow after 10 attempts -- " + "no consecutive buffer pair found where buffer N has remaining capacity and buffer N+1 starts with a large callstack"); } @@ -1560,6 +1697,339 @@ public async Task RuntimeAsync_MetadataMatchesWrapperMethods() } + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAll_TracksAllBranches_BranchA_Marker() + { + await Task.Yield(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAll_TracksAllBranches_BranchB_Marker() + { + await Task.Yield(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAll_TracksAllBranches_BranchC_Marker() + { + await Task.Yield(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAll_TracksAllBranches_Marker() + { + await Task.WhenAll( + RuntimeAsync_WhenAll_TracksAllBranches_BranchA_Marker(), + RuntimeAsync_WhenAll_TracksAllBranches_BranchB_Marker(), + RuntimeAsync_WhenAll_TracksAllBranches_BranchC_Marker()); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_WhenAll_TracksAllBranches() + { + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_WhenAll_TracksAllBranches_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAll_TracksAllBranches_Marker)); + Assert.NotEmpty(markerCallstacks); + + // Each branch is its own async chain; its inner await of Task.Yield produces a Resume callstack containing the branch frame. + var branchACallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAll_TracksAllBranches_BranchA_Marker)); + var branchBCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAll_TracksAllBranches_BranchB_Marker)); + var branchCCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAll_TracksAllBranches_BranchC_Marker)); + Assert.NotEmpty(branchACallstacks); + Assert.NotEmpty(branchBCallstacks); + Assert.NotEmpty(branchCCallstacks); + + // Each tracked chain (3 branches + outer marker) must see exactly one Create and one Complete on its own TaskId. + AssertExactlyOneCreateAndComplete(stream, branchACallstacks[0].TaskId, nameof(RuntimeAsync_WhenAll_TracksAllBranches_BranchA_Marker)); + AssertExactlyOneCreateAndComplete(stream, branchBCallstacks[0].TaskId, nameof(RuntimeAsync_WhenAll_TracksAllBranches_BranchB_Marker)); + AssertExactlyOneCreateAndComplete(stream, branchCCallstacks[0].TaskId, nameof(RuntimeAsync_WhenAll_TracksAllBranches_BranchC_Marker)); + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(RuntimeAsync_WhenAll_TracksAllBranches_Marker)); + + // The outer marker's chain should fire Create -> Resume -> Complete in that order. + ulong markerTaskId = markerCallstacks[0].TaskId; + var markerIds = stream.ForTask(markerTaskId).Select(e => e.EventId).ToList(); + + int createIdx = markerIds.IndexOf(AsyncEventID.CreateAsyncContext); + int resumeIdx = markerIds.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create on the outer marker"); + + int completeIdx = markerIds.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume on the outer marker"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAny_TracksAllBranches_Fast_Marker() + { + await Task.Yield(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAny_TracksAllBranches_Slow1_Marker() + { + await Task.Delay(200); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAny_TracksAllBranches_Slow2_Marker() + { + await Task.Delay(300); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_WhenAny_TracksAllBranches_Marker() + { + Task fast = RuntimeAsync_WhenAny_TracksAllBranches_Fast_Marker(); + Task slow1 = RuntimeAsync_WhenAny_TracksAllBranches_Slow1_Marker(); + Task slow2 = RuntimeAsync_WhenAny_TracksAllBranches_Slow2_Marker(); + + await Task.WhenAny(fast, slow1, slow2); + await Task.WhenAll(slow1, slow2); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_WhenAny_TracksAllBranches() + { + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_WhenAny_TracksAllBranches_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Marker)); + Assert.NotEmpty(markerCallstacks); + + // All branches - including the slow ones whose completion the outer is no longer + // strictly waiting on after WhenAny returned - must produce their own Resume callstacks. + var fastCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Fast_Marker)); + var slow1Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Slow1_Marker)); + var slow2Callstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Slow2_Marker)); + Assert.NotEmpty(fastCallstacks); + Assert.NotEmpty(slow1Callstacks); + Assert.NotEmpty(slow2Callstacks); + + // Each tracked chain (3 branches + outer marker) must see exactly one Create and one Complete on its own TaskId. + AssertExactlyOneCreateAndComplete(stream, fastCallstacks[0].TaskId, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Fast_Marker)); + AssertExactlyOneCreateAndComplete(stream, slow1Callstacks[0].TaskId, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Slow1_Marker)); + AssertExactlyOneCreateAndComplete(stream, slow2Callstacks[0].TaskId, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Slow2_Marker)); + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(RuntimeAsync_WhenAny_TracksAllBranches_Marker)); + + // The outer marker should also be resumed at least once. + ulong markerTaskId = markerCallstacks[0].TaskId; + var markerIds = stream.ForTask(markerTaskId).Select(e => e.EventId).ToList(); + int resumeCountForMarker = markerIds.Count(id => id == AsyncEventID.ResumeAsyncContext); + Assert.True(resumeCountForMarker >= 1, $"Expected outer marker to be resumed at least once, got {resumeCountForMarker}"); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_ConfigureAwaitFalse_Leaf_Marker() + { + await Task.Yield(); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_ConfigureAwaitFalse_Mid_Marker() + { + await RuntimeAsync_ConfigureAwaitFalse_Leaf_Marker().ConfigureAwait(false); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_ConfigureAwaitFalse_Marker() + { + await RuntimeAsync_ConfigureAwaitFalse_Mid_Marker().ConfigureAwait(false); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_ConfigureAwaitFalse() + { + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_ConfigureAwaitFalse_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_ConfigureAwaitFalse_Marker)); + Assert.NotEmpty(markerCallstacks); + + var frameNames = markerCallstacks[0].Frames + .Select(f => GetMethodNameFromMethodId(markerCallstacks[0].CallstackType, f.MethodId)) + .Where(n => n is not null) + .ToList(); + + Assert.Contains(nameof(RuntimeAsync_ConfigureAwaitFalse_Leaf_Marker), frameNames); + Assert.Contains(nameof(RuntimeAsync_ConfigureAwaitFalse_Mid_Marker), frameNames); + Assert.Contains(nameof(RuntimeAsync_ConfigureAwaitFalse_Marker), frameNames); + + // ConfigureAwait(false) on a sequential await chain collapses Leaf -> Mid -> Marker into one + // continuation chain, so exactly one Create / one Complete is expected on the marker's TaskId. + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(RuntimeAsync_ConfigureAwaitFalse_Marker)); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_TaskCancellation_Inner_Marker(CancellationToken ct) + { + await Task.Delay(5000, ct); + } + + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_TaskCancellation_Marker() + { + using var cts = new CancellationTokenSource(); + Task inner = RuntimeAsync_TaskCancellation_Inner_Marker(cts.Token); + cts.CancelAfter(50); + try + { + await inner; + } + catch (OperationCanceledException) + { + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_TaskCancellation() + { + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_TaskCancellation_Marker); + + // DumpAllEvents(events); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_TaskCancellation_Marker)); + Assert.NotEmpty(markerCallstacks); + + var innerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_TaskCancellation_Inner_Marker)); + Assert.NotEmpty(innerCallstacks); + + // Inner cancelled task + outer marker must each see exactly one Create and one Complete on their own TaskId. + AssertExactlyOneCreateAndComplete(stream, innerCallstacks[0].TaskId, nameof(RuntimeAsync_TaskCancellation_Inner_Marker)); + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(RuntimeAsync_TaskCancellation_Marker)); + } + + private static InlinePostSynchronizationContext? s_runtimeAsyncSyncContextCtx; + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker() + { + // Install a non-default SynchronizationContext on this thread so the await captures it. + // The await's continuation will be routed via SynchronizationContextAwaitTaskContinuation, + // which the V2 runtime-async dispatch loop should honor when resuming the chain. + int callerThreadId = Environment.CurrentManagedThreadId; + SynchronizationContext? prev = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(s_runtimeAsyncSyncContextCtx); + try + { + await Task.Delay(100); + } + finally + { + if (Environment.CurrentManagedThreadId == callerThreadId) + { + SynchronizationContext.SetSynchronizationContext(prev); + } + } + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CustomSyncContext_EmitsContextEventsAndCallstack() + { + s_runtimeAsyncSyncContextCtx = new InlinePostSynchronizationContext(); + + var events = await CollectEventsAsync(CallstackKeywords, RuntimeAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker); + + // DumpAllEvents(events); + + // The custom SyncContext should have received at least one Post for the await continuation. + Assert.True(s_runtimeAsyncSyncContextCtx.PostCount > 0, + $"Expected custom SynchronizationContext to receive at least one Post, got {s_runtimeAsyncSyncContextCtx.PostCount}"); + + var stream = ParseAllEvents(events); + + // The marker frame should appear in the Resume callstack. + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker)); + Assert.NotEmpty(markerCallstacks); + + // The marker's chain should see exactly one Create and one Complete on its own TaskId. + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(RuntimeAsync_CustomSyncContext_EmitsContextEventsAndCallstack_Marker)); + + // Verify the standard Create -> Resume -> Complete sequence fired in order for our context. + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + + [RuntimeAsyncMethodGeneration(true)] + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task RuntimeAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker() + { + await Task.Delay(100); + } + + [ConditionalFact(typeof(AsyncProfilerTests), nameof(IsRuntimeAsyncSupported))] + public async Task RuntimeAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack() + { + var scheduler = new InlineRunTaskScheduler(); + + var events = await CollectEventsAsync(CallstackKeywords, async () => + { + // Start the marker on the custom scheduler so the resulting Task is queued through it. + await Task.Factory.StartNew( + () => RuntimeAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker(), + CancellationToken.None, + TaskCreationOptions.None, + scheduler).Unwrap(); + }); + + // DumpAllEvents(events); + + // The custom scheduler must have received at least one QueueTask call. + Assert.True(scheduler.QueuedCount >= 1, + $"Expected custom TaskScheduler to receive at least one QueueTask call, got {scheduler.QueuedCount}"); + + var stream = ParseAllEvents(events); + + var markerCallstacks = stream.CallstacksWithMarker(AsyncEventID.ResumeAsyncCallstack, nameof(RuntimeAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker)); + Assert.NotEmpty(markerCallstacks); + + // The marker's chain should see exactly one Create and one Complete on its own TaskId. + AssertExactlyOneCreateAndComplete(stream, markerCallstacks[0].TaskId, nameof(RuntimeAsync_CustomTaskScheduler_EmitsContextEventsAndCallstack_Marker)); + + // Verify the standard Create -> Resume -> Complete sequence fired in order for our context. + ulong taskId = markerCallstacks[0].TaskId; + var ids = stream.ForTask(taskId).Select(e => e.EventId).ToList(); + + int createIdx = ids.IndexOf(AsyncEventID.CreateAsyncContext); + int resumeIdx = ids.IndexOf(AsyncEventID.ResumeAsyncContext, createIdx + 1); + Assert.True(resumeIdx > createIdx, "Expected ResumeAsyncContext after Create"); + + int completeIdx = ids.IndexOf(AsyncEventID.CompleteAsyncContext, resumeIdx + 1); + Assert.True(completeIdx > resumeIdx, "Expected CompleteAsyncContext after Resume"); + } + [System.Runtime.CompilerServices.RuntimeAsyncMethodGeneration(true)] [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static async ValueTask RuntimeAsync_ValueTask_EventSequenceOrder_Marker() From fc0ec99bdd2a5396c13099793c97ae129676b513 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Fri, 5 Jun 2026 13:53:38 +0200 Subject: [PATCH 17/19] Adjustments. --- .../Runtime/CompilerServices/AsyncProfiler.cs | 2 +- .../CompilerServices/AsyncTaskDispatcher.cs | 37 ++++--------------- .../AsyncTaskMethodBuilderT.cs | 23 +++++------- .../ConfiguredValueTaskAwaitable.cs | 4 +- .../PoolingAsyncValueTaskMethodBuilderT.cs | 4 -- .../Runtime/CompilerServices/TaskAwaiter.cs | 22 +++++------ .../CompilerServices/ValueTaskAwaiter.cs | 4 +- .../CompilerServices/YieldAwaitable.cs | 2 +- .../src/System/Threading/Tasks/Task.cs | 3 +- .../Threading/Tasks/TaskContinuation.cs | 2 +- 10 files changed, 35 insertions(+), 68 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index dcf59dcc98fc99..0b558f55178db7 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -797,7 +797,7 @@ public static void Append(AsyncTaskDispatcher dispatcher, AsyncThreadContext con { if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords) && dispatcher.ContinuationChainChanged) { - AsyncCallstack.EmitEvent(dispatcher, context, dispatcher.LastContinuation?.DiagnosticContinuationObject, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); + AsyncCallstack.EmitEvent(dispatcher, context, dispatcher.LastContinuation?.GetContinuationForDiagnostics, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index d8e2ed67cd3498..0a8c3bf4c9e549 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -33,10 +33,13 @@ internal unsafe ref struct AsyncTaskDispatcherInfo [ThreadStatic] internal static unsafe AsyncTaskDispatcherInfo* t_current; - public static bool InstrumentCheckPoint + public static bool AsyncProfilerInstrumentCheckPoint { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => AsyncInstrumentation.IsSupported && AsyncInstrumentation.ActiveFlags != AsyncInstrumentation.Flags.Disabled; + get => + AsyncInstrumentation.IsSupported && + AsyncInstrumentation.ActiveFlags != AsyncInstrumentation.Flags.Disabled && + AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags()); } internal static unsafe AsyncTaskDispatcher? GetActiveDispatcher() @@ -146,26 +149,8 @@ internal ulong ContextId } } - internal bool ContinuationChainChanged => LastContinuation?.DiagnosticContinuationObject != null; - - /// - /// Creates a new dispatcher for the given box. If a dispatcher is already active on the - /// current thread (mid-chain yield), the new dispatcher becomes a child wrapping the box - /// and inherits the active dispatcher's contextId so subsequent events fold into the - /// same logical context. Every dispatcher (root or child) emits a CreateAsyncContext - /// event with its contextId — children share their parent's contextId, so multiple Create - /// events appear for the same logical context. Parsers can reconstruct Suspend semantics - /// via per-contextId refcounting: Complete events while refcount > 0 indicate a dispatcher - /// MoveNext ended but the chain continues; the final Complete (refcount → 0) marks the - /// logical context fully drained. Balance invariant: Create count == Complete count per - /// contextId. - /// - /// When created as a child (parent is mid-MoveNext, about to suspend), the parent - /// dispatcher emits an Append event here — this is the last synchronization point where - /// the chain state is known stable. Once we return and the new child dispatcher is - /// scheduled, other threads may race ahead and walk/mutate state before the parent's - /// Complete-time Append fires. - /// + internal bool ContinuationChainChanged => LastContinuation?.GetContinuationForDiagnostics != null; + internal static unsafe AsyncTaskDispatcher Create(IAsyncStateMachineBox box) { AsyncTaskDispatcherInfo* activeInfo = AsyncTaskDispatcherInfo.t_current; @@ -174,7 +159,7 @@ internal static unsafe AsyncTaskDispatcher Create(IAsyncStateMachineBox box) ? new AsyncTaskDispatcher(box, activeDispatcher) : new AsyncTaskDispatcher(box); - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); + AsyncInstrumentation.Flags flags = AsyncInstrumentation.ActiveFlags; if (AsyncInstrumentation.IsEnabled.CreateAsyncContext(flags) || AsyncInstrumentation.IsEnabled.ResumeAsyncContext(flags)) { if (activeDispatcher is not null) @@ -222,17 +207,11 @@ public unsafe void MoveNext() } finally { - // Always emit Complete in V1 — each dispatcher MoveNext is a discrete unit - // emitting one Resume + one Complete. The logical context (shared contextId) - // spans multiple dispatchers; Resume count == Complete count per context. if (AsyncInstrumentation.IsEnabled.CompleteAsyncContext(flags)) { AsyncProfiler.CompleteAsyncContext.Complete(this, ref dispatcherInfo.AsyncProfilerInfo); } - // Pop t_current inside finally so the TLS pointer is restored even if - // inner.MoveNext() throws. Otherwise t_current would dangle at a destroyed - // stack frame and pollute subsequent code on this thread. refCurrent = dispatcherInfo.Next; } } 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 e01e2f8cc8324b..05a20f7e81fd66 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 @@ -354,13 +354,9 @@ private void MoveNext(Thread? threadPoolThread) { Debug.Assert(!IsCompleted); - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) - { - AsyncTaskDispatcherInfo.TryFireResumeAsyncMethod(this, flags); - } + AsyncTaskDispatcherInfo.TryFireResumeAsyncMethod(this, AsyncInstrumentation.ActiveFlags); } bool loggingOn = TplEventSource.Log.IsEnabled(); @@ -438,7 +434,7 @@ bool IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, { methodId = TStateMachineDiagnosticData.MethodId; state = TStateMachineDiagnosticData.GetState(ref StateMachine); - nextContinuation = this.DiagnosticContinuationObject; + nextContinuation = this.GetContinuationForDiagnostics; return true; } else @@ -587,10 +583,9 @@ internal static void SetExistingTaskResult(Task task, TResult? result) { Debug.Assert(task != null, "Expected non-null task"); - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { - if (AsyncInstrumentation.IsEnabled.CompleteAsyncMethod(flags)) + if (AsyncInstrumentation.IsEnabled.CompleteAsyncMethod(AsyncInstrumentation.ActiveFlags)) { AsyncTaskDispatcherInfo.CompleteAsyncMethod(); } @@ -626,10 +621,12 @@ internal static void SetException(Exception exception, ref Task? taskFi // Get the task, forcing initialization if it hasn't already been initialized. Task task = (taskField ??= new Task()); - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags) && AsyncInstrumentation.IsEnabled.UnwindAsyncException(flags)) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { - AsyncTaskDispatcherInfo.UnwindAsyncFrame(); + if (AsyncInstrumentation.IsEnabled.UnwindAsyncException(AsyncInstrumentation.ActiveFlags)) + { + AsyncTaskDispatcherInfo.UnwindAsyncFrame(); + } } // If the exception represents cancellation, cancel the task. Otherwise, fault the task. diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs index 5c80c97eaa66d4..390d26bf518054 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ConfiguredValueTaskAwaitable.cs @@ -102,7 +102,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); } @@ -212,7 +212,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs index 48038326e5de70..6cc757078054d5 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/PoolingAsyncValueTaskMethodBuilderT.cs @@ -399,10 +399,6 @@ private static void ExecutionContextCallback(object? s) void IThreadPoolWorkItem.Execute() => MoveNext(); /// Calls MoveNext on - // TODO-AsyncProfiler: This MoveNext lacks profiler instrumentation (Resume/Complete events). - // The pooling builder is opt-in and uses ManualResetValueTaskSourceCore for completion instead of - // Task.TrySetResult, so SetExistingTaskResult events don't fire here either. Needs dedicated - // instrumentation similar to AsyncTaskMethodBuilder's AsyncStateMachineBox.MoveNext. public void MoveNext() { ExecutionContext? context = Context; diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs index 16d3514731ae1e..b357126c6eb80e 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs @@ -194,24 +194,20 @@ internal static void UnsafeOnCompletedInternal(Task task, IAsyncStateMachineBox { Debug.Assert(stateMachineBox != null); - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { - AsyncInstrumentation.Flags flags = AsyncInstrumentation.SyncActiveFlags(); - if (AsyncInstrumentation.IsEnabled.AsyncProfiler(flags)) + if (task is not IAsyncStateMachineBox) { - if (task is not IAsyncStateMachineBox) + stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); + } + else if (continueOnCapturedContext) + { + bool customSyncContext = SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext); + bool customTaskScheduler = TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default; + if (customSyncContext || customTaskScheduler) { stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); } - else if (continueOnCapturedContext) - { - bool customSyncContext = SynchronizationContext.Current is SynchronizationContext syncCtx && syncCtx.GetType() != typeof(SynchronizationContext); - bool customTaskScheduler = TaskScheduler.InternalCurrent is TaskScheduler scheduler && scheduler != TaskScheduler.Default; - if (customSyncContext || customTaskScheduler) - { - stateMachineBox = AsyncTaskDispatcher.Create(stateMachineBox); - } - } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs index afdc2bff86318f..9c2e73e3022b4d 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs @@ -98,7 +98,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b // allocating a full AsyncTaskDispatcher (Task-derived). The IValueTaskSource.OnCompleted API // already takes Action + state separately, so we can use a lightweight static callback // that performs PUSH/MoveNext/POP inline without the Task overhead. - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); } @@ -185,7 +185,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs index a421661b4dc759..26a5cb3db94c11 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/YieldAwaitable.cs @@ -116,7 +116,7 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b { Debug.Assert(box != null); - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs index 0601c366e3034b..a14b29440b6504 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs @@ -7224,8 +7224,7 @@ internal static Task CreateUnwrapPromise(Task outerTask, bool return new UnwrapPromise(outerTask, lookForOce); } - /// Gets the continuation object for async callstack diagnostics. - internal object? DiagnosticContinuationObject => m_continuationObject; + internal object? GetContinuationForDiagnostics => m_continuationObject; internal virtual Delegate[]? GetDelegateContinuationsForDebugger() { diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs index 0e580bac153760..0488aec05cf836 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/TaskContinuation.cs @@ -768,7 +768,7 @@ internal static void RunOrScheduleAction(IAsyncStateMachineBox box, bool allowIn // If we're not allowed to run here, schedule the action if (!allowInlining || !IsValidLocationForInlining) { - if (AsyncTaskDispatcherInfo.InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags())) + if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); } From 16fd8356dcf47cc7e1f5bbb74bd0dde7b6a8a432 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Fri, 5 Jun 2026 14:22:44 +0200 Subject: [PATCH 18/19] Move TStateMachineDiagnosticData into own file + enable V1 on Mono. --- .../System.Private.CoreLib.Shared.projitems | 9 +- .../Runtime/CompilerServices/AsyncProfiler.cs | 2 +- .../AsyncStateMachineDiagnostics.cs | 94 +++++++++++++++++++ .../CompilerServices/AsyncTaskDispatcher.cs | 2 +- .../AsyncTaskMethodBuilderT.cs | 79 +--------------- .../CompilerServices/ValueTaskAwaiter.cs | 4 - .../src/System/Threading/Tasks/Task.cs | 2 +- 7 files changed, 105 insertions(+), 87 deletions(-) create mode 100644 src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncStateMachineDiagnostics.cs diff --git a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems index ec978091d6ee1b..d52b66b657cc17 100644 --- a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems +++ b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems @@ -836,12 +836,13 @@ + - + @@ -943,9 +944,9 @@ - - - + + + diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 0b558f55178db7..3e5098f658e993 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -797,7 +797,7 @@ public static void Append(AsyncTaskDispatcher dispatcher, AsyncThreadContext con { if (IsEnabled.ResumeAsyncCallstackEvent(context.ActiveEventKeywords) && dispatcher.ContinuationChainChanged) { - AsyncCallstack.EmitEvent(dispatcher, context, dispatcher.LastContinuation?.GetContinuationForDiagnostics, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); + AsyncCallstack.EmitEvent(dispatcher, context, dispatcher.LastContinuation?.ContinuationForDiagnostics, currentTimestamp, AsyncEventID.AppendAsyncCallstack, GetId(dispatcher)); } } diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncStateMachineDiagnostics.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncStateMachineDiagnostics.cs new file mode 100644 index 00000000000000..59f5e2c481c7d0 --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncStateMachineDiagnostics.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace System.Runtime.CompilerServices +{ + internal static class AsyncStateMachineDiagnostics + where TStateMachine : IAsyncStateMachine + { +#if NATIVEAOT + // In NativeAOT we don't have reflection to resolve the method handle and state field offset. + // Due to the way the state machine is constructed, we can't get a direct pointer to its MoveNext method + // and using the interface dispatch to locate the method at slot 0 is unreliable due to Native AOT optimizations. + // The state field is also not guaranteed to be at a specific offset due to auto layout and Native AOT optimizations. + // To support this on Native AOT we would need to precompute this information in ILC and emit a + // hash table keyed by state machine MethodTable. At runtime we would still need to cache + // this data in static fields to avoid lookup cost when walking each continuation frame. + // On JIT these static fields are lazy evaluated and cached on initial access, but on Native AOT + // they will be pre-allocated, so code should be linked out when diagnostics is not supported. + // Given the added complexity on Native AOT, the fact that this is only used for diagnostics, + // and that Native AOT currently have limited asyncv1 diagnostics support in tooling, we can + // postpone the support until proven needed. + public static ulong MethodId + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetState(ref TStateMachine? _) => -1; +#else + private static readonly ulong s_methodId = ResolveMethodId(); + private static readonly int s_resolveStateFieldOffset = ResolveStateFieldOffset(); + + public static ulong MethodId + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => s_methodId; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetState(ref TStateMachine? stateMachine) + { + if (typeof(TStateMachine).IsValueType) + { + // Struct: state field is inline at offset within the struct + return Unsafe.As(ref Unsafe.AddByteOffset(ref Unsafe.As(ref stateMachine), (nint)s_resolveStateFieldOffset)); + } + else + { + // Class (debug builds): StateMachine is a reference, dereference to get object data + if (stateMachine is not null) + { + return Unsafe.As(ref Unsafe.AddByteOffset(ref RuntimeHelpers.GetRawData(stateMachine), (nint)s_resolveStateFieldOffset)); + } + } + + return -1; + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] + private static ulong ResolveMethodId() + { + MethodInfo? methodInfo = typeof(TStateMachine).GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (methodInfo is not null) + { + return (ulong)methodInfo.MethodHandle.Value; + } + + return 0; + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] + private static int ResolveStateFieldOffset() + { + FieldInfo? stateField = typeof(TStateMachine).GetField("<>1__state", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (stateField is not null) + { +#if MONO + return stateField.GetFieldOffset(); +#else + Debug.Assert(stateField is RtFieldInfo, $"Expected RtFieldInfo but got {stateField.GetType().Name}"); + return RuntimeFieldHandle.GetInstanceFieldOffset((RtFieldInfo)stateField); +#endif + } + + return 0; + } +#endif + } +} diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index 0a8c3bf4c9e549..b15603ca353ff7 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -149,7 +149,7 @@ internal ulong ContextId } } - internal bool ContinuationChainChanged => LastContinuation?.GetContinuationForDiagnostics != null; + internal bool ContinuationChainChanged => LastContinuation?.ContinuationForDiagnostics != null; internal static unsafe AsyncTaskDispatcher Create(IAsyncStateMachineBox box) { 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 05a20f7e81fd66..97eb26ca8eb38c 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 @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -432,9 +431,9 @@ bool IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, { if (AsyncInstrumentation.IsSupported) { - methodId = TStateMachineDiagnosticData.MethodId; - state = TStateMachineDiagnosticData.GetState(ref StateMachine); - nextContinuation = this.GetContinuationForDiagnostics; + methodId = AsyncStateMachineDiagnostics.MethodId; + state = AsyncStateMachineDiagnostics.GetState(ref StateMachine); + nextContinuation = this.ContinuationForDiagnostics; return true; } else @@ -446,78 +445,6 @@ bool IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, } } - private static class TStateMachineDiagnosticData - { -#if NATIVEAOT - // In NativeAOT we don't have reflection to resolve the method handle and state field offset. - // Due to the way the state machine is constructed, we can't get a direct pointer to its MoveNext method - // and using the interface dispatch to locate the method at slot 0 is unreliable due to Native AOT optimizations. - // The state field is also not guaranteed to be at a specific offset due to auto layout and Native AOT optimizations. - // To support this on Native AOT we would need to precompute this information in ILC and emit a - // hash table keyed by state machine MethodTable. At runtime we would still need to cache - // this data in static fields to avoid lookup cost when walking each continuation frame. - // On JIT these static fields are lazy evaluated and cached on initial access, but on Native AOT - // they will be pre-allocated, so code should be linked out when diagnostics is not supported. - // Given the added complexity on Native AOT, the fact that this is only used for diagnostics, - // and that Native AOT currently have limited asyncv1 diagnostics support in tooling, we can - // postpone the support until proven needed. - public static ulong MethodId => 0; - public static int GetState(ref TStateMachine? _) - { - return -1; - } -#else - private static readonly ulong s_methodId = ResolveMethodId(); - private static readonly int s_resolveStateFieldOffset = ResolveStateFieldOffset(); - - public static ulong MethodId => s_methodId; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int GetState(ref TStateMachine? stateMachine) - { - if (typeof(TStateMachine).IsValueType) - { - // Struct: state field is inline at offset within the struct - return Unsafe.As(ref Unsafe.AddByteOffset(ref Unsafe.As(ref stateMachine), (nint)s_resolveStateFieldOffset)); - } - else - { - // Class (debug builds): StateMachine is a reference, dereference to get object data - if (stateMachine != null) - { - return Unsafe.As(ref Unsafe.AddByteOffset(ref RuntimeHelpers.GetRawData(stateMachine), (nint)s_resolveStateFieldOffset)); - } - } - - return -1; - } - - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] - private static ulong ResolveMethodId() - { - MethodInfo? methodInfo = typeof(TStateMachine).GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (methodInfo != null) - { - return (ulong)methodInfo.MethodHandle.Value; - } - - return 0; - } - - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2090", Justification = "State machine types are always preserved.")] - private static int ResolveStateFieldOffset() - { - FieldInfo? stateField = typeof(TStateMachine).GetField("<>1__state", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (stateField != null) - { - Debug.Assert(stateField is RtFieldInfo, $"Expected RtFieldInfo but got {stateField.GetType().Name}"); - return RuntimeFieldHandle.GetInstanceFieldOffset((RtFieldInfo)stateField); - } - - return 0; - } -#endif - } } /// Gets the for this builder. diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs index 9c2e73e3022b4d..5c9ace7fe0d4e9 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ValueTaskAwaiter.cs @@ -94,10 +94,6 @@ void IStateMachineBoxAwareAwaiter.AwaitUnsafeOnCompleted(IAsyncStateMachineBox b } else if (obj != null) { - // TODO-AsyncProfiler: Optimize by using a static delegate + original box as state instead of - // allocating a full AsyncTaskDispatcher (Task-derived). The IValueTaskSource.OnCompleted API - // already takes Action + state separately, so we can use a lightweight static callback - // that performs PUSH/MoveNext/POP inline without the Task overhead. if (AsyncTaskDispatcherInfo.AsyncProfilerInstrumentCheckPoint) { box = AsyncTaskDispatcher.Create(box); diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs index a14b29440b6504..dd55f66a70dc7f 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs @@ -7224,7 +7224,7 @@ internal static Task CreateUnwrapPromise(Task outerTask, bool return new UnwrapPromise(outerTask, lookForOce); } - internal object? GetContinuationForDiagnostics => m_continuationObject; + internal object? ContinuationForDiagnostics => m_continuationObject; internal virtual Delegate[]? GetDelegateContinuationsForDebugger() { From 2c49a424215d9d595a1317a1ac252e69bfc5a7b4 Mon Sep 17 00:00:00 2001 From: lateralusX Date: Fri, 5 Jun 2026 14:39:11 +0200 Subject: [PATCH 19/19] Fix compile on Mono + added instrumentation checkpoint to diag data. --- .../CompilerServices/AsyncProfiler.CoreCLR.cs | 6 ------ .../Runtime/CompilerServices/AsyncProfiler.cs | 18 ++++++++++++++++-- .../CompilerServices/AsyncTaskDispatcher.cs | 11 +++++++---- .../AsyncTaskMethodBuilderT.cs | 14 ++++++-------- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs index af5e98f1106488..d176067a553366 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.CoreCLR.cs @@ -148,12 +148,6 @@ internal static partial class ContinuationWrapper /// public const string NameTemplate = "Continuation_Wrapper_{0}"; - /// - /// Number of distinct wrapper methods. The wrapper index rotates modulo this value. - /// - public const byte COUNT = 32; - public const byte COUNT_MASK = COUNT - 1; - public static void InitInfo(ref Info info) { info.ContinuationTable = ref Unsafe.As(ref s_continuationWrappers); diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs index 3e5098f658e993..6920fec6a326ff 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncProfiler.cs @@ -55,7 +55,6 @@ internal ref struct Info internal static void InitInfo(ref Info info) { info.Context = null; - info.ContinuationIndex = 0; ContinuationWrapper.InitInfo(ref info); } @@ -1008,6 +1007,20 @@ public static void EmitEvent(AsyncThreadContext context) internal static partial class ContinuationWrapper { + /// + /// Number of distinct wrapper methods. The wrapper index rotates modulo this value. + /// + public const byte COUNT = 32; + public const byte COUNT_MASK = COUNT - 1; + +#if MONO + public static void InitInfo(ref Info info) + { + info.ContinuationTable = 0; + info.ContinuationIndex = 0; + } +#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void IncrementIndex(ref Info info) { @@ -1073,8 +1086,9 @@ private static void ResetContext(AsyncThreadContext context) Config.EmitAsyncProfilerMetadataIfNeeded(context); EmitEvent(context); } - +#if !MONO ResumeAsyncCallstacks(context); +#endif } private static void EmitEvent(AsyncThreadContext context) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs index b15603ca353ff7..716b25de5c5848 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskDispatcher.cs @@ -33,13 +33,16 @@ internal unsafe ref struct AsyncTaskDispatcherInfo [ThreadStatic] internal static unsafe AsyncTaskDispatcherInfo* t_current; + public static bool InstrumentCheckPoint + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => AsyncInstrumentation.IsSupported && AsyncInstrumentation.ActiveFlags != AsyncInstrumentation.Flags.Disabled; + } + public static bool AsyncProfilerInstrumentCheckPoint { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => - AsyncInstrumentation.IsSupported && - AsyncInstrumentation.ActiveFlags != AsyncInstrumentation.Flags.Disabled && - AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags()); + get => InstrumentCheckPoint && AsyncInstrumentation.IsEnabled.AsyncProfiler(AsyncInstrumentation.SyncActiveFlags()); } internal static unsafe AsyncTaskDispatcher? GetActiveDispatcher() 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 97eb26ca8eb38c..c1ede9d94cd785 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 @@ -429,20 +429,18 @@ public void ClearStateUponCompletion() bool IAsyncStateMachineBox.GetDiagnosticData(out ulong methodId, out int state, out object? nextContinuation) { - if (AsyncInstrumentation.IsSupported) + if (AsyncTaskDispatcherInfo.InstrumentCheckPoint) { methodId = AsyncStateMachineDiagnostics.MethodId; state = AsyncStateMachineDiagnostics.GetState(ref StateMachine); nextContinuation = this.ContinuationForDiagnostics; return true; } - else - { - methodId = 0; - state = -1; - nextContinuation = null; - return false; - } + + methodId = 0; + state = -1; + nextContinuation = null; + return false; } }