可取消的 F# async main 传播异常

cancellable F# async main with exceptions propagated

我正在寻找像 F# Async.Start 这样的解决方案,但没有让它吞下异常。我希望控制台应用程序因未处理的异常而正常终止。这是一个 .NET Core 2.1 应用程序,我需要通过响应 Linux SIGTERM 和 SIGINT 信号来处理资源。我如何修改此代码以使其传播 kaboom! 异常?

let mainAsync() =
    async {
        // blow up on purpose after a number of seconds
        let seconds = 10
        printfn "%d seconds and counting" seconds
        do! (System.TimeSpan.FromSeconds(float seconds)).TotalMilliseconds |> int |> Async.Sleep

        //failwithf "kaboom!"
        // Update: failwithf does reproduce the problem
        // My real app is making WCF calls that are Task based and awaiting on them
        // I don't know how to make a small test case for this
        // The client is throwing a TimeoutException if unable to connect

        wcfClient.RetrieveServiceContentAsync someValue |> Async.AwaitTask
    }

[<EntryPoint>]
let main argv =
    use cancelMainAsync = new System.Threading.CancellationTokenSource()
    use cancelMain = new System.Threading.AutoResetEvent(false)
    let cancel() =
        if not cancelMainAsync.IsCancellationRequested then
            cancelMainAsync.Cancel()
            cancelMain.Set() |> ignore
    System.Runtime.Loader.AssemblyLoadContext.Default.add_Unloading(fun _ -> cancel())
    System.Console.CancelKeyPress.Add (fun keyPress -> keyPress.Cancel <- true; cancel())
    Async.Start(mainAsync(), cancelMainAsync.Token)
    cancelMain.WaitOne() |> ignore
    0

正如 Fyodor 在评论中提到的,我认为只使用 Async.RunSynchronously 就可以了。当我按 CTRL+C 时,我放在一起的这个小版本的代码似乎打印 "Cancelled",而当我不按时它会抛出 "Kaboom!" 异常:

open System
open System.Threading

let f () = 
    async {
        printfn "Running..."
        do! Async.Sleep 10000
        failwith "Kaboom!"
    }

[<EntryPoint>]
let main argv =
    use cancellation = new CancellationTokenSource()
    Console.CancelKeyPress |> Event.add (fun _ -> cancellation.Cancel(); printfn "Cancelled")
    Async.RunSynchronously(f(), cancellationToken = cancellation.Token)
    0

编辑

演示按 CTRL+C 终止的屏幕截图。请注意此版本中 Async.RunSynchronously 之后的额外 printfn,以及它如何不执行。

我需要赶到明天,所以这是我想出的解决方案。 F# Async 与 C# Async 的互操作有时会非常令人沮丧。请查阅。如果有更好的解决方案,我会很高兴。

我最终将我自己的异常处理程序传递给 Async.StartWithContinuations,但由于它在同一个线程上启动,我将 do! Async.SwitchToThreadPool() 添加到 mainAsync。这允许 CancelKeyPress 工作。如果不需要CancelKeyPress,就不用另开帖了。

主要目标是确保正确处理一些宝贵的资源。 "safe haven" 将在发生异常时打印,控制台应用程序被 ctrl+c 取消或被杀死。从 Visual Studio 开始,您可以通过点击 运行 时弹出的控制台 window 上的关闭按钮来终止应用程序。异常和被取消的退出代码设置不同。

let mainAsync() =
    async {
        do! Async.SwitchToThreadPool()

        use precious = { new System.IDisposable with override this.Dispose() = printfn "safe haven" }

        // blow up on purpose after a number of seconds
        let seconds = 10
        printfn "%d seconds and counting" seconds
        do! (System.TimeSpan.FromSeconds(float seconds)).TotalMilliseconds |> int |> Async.Sleep
        failwithf "kaboom!"

        // the real app is has a System.TimeoutException being thrown from a C# Task
        //wcfClient.RetrieveServiceContentAsync someValue |> Async.AwaitTask
    }

