Search code examples
f#mailboxprocessor

Unifying Task<T> and F# MailboxProcessor exception handling


When using Task<T>, an exception during the execution of the task is thrown during Task.Wait(); when using F#'s MailBoxProcessor, the exception gets swallowed and needs to be explicitly dealt with as per this question.

This difference makes it difficult to expose F# agents to C# code via a Task. For example, this agent:

type internal IncrementMessage = 
    Increment of int * AsyncReplyChannel<int>

type IncrementAgent() =
    let counter = Agent.Start(fun agent -> 
        let rec loop() = async { let! Increment(msg, replyChannel) = agent.Receive()
                                match msg with 
                                | int.MaxValue -> return! failwith "Boom!"
                                | _ as i -> replyChannel.Reply (i + 1)
                                            return! loop() }

        loop())

    member x.PostAndAsyncReply i =
        Async.StartAsTask (counter.PostAndAsyncReply (fun channel -> Increment(i, channel)))

can be called from C#, but the exception is not returned to C#:

[Test]
public void ExceptionHandling()
{
    //
    // TPL exception behaviour
    //
    var task = Task.Factory.StartNew<int>(() => { throw new Exception("Boom!"); });

    try
    {
        task.Wait();
    }
    catch(AggregateException e)
    {
        // Exception available here
        Console.WriteLine("Task failed with {0}", e.InnerException.Message);
    }

    //
    // F# MailboxProcessor exception behaviour
    //
    var incAgent = new IncrementAgent();
    task = incAgent.PostAndAsyncReply(int.MaxValue);

    try
    {
        task.Wait(); // deadlock here
    }
    catch (AggregateException e)
    {
        Console.WriteLine("Agent failed with {0}", e.InnerException.Message);
    }
}

Instead of getting the exception, the C# code just hangs at task.Wait(). Is there any way to get the F# agent to behave like a Task? If not, it seems like there is limited use in exposing F# agents to other .NET code.


Solution

  • One way to handle it is have the agent return a DU with an error case. You could then raise the exception from outside the agent.

    type internal IncrementResponse =
        | Response of int
        | Error of exn
    
    type internal IncrementMessage = 
        | Increment of int * AsyncReplyChannel<IncrementResponse>
    
    type IncrementAgent() =
        let counter = Agent.Start(fun agent -> 
            let rec loop() = 
              async { 
                let! Increment(msg, replyChannel) = agent.Receive()
                match msg with 
                | int.MaxValue -> replyChannel.Reply (Error (Failure "Boom!"))
                | _ as i -> replyChannel.Reply (Response(i + 1))
                return! loop() 
              }
            loop())
    
        member x.PostAndAsyncReply i =
            Async.StartAsTask (
              async {
                let! res = counter.PostAndAsyncReply (fun channel -> Increment(i, channel))
                match res with
                | Response i -> return i
                | Error e -> return (raise e)
              }
            )