8

I have been attempting to have a re-usable modal progress window (I.e. progressForm.ShowDialog()) to show progress from a running async task, including enabling cancellation.
I have seen some implementations that launch start the async task by hooking the Activated event handler on the form, but I need to start the task first, then show the modal dialog that will show it's progress, and then have the modal dialog close when completed or cancellation is completed (note - I want the form closed when cancellation is completed - signalled to close from the task continuation).

I currently have the following - and although this working - are there issues with this - or could this be done in a better way?

I did read that I need to run this CTRL-F5, without debugging (to avoid the AggregateException stopping the debugger in the continuation - and let it be caught in the try catch as in production code)

ProgressForm.cs - Form with ProgressBar (progressBar1) and Button (btnCancel)

public partial class ProgressForm : Form
{
    public ProgressForm()
    {
        InitializeComponent();
    }

    public event Action Cancelled;
    private void btnCancel_Click(object sender, EventArgs e)
    {
        if (Cancelled != null) Cancelled();
    }

    public void UpdateProgress(int progressInfo)
    {
        this.progressBar1.Value = progressInfo;
    }
}

Services.cs - Class file containing logic consumed by WinForms app (as well as console app)

public class MyService
{
    public async Task<bool> DoSomethingWithResult(
        int arg, CancellationToken token, IProgress<int> progress)
    {
        // Note: arg value would normally be an 
        //  object with meaningful input args (Request)

        // un-quote this to test exception occuring.
        //throw new Exception("Something bad happened.");

        // Procressing would normally be several Async calls, such as ...
        //  reading a file (e.g. await ReadAsync)
        //  Then processing it (CPU instensive, await Task.Run), 
        //  and then updating a database (await UpdateAsync)
        //  Just using Delay here to provide sample, 
        //   using arg as delay, doing that 100 times.

        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();
            await Task.Delay(arg);
            progress.Report(i + 1);
        }

        // return value would be an object with meaningful results (Response)
        return true;
    }
}

MainForm.cs - Form with Button (btnDo).

public partial class MainForm : Form
{
    public MainForm()
    {
        InitializeComponent();
    }

    private async void btnDo_Click(object sender, EventArgs e)
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        CancellationToken token = cts.Token;

        // Create the ProgressForm, and hook up the cancellation to it.
        ProgressForm progressForm = new ProgressForm();
        progressForm.Cancelled += () => cts.Cancel();

        // Create the progress reporter - and have it update 
        //  the form directly (if form is valid (not disposed))
        Action<int> progressHandlerAction = (progressInfo) =>
        {
            if (!progressForm.IsDisposed) // don't attempt to use disposed form
                progressForm.UpdateProgress(progressInfo);
        };
        Progress<int> progress = new Progress<int>(progressHandlerAction);

        // start the task, and continue back on UI thread to close ProgressForm
        Task<bool> responseTask
            = MyService.DoSomethingWithResultAsync(100, token, progress)
            .ContinueWith(p =>
            {
                if (!progressForm.IsDisposed) // don't attempt to close disposed form
                    progressForm.Close();
                return p.Result;
            }, TaskScheduler.FromCurrentSynchronizationContext());

        Debug.WriteLine("Before ShowDialog");

        // only show progressForm if 
        if (!progressForm.IsDisposed) // don't attempt to use disposed form
            progressForm.ShowDialog();

        Debug.WriteLine("After ShowDialog");

        bool response = false;

        // await for the task to complete, get the response, 
        //  and check for cancellation and exceptions
        try
        {
            response = await responseTask;
            MessageBox.Show("Result = " + response.ToString());
        }
        catch (AggregateException ae)
        {
            if (ae.InnerException is OperationCanceledException)
                Debug.WriteLine("Cancelled");
            else
            {
                StringBuilder sb = new StringBuilder();
                foreach (var ie in ae.InnerExceptions)
                {
                    sb.AppendLine(ie.Message);
                }
                MessageBox.Show(sb.ToString());
            }
        }
        finally
        {
            // Do I need to double check the form is closed?
            if (!progressForm.IsDisposed) 
                progressForm.Close();
        }

    }
}

Modified code - using TaskCompletionSource as recommended...

    private async void btnDo_Click(object sender, EventArgs e)
    {
        bool? response = null;
        string errorMessage = null;
        using (CancellationTokenSource cts = new CancellationTokenSource())
        {
            using (ProgressForm2 progressForm = new ProgressForm2())
            {
                progressForm.Cancelled += 
                    () => cts.Cancel();
                var dialogReadyTcs = new TaskCompletionSource<object>();
                progressForm.Shown += 
                    (sX, eX) => dialogReadyTcs.TrySetResult(null);
                var dialogTask = Task.Factory.StartNew(
                    () =>progressForm.ShowDialog(this),
                    cts.Token,
                    TaskCreationOptions.None,
                    TaskScheduler.FromCurrentSynchronizationContext());
                await dialogReadyTcs.Task;
                Progress<int> progress = new Progress<int>(
                    (progressInfo) => progressForm.UpdateProgress(progressInfo));
                try
                {
                    response = await MyService.DoSomethingWithResultAsync(50, cts.Token, progress);
                }
                catch (OperationCanceledException) { } // Cancelled
                catch (Exception ex)
                {
                    errorMessage = ex.Message;
                }
                finally
                {
                    progressForm.Close();
                }
                await dialogTask;
            }
        }
        if (response != null) // Success - have valid response
            MessageBox.Show("MainForm: Result = " + response.ToString());
        else // Faulted
            if (errorMessage != null) MessageBox.Show(errorMessage);
    }
lcpldev
  • 83
  • 1
  • 4
  • One potential problem I see here is that you worker task starts executing *before* the progress dialog gets initialized and shown (with `progressForm.ShowDialog()`. Also, mixing `async/awaiy` with `ContinueWith` is almost never necessary. It can be avoided in this case, too. – noseratio Mar 05 '14 at 05:01
  • @Noseratio - Thanks - I really want the task started running before the form is shown modally. It works as it is - but what is the issue with starting the task before the call to 'ShowDialog()'? – lcpldev Mar 05 '14 at 05:30
  • @Noseratio - I tried very had not to use the `ContinueWith` and just use `await` - but I couldn't figure it out in the context of how I was trying to get it working. Any ideas? – lcpldev Mar 05 '14 at 05:32
  • It doesn't look like this may happen with this particular code, but in theory if you start the task before your modal progress dialog has been fully initialized, a `IProgress` notification may arrive on the UI thread *before* your dialog is ready for that. – noseratio Mar 05 '14 at 05:34
  • I think the biggest issue I have, is that using `await` (instead of `ContinueWith`) means I can't use `ShowDialog` because both are blocking calls. If I call `ShowDialog` first the code is blocked at that point, and the progress form needs to actually start the async method (which is what I want to avoid). If I call `await MyService.DoSomethingWithResultAsync` first, then this blocks and I can't then show my progress form. I must be missing something simple here. I'm new to `async`/`Task` - having historically used `AsyncCallback` for this scenario. – lcpldev Mar 05 '14 at 05:52

1 Answers1

8

I think the biggest issue I have, is that using await (instead of ContinueWith) means I can't use ShowDialog because both are blocking calls. If I call ShowDialog first the code is blocked at that point, and the progress form needs to actually start the async method (which is what I want to avoid). If I call await MyService.DoSomethingWithResultAsync first, then this blocks and I can't then show my progress form.

The ShowDialog is indeed a blocking API in the sense it doesn't return until the dialog has been closed. But it is non-blocking in the sense it continues to pump messages, albeit on a new nested message loop. We can utilize this behavior with async/await and TaskCompletionSource:

private async void btnDo_Click(object sender, EventArgs e)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;

    // Create the ProgressForm, and hook up the cancellation to it.
    ProgressForm progressForm = new ProgressForm();
    progressForm.Cancelled += () => cts.Cancel();

    var dialogReadyTcs = new TaskCompletionSource<object>();
    progressForm.Load += (sX, eX) => dialogReadyTcs.TrySetResult(true);

    // show the dialog asynchronousy
    var dialogTask = Task.Factory.StartNew( 
        () => progressForm.ShowDialog(),
        token,
        TaskCreationOptions.None,
        TaskScheduler.FromCurrentSynchronizationContext());

    // await to make sure the dialog is ready
    await dialogReadyTcs.Task;

    // continue on a new nested message loop,
    // which has been started by progressForm.ShowDialog()

    // Create the progress reporter - and have it update 
    //  the form directly (if form is valid (not disposed))
    Action<int> progressHandlerAction = (progressInfo) =>
    {
        if (!progressForm.IsDisposed) // don't attempt to use disposed form
            progressForm.UpdateProgress(progressInfo);
    };
    Progress<int> progress = new Progress<int>(progressHandlerAction);

    try
    {
        // await the worker task
        var taskResult = await MyService.DoSomethingWithResultAsync(100, token, progress);
    }
    catch (Exception ex)
    {
        while (ex is AggregateException)
            ex = ex.InnerException;
        if (!(ex is OperationCanceledException))
            MessageBox.Show(ex.Message); // report the error
    }

    if (!progressForm.IsDisposed && progressForm.Visible)
        progressForm.Close();

    // this make sure showDialog returns and the nested message loop is over
    await dialogTask;
}
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • Thanks - I hadn't seen TaskCompletionSource before - seems to be the main thing I was missing. I'm not quite sure what you're doing with the `while (ex is AggregateException) ex = ex.InnerException;` part though. But otherwise - I have re-written based on your input. – lcpldev Mar 06 '14 at 01:47
  • @lcpldev, the `ex = ex.InnerException` loop is just to get to the actual exception in case `AggregateException` was thrown by the worker task. `AggregateException` can nest another `AggregateException` etc. – noseratio Mar 06 '14 at 01:51
  • Question: Rather than using `Task.Factory.StartNew` would a new extension method on `Window` called `ShowDialogAsync` that returns a `Task` and consists of only two lines: `await Task.Yield()` and `return window.ShowDialog()` essentially do the same thing without having to manually spin up a new task? (Also I noticed this was from '14 so that may not have been an option back then. Not sure.) – Mark A. Donohoe Dec 26 '20 at 18:36
  • Also, shouldn't you use `new TaskCompletionSource()` and not `new TaskCompletionSource()` since you don't actually care about the result, just that there is one (i.e. the latter returns `Task` where the former returns `Task` only)? To indication completion, don't you just need to call `dialogReadyTcs.TrySetResult()`? – Mark A. Donohoe Dec 26 '20 at 18:47
  • 1
    @MarkA.Donohoe, I have a more recent version of it: https://stackoverflow.com/a/33411037/1768303. – noseratio Dec 28 '20 at 10:08