Skip to content

Commit 93badc9

Browse files
committed
Add stacktrace validation
1 parent c09cd0e commit 93badc9

1 file changed

Lines changed: 83 additions & 1 deletion

File tree

  • tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control

tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncType.fs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ open FSharp.Core.UnitTests.LibraryTestFx
1010
open Xunit
1111
open System.Threading
1212
open System.Threading.Tasks
13+
open Xunit.Internal
1314

1415
// Cancels default token.
1516
[<Collection(nameof FSharp.Test.NotThreadSafeResourceCollection)>]
@@ -754,4 +755,85 @@ module AsyncTaskLikeAwaitTests =
754755
}
755756
tcs.SetException(InvalidOperationException "boom")
756757
let ok = Async.RunSynchronously a
757-
Assert.True ok
758+
Assert.True ok
759+
760+
[<Collection(nameof FSharp.Test.NotThreadSafeResourceCollection)>]
761+
module AsyncAwaitStackTraceTests =
762+
763+
open System.Runtime.CompilerServices
764+
765+
// Minimal wrapper to route through the SRTP overload instead of the specific Task<'T> overload.
766+
// Task<'T>, Task, ValueTask<'T>, and ValueTask all have higher-priority intrinsic overloads.
767+
type TaskWrapper<'T>(inner: Task<'T>) =
768+
member _.GetAwaiter() = inner.GetAwaiter()
769+
770+
// Plain function — provides a stable named frame at the outermost throw site.
771+
[<MethodImpl(MethodImplOptions.NoInlining)>]
772+
let throwAtLevel1 () : unit = invalidOp "boom"
773+
774+
// Level-1 task: thin wrapper around the direct throw.
775+
[<MethodImpl(MethodImplOptions.NoInlining)>]
776+
let level1Task () : Task<unit> = task { throwAtLevel1 () }
777+
778+
// Level-2 task: introduces a real async await boundary between levels 1 and 2.
779+
[<MethodImpl(MethodImplOptions.NoInlining)>]
780+
let level2Task () : Task<unit> = task { do! level1Task () }
781+
782+
// Run via StartImmediateAsTask + .Wait() and return the inner exception.
783+
// Using StartImmediateAsTask (not RunSynchronously) ensures that the async-layer
784+
// exception machinery goes through TaskCompletionSource.SetException, which preserves
785+
// the stack trace rather than rethrowing synchronously and potentially truncating it.
786+
let runAndCaptureException (computation: Async<unit>) : exn =
787+
// TODO swap in usage of Async.RunSynchronouslyImmediate
788+
let t = Async.StartImmediateAsTask computation
789+
let ae = Assert.Throws<AggregateException>(fun () -> t.Wait())
790+
ae.InnerException
791+
792+
// Template assertion: levels 1 and 2 must be traceable in the stack trace
793+
// regardless of which Async.Await overload is used.
794+
let checkTrace totalCount (e: exn) =
795+
let trace = e.StackTrace
796+
Assert.NotNull(trace)
797+
Assert.Contains("throwAtLevel1", trace)
798+
Assert.Contains("level1Task", trace)
799+
Assert.Contains("level2Task", trace)
800+
Assert.True((totalCount = trace.Split('\n').Length), trace)
801+
802+
// --- Tests per overload ---
803+
// The common skeleton is: build a 3-level chain (throwAtLevel1 → level1Task → level2Task),
804+
// wrap the outermost level in an async block using Async.Await, run via
805+
// StartImmediateAsTask + .Wait(), and assert on the resulting exception's stack trace.
806+
807+
[<Fact>]
808+
let ``Await Task-of-T: all three levels visible in stack trace`` () =
809+
let e = runAndCaptureException (async { do! Async.Await(level2Task()) })
810+
checkTrace 3 e
811+
812+
[<Fact>]
813+
let ``Await Task (non-generic): all three levels visible in stack trace`` () =
814+
let e = runAndCaptureException (async { do! Async.Await(level2Task() :> Task) })
815+
checkTrace 3 e
816+
// Same behaviour as the Task<'T> overload — see comment there.
817+
818+
#if NETSTANDARD2_1
819+
[<Fact>]
820+
let ``Await ValueTask-of-T: all three levels visible in stack trace`` () =
821+
// For a faulted ValueTask<unit>, IsCompletedSuccessfully is false; the overload falls
822+
// through to AwaitTask, which takes the same path as the specific Task<'T> overload.
823+
let e = runAndCaptureException (async { do! Async.Await(ValueTask<unit>(level2Task())) })
824+
checkTrace 3 e
825+
826+
[<Fact>]
827+
let ``Await ValueTask (non-generic): all three levels visible in stack trace`` () =
828+
// Same as ValueTask<'T>: falls through to AwaitUnitTask for the non-successfully-completed case.
829+
let e = runAndCaptureException (async { do! Async.Await(ValueTask(level2Task() :> Task)) })
830+
831+
checkTrace 3 e
832+
#endif
833+
834+
[<Fact>]
835+
let ``Await task-like via SRTP overload: all three levels visible in stack trace`` () =
836+
let e = runAndCaptureException (async { do! Async.Await(TaskWrapper(level2Task())) })
837+
838+
// 4 instead of 3 as current impl has an outer "at FSharp.Core.UnitTests.Control.AsyncAwaitStackTraceTests.e@836-9.Invoke(Tuple`3 tupledArg)
839+
checkTrace 4 e

0 commit comments

Comments
 (0)