Skip to content

Runtime async: rethrow after a suspending handler or finally loses the stack trace #20869

Description

@majocha

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:

  1. 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, including use of an IAsyncDisposable in 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: LowerAsyncSeq already rethrows with ExceptionDispatchInfo.Capture(exn).Throw().

  2. 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)
    0

Expected 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: rewriteCapturedException builds the Choice, and RuntimeAsyncReraise and RewriteRuntimeAsyncReraise emit mkThrow.
  • 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

No one assigned

    Type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions