Repository navigation
Runtime async: rethrow after a suspending handler or finally loses the stack trace #20869
Description
A try/with handler or try/finally compensation that suspends (calls AsyncHelpers.Await) is moved out of its exception-handling region by RewriteRuntimeAsyncExceptionHandlers. The rewrite has two problems:
-
Stack trace lost. A pending exception is rethrown with a plain IL
throw, which resets its stack trace to the rethrow site. This affects:- a suspending
finally, includinguseof anIAsyncDisposablein a runtime-async builder, reraise ()after a suspension in a handler,- a handler whose pattern doesn't match, which falls through to
reraise ().
Runtime-async sequences don't have this problem:
LowerAsyncSeqalready rethrows withExceptionDispatchInfo.Capture(exn).Throw(). - a suspending
-
Allocation on the success path. The body's outcome is stored in a
Choice<'T, exn>, so every execution allocates (24 B on x64), even when nothing is thrown.
Repro steps
Compile as an exe using the compiler and matching net10.0 FSharp.Core built from main fe180deb551cc47a57de788c936e2fd4d778a909.
module Repro
open System
open System.Threading.Tasks
open System.Runtime.CompilerServices
open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers
[<MethodImpl(MethodImplOptions.NoInlining)>]
let throwingFrame () : int = raise (InvalidOperationException "boom")
let suspendingFinally () : Task<int> =
__runtimeAsyncReturn (
try
throwingFrame ()
finally
AsyncHelpers.Await(Task.Delay 1))
let reraiseAfterSuspension () : Task<int> =
__runtimeAsyncReturn (
try
throwingFrame ()
with _ ->
AsyncHelpers.Await(Task.Delay 1)
reraise ())
[<MethodImpl(MethodImplOptions.NoInlining)>]
let compute (value: int) = value + 1000
let plainHandler (value: int) : Task<int> =
__runtimeAsyncReturn (try compute value with _ -> 0)
let suspendingHandler (value: int) : Task<int> =
__runtimeAsyncReturn (
try
compute value
with _ ->
AsyncHelpers.Await Task.CompletedTask
0)
let bytesPerCall (start: int -> Task<int>) =
for i in 1..10_000 do start(i).Result |> ignore
let before = GC.GetAllocatedBytesForCurrentThread()
for i in 1..10_000 do start(i).Result |> ignore
float (GC.GetAllocatedBytesForCurrentThread() - before) / 10_000.0
[<EntryPoint>]
let main _ =
for name, start in [ "suspending finally", suspendingFinally; "reraise after suspension", reraiseAfterSuspension ] do
try start().GetAwaiter().GetResult() |> ignore
with error -> printfn "%s keeps throw site: %b" name ((string error.StackTrace).Contains "throwingFrame")
printfn "extra bytes per successful call: %.1f" (bytesPerCall suspendingHandler - bytesPerCall plainHandler)
0Expected behavior
suspending finally keeps throw site: true
reraise after suspension keeps throw site: true
extra bytes per successful call: 0.0
Actual behavior
suspending finally keeps throw site: false
reraise after suspension keeps throw site: false
extra bytes per successful call: 24.0
Known workarounds
None in user code. The rewrite is applied automatically whenever a handler or compensation awaits.
Related information
- The cause is in
src/Compiler/Optimize/RuntimeAsyncExceptionRewrite.fs:rewriteCapturedExceptionbuilds theChoice, andRuntimeAsyncReraiseandRewriteRuntimeAsyncReraiseemitmkThrow. - Roslyn lowers awaits in catch/finally with a pending-exception local and rethrows it with
ExceptionDispatchInfo. - The existing tests (
RuntimeAsyncReraiseOwnership.fs) check that the exception object is the same one, but not its stack trace.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNew
Activity