Unexpected behavior with exception handling in asy

2019-07-04 06:32发布

问题:

I have stumbled upon a problem when calling a nested Async which happens to be null. An exception is raised but it can't be catched with any of the normal exception handling methods Async workflows provide.

The following is a simple test which reproduces the problem:

[<Test>]
let ``Nested async is null with try-with``() = 

    let g(): Async<unit> = Unchecked.defaultof<Async<unit>>

    let f = async {
            try
                do! g()
            with e ->  
                printf "%A" e
    }

    f |> Async.RunSynchronously |> ignore

which results in the follwing exception:

System.NullReferenceException : Object reference not set to an instance of an object.
at Microsoft.FSharp.Control.AsyncBuilderImpl.bindA@714.Invoke(AsyncParams`1 args)
at <StartupCode$FSharp-Core>.$Control.loop@413-40(Trampoline this, FSharpFunc`2 action)
at Microsoft.FSharp.Control.Trampoline.ExecuteAction(FSharpFunc`2 firstAction)
at Microsoft.FSharp.Control.TrampolineHolder.Protect(FSharpFunc`2 firstAction)
at Microsoft.FSharp.Control.AsyncBuilderImpl.startAsync(CancellationToken cancellationToken,     FSharpFunc`2 cont, FSharpFunc`2 econt, FSharpFunc`2 ccont, FSharpAsync`1 p)
at Microsoft.FSharp.Control.CancellationTokenOps.starter@1121-1.Invoke(CancellationToken     cancellationToken, FSharpFunc`2 cont, FSharpFunc`2 econt, FSharpFunc`2 ccont, FSharpAsync`1 p)
at Microsoft.FSharp.Control.CancellationTokenOps.RunSynchronously(CancellationToken token, FSharpAsync`1 computation, FSharpOption`1 timeout)
at Microsoft.FSharp.Control.FSharpAsync.RunSynchronously(FSharpAsync`1 computation, FSharpOption`1 timeout, FSharpOption`1 cancellationToken)
at Prioinfo.Urkund.DocCheck3.Core2.Tests.AsyncTests.Nested async is null with try-with() in SystemTests.fs: line 345 

I really think the exception should be caught in this case, or is this really the expected behavior? (I'm using Visual Studio 2010 Sp1 for the record)

Also, Async.Catch and Async.StartWithContinuations exhibits the same problem as demonstrated by these test cases:

[<Test>]
let ``Nested async is null with Async.Catch``() = 

    let g(): Async<unit> = Unchecked.defaultof<Async<unit>>

    let f = async {
                do! g()
            }

    f |> Async.Catch |> Async.RunSynchronously |> ignore


[<Test>]
let ``Nested async is null with StartWithContinuations``() = 

    let g(): Async<unit> = Unchecked.defaultof<Async<unit>>

    let f = async {
                do! g()
            }

    Async.StartWithContinuations(f
                                , fun _ -> ()
                                , fun e -> printfn "%A" e
                                , fun _ -> ())

It seems the exception is raised within the bind-method in the workflow builder and my guess is that as a result the normal error handling code is bypassed. It looks like a bug in the implementation of async workflows to me since I haven't found anything in the documentation or elsewhere which suggest that this is the intended behavior.

It is pretty easy to work around in most cases I think so it's not a huge problem for me at least but it is a bit unsettling since it means that you can't completely trust the async exception handling mechanism to be able to capture all exceptions.

Edit:

After giving it some thought I agree with kvb. Null asyncs should not really exist in normal code and could really only be produced if you do something you probably shouldn't (such as using Unchecked.defaultOf) or use reflection to produce the values (in my case it was a mocking framework involved). Thus it's not really a bug but more of an edge case.

回答1:

I don't think it's a bug. As the name indicates Unchecked.defaultof<_> does not check that the values it produces are valid, and Async<unit> does not support null as a proper value (e.g. see the message if you try to use let x : Async<unit> = null). Async.Catch and the like are intended to catch exceptions thrown within asynchronous computations, not exceptions caused by sneaking behind the compiler's back and creating invalid asynchronous computations.



回答2:

I fully agree with kvb - when you initialize a value using Unchecked.defaultOf, it means that the behaviour of using the value may be undefined, so this cannot be treated as bug. In practice, you don't have to worry about it, because you should never get null values of Async<'T> type.

To add some more details, the exception cannot be handled, because the translation looks as follows:

async.TryWith
  ( async.Bind ( Unchecked.defaultof<_>, 
                 fun v -> async { printfn "continued" } ), 
    fun e -> printfn "%A" e)

The exception is thrown from the Bind method before the workflow returned by Bind is started (it happens after you call RunSynchronously, because the workflow is wrapped using Delay, but it happens outside of the workflow execution). If you want to handle this kinds of exceptions (arising from incorrectly constructed workflows), you can write a version of TryWith that runs the workflow and handles exceptions thrown outside of the execution:

let TryWith(work, handler) = 
  Async.FromContinuations(fun (cont, econt, ccont) ->
    try
      async { let! res = work in cont res }
      |> Async.StartImmediate
    with e -> 
      async { let! res = handler e in cont res } 
      |> Async.StartImmediate )   

Then you can handle exceptions like this:

let g(): Async<unit> = Unchecked.defaultof<Async<unit>> 
let f = 
    TryWith
      ( (async { do! g() }),
        (fun e -> async { printfn "error %A" e }))
f |> Async.RunSynchronously