4

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.

Samuel Otter
  • 608
  • 3
  • 7
  • I can reproduce it in FSI and in a console application, both when debugging in VS and when launched from explorer. So it seems to be consistent – Samuel Otter Jan 19 '12 at 10:13
  • Option.None is basically null. So it seems like Async<'a option> can have this issue. – Hans Jun 10 '13 at 16:36

2 Answers2

5

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.

kvb
  • 54,864
  • 2
  • 91
  • 133
  • I can see your point, but on the other hand the syntax kind of makes you expect that the exception should be catched. If you call a null delegate in a normal try-catch block the exception would be catched. – Samuel Otter Jan 19 '12 at 11:02
  • @SamuelOtter - in general, using `Unchecked.defaultof<_>` will frequently cause problems when you use it to create null values for types which don't have null as a valid representation. Note that for delegates, null is a perfectly acceptable value (e.g. the compiler has no problem with `let x : System.Action = null`). – kvb Jan 19 '12 at 11:28
  • I know that, Unchecked.defaultOf is only used in the test in order to provoke a null value for the test case. I agree that null asyncs should be rare in practice, but it can happen after all. I originally found this because I had a mocked object with an async method but had no expectations and the default behavior of the mocking framework was to simply return null for all methods. But I agree that it might be a bit strong to call it a bug, it is more of an undocumented edge case. – Samuel Otter Jan 19 '12 at 11:44
4

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
ildjarn
  • 62,044
  • 9
  • 127
  • 211
Tomas Petricek
  • 240,744
  • 19
  • 378
  • 553
  • While I agree that it is not really a bug, I have to disagree with that it could never happen in practice because it did happen to me. In my case it was in a unit test and caused by a mocking framework so it didn't matter that much (even though I did spend a day believing my code was broken before realizing what the problem was). In idiomatic F# code this should not happen, but I could imagine someone getting bitten by this by for example using a DI container to create or inject Async-values or some thing like that. – Samuel Otter Jan 19 '12 at 15:32