7

I tried to reduce this to the smallest possible repro, but it's still a bit long-ish, my apologies.

I have an F# project that references a C# project with code like the following.

public static class CSharpClass {
    public static async Task AsyncMethod(CancellationToken cancellationToken) {
        await Task.Delay(3000);
        cancellationToken.ThrowIfCancellationRequested();
    }
}

Here's the F# code.

type Message = 
    | Work of CancellationToken
    | Quit of AsyncReplyChannel<unit>

let mkAgent() = MailboxProcessor.Start <| fun inbox -> 
    let rec loop() = async {
        let! msg = inbox.TryReceive(250)
        match msg with
        | Some (Work cancellationToken) ->
            let! result = 
                CSharpClass.AsyncMethod(cancellationToken)
                |> Async.AwaitTask
                |> Async.Catch
            // THIS POINT IS NEVER REACHED AFTER CANCELLATION
            match result with
            | Choice1Of2 _ -> printfn "Success"
            | Choice2Of2 exn -> printfn "Error: %A" exn    
            return! loop()
        | Some (Quit replyChannel) -> replyChannel.Reply()
        | None -> return! loop()
    }
    loop()

[<EntryPoint>]
let main argv = 
    let agent = mkAgent()
    use cts = new CancellationTokenSource()
    agent.Post(Work cts.Token)
    printfn "Press any to cancel."
    System.Console.Read() |> ignore
    cts.Cancel()
    printfn "Cancelled."
    agent.PostAndReply Quit
    printfn "Done."
    System.Console.Read()

The issue is that, upon cancellation, control never returns to the async block. I'm not sure if it's hanging in AwaitTask or Catch. Intuition tells me it's blocking when trying to return to the previous sync context, but I'm not sure how to confirm this. I'm looking for ideas on how to troubleshoot this, or perhaps someone with a deeper understanding here can spot the issue.

POSSIBLE SOLUTION

let! result = 
    Async.FromContinuations(fun (cont, econt, _) ->
        let ccont e = econt e
        let work = CSharpClass.AsyncMethod(cancellationToken) |> Async.AwaitTask
        Async.StartWithContinuations(work, cont, econt, ccont))
    |> Async.Catch
Daniel
  • 47,404
  • 11
  • 101
  • 179
  • 1
    `OperationCanceledException` has special handling in F#, always stops the whole async process, all the way up to the turtles. See this answer: http://stackoverflow.com/a/27022315/180286 – Fyodor Soikin Feb 08 '17 at 17:35
  • This is true even though a unique cancellation token is passed to the C# method, and not shared by any of the F# async code? – Daniel Feb 08 '17 at 17:55
  • Yes. It's not a matter of token, it's just how `OperationCanceledException` is handled. You can even throw it yourself, without involving a token, and it will still stop the whole async. – Fyodor Soikin Feb 08 '17 at 17:59
  • I updated my question with a solution gleaned from the answer you linked to, which seems to work. Is this the ideal means of catching cancellation exceptions? – Daniel Feb 08 '17 at 18:03
  • D'know, depends on your definition of "ideal". I really can't tell you any more than is described in those answers. – Fyodor Soikin Feb 08 '17 at 18:15
  • @Daniel Did the alternate solution work out for you? – Asti Feb 08 '17 at 19:23
  • Yes, it seems to work fine. The only reason I went with this instead of your solution is because sometimes the task-returning method returns null. – Daniel Feb 08 '17 at 19:37
  • The example uses a `TaskCompletionSource`, which essentially returns a null result – Asti Feb 08 '17 at 20:16
  • I'm not seeing any other cases where it returns null. If you could clarify, I'll edit the answer for future reference. – Asti Feb 08 '17 at 20:18
  • If I wasn't clear, it's the C# method in my example that can sometimes return null, so calling `ContinueWith` would throw a NRE. `Async.AwaitTask >> Async.Catch` handles that case. – Daniel Feb 08 '17 at 20:46

1 Answers1

3

What ultimately causes this behavior is that cancellations are special in F# Async. Cancellations effectively translate to a stop and teardown. As you can see in the source, cancellation in the Task makes it all the way out of the computation.

If you want the good old OperationCanceledException which you can handle as part of your computation, we can just make our own.

type Async =
    static member AwaitTaskWithCancellations (task: Task<_>) =
        Async.FromContinuations(fun (setResult, setException, setCancelation) ->
            task.ContinueWith(fun (t:Task<_>) -> 
                match t.Status with 
                | TaskStatus.RanToCompletion -> setResult t.Result
                | TaskStatus.Faulted -> setException t.Exception
                | TaskStatus.Canceled -> setException <| OperationCanceledException()
                | _ -> ()
            ) |> ignore
        )

Cancellation is now just another exception - and exceptions, we can handle. Here's the repro:

let tcs = TaskCompletionSource<unit>()
tcs.SetCanceled()

async { 
    try        
        let! result = tcs.Task |> Async.AwaitTaskWithCancellations
        return result
    with
         | :? OperationCanceledException -> 
           printfn "cancelled"      
         | ex -> printfn "faulted %A" ex

    ()
} |> Async.RunSynchronously
Asti
  • 12,447
  • 29
  • 38