[<EntryPoint>]
let main argv =
    let mutable exitCode = 0
    use cancelMainAsync = new System.Threading.CancellationTokenSource()
    use cancelMain = new System.Threading.ManualResetEventSlim()

    let cancel() =
        if not cancelMainAsync.IsCancellationRequested then
            cancelMainAsync.Cancel()
            cancelMain.Set()

    let exceptionHandler (ex: System.Exception) =
        let ex =
            match ex with
            | :? System.AggregateException as ae ->
                if ae.InnerExceptions.Count = 1 then ae.InnerException else ex
            | _ -> ex
        printfn "%A" ex
        exitCode <- 1
        cancel()

    System.Runtime.Loader.AssemblyLoadContext.Default.add_Unloading(fun _ -> cancel())
    System.Console.CancelKeyPress.Add (fun args -> args.Cancel <- true; exitCode <- 2; cancel())
    Async.StartWithContinuations(mainAsync(), (fun _ -> ()), exceptionHandler, (fun _ -> ()), cancelMainAsync.Token)
    cancelMain.Wait()
    exitCode

更新解决方案

Aaron 坚持认为 Async.RunSchronously 可以与 CancellationToken 一起使用,而且确实如此。我将他的回答标记为解决方案。这会像我想要的那样爆炸,除了 OperationCancelException.

open System

let mainAsync() =
    async {
        use precious = { new System.IDisposable with override this.Dispose() = printfn "safe haven" }
        // blow up on purpose after a number of seconds
        let seconds = 10
        printfn "%d seconds and counting" seconds
        do! (System.TimeSpan.FromSeconds(float seconds)).TotalMilliseconds |> int |> Async.Sleep
        failwithf "kaboom!"
        // the real app is has a System.TimeoutException being thrown from a C# Task
        //wcfClient.RetrieveServiceContentAsync someValue |> Async.AwaitTask
        return 0
    }

[<EntryPoint>]
let main argv =
    let cancellation = new Threading.CancellationTokenSource()
    let cancel() =
        if not cancellation.IsCancellationRequested then
            cancellation.Cancel()

    Runtime.Loader.AssemblyLoadContext.Default.add_Unloading(fun _ -> cancel())
    Console.CancelKeyPress.Add (fun event -> event.Cancel <- true; cancel())
    Async.RunSynchronously(mainAsync(), cancellationToken = cancellation.Token)

使用更多异常处理更新解决方案

为了完整起见,这里添加了额外的异常处理。

open System

let mainAsync() =
    async {
        use precious = { new System.IDisposable with override this.Dispose() = printfn "safe haven" }
        // blow up on purpose after a number of seconds
        let seconds = 10
        printfn "%d seconds and counting" seconds
        do! (System.TimeSpan.FromSeconds(float seconds)).TotalMilliseconds |> int |> Async.Sleep
        failwithf "kaboom!"
        // the real app is has a System.TimeoutException being thrown from a C# Task
        //wcfClient.RetrieveServiceContentAsync someValue |> Async.AwaitTask
        return 0
    }

[<EntryPoint>]
let main argv =
    let cancellation = new Threading.CancellationTokenSource()
    let cancel() =
        if not cancellation.IsCancellationRequested then
            cancellation.Cancel()

    Runtime.Loader.AssemblyLoadContext.Default.add_Unloading(fun _ -> cancel())
    Console.CancelKeyPress.Add (fun event -> event.Cancel <- true; cancel())

    try
        Async.RunSynchronously(mainAsync(), cancellationToken = cancellation.Token)
    with
    | :? OperationCanceledException -> 2
    | ex ->
        let ex =
            match ex with
            | :? AggregateException as ae ->
                if ae.InnerExceptions.Count = 1 then ae.InnerException else ex
            | _ -> ex
        printfn "%A" ex
        1