FlintZA
FlintZA

Reputation: 872

How should awaiting an async task and showing a modal form in the same method be handled?

I have a windows forms application in which I send an email using SmtpClient. Other async operations in the application use async/await, and I'd ideally like to be consistent in that when sending the mail.

I display a modal dialog with a cancel button when sending the mail, and combining SendMailAsync with form.ShowDialog is where things get tricky because awaiting the send would block, and so would ShowDialog. My current approach is as below, but it seems messy, is there a better approach to this?

private async Task SendTestEmail()
{
  // Prepare message, client, and form with cancel button
  using (Message message = ...)
  {
     SmtpClient client = ...
     CancelSendForm form = ...

     // Have the form button cancel async sends and
     // the client completion close the form
     form.CancelBtn.Click += (s, a) =>
     {
        client.SendAsyncCancel();
     };
     client.SendCompleted += (o, e) =>
     {
       form.Close();
     };

     // Try to send the mail
     try
     {
        Task task = client.SendMailAsync(message);
        form.ShowDialog();
        await task; // Probably redundant

        MessageBox.Show("Test mail sent", "Success");
     }
     catch (Exception ex)
     {
        string text = string.Format(
             "Error sending test mail:\n{0}",
             ex.Message);
        MessageBox.Show(text, "Error");
     }
  }   

Upvotes: 8

Views: 5915

Answers (2)

noseratio
noseratio

Reputation: 61686

One questionable thing about your existing SendTestEmail implementation is that it's in fact synchronous, despite it returns a Task. So, it only returns when the task has already completed, because ShowDialog is synchronous (naturally, because the dialog is modal).

This can be somewhat misleading. For example, the following code wouldn't work the expected way:

var sw = new Stopwatch();
sw.Start();
var task = SendTestEmail();
while (!task.IsCompleted)
{
    await WhenAny(Task.Delay(500), task);
    StatusBar.Text = "Lapse, ms: " + sw.ElapsedMilliseconds;
}
await task;

It can be easily addressed with Task.Yield, which would allow to continue asynchronously on the new (nested) modal dialog message loop:

public static class FormExt
{
    public static async Task<DialogResult> ShowDialogAsync(
        Form @this, CancellationToken token = default(CancellationToken))
    {
        await Task.Yield();
        using (token.Register(() => @this.Close(), useSynchronizationContext: true))
        {
            return @this.ShowDialog();
        }
    }
}

Then you could do something like this (untested):

private async Task SendTestEmail(CancellationToken token)
{
    // Prepare message, client, and form with cancel button
    using (Message message = ...)
    {
        SmtpClient client = ...
        CancelSendForm form = ...

        // Try to send the mail
        var ctsDialog = CancellationTokenSource.CreateLinkedTokenSource(token);
        var ctsSend = CancellationTokenSource.CreateLinkedTokenSource(token);
        var dialogTask = form.ShowDialogAsync(ctsDialog.Token);
        var emailTask = client.SendMailExAsync(message, ctsSend.Token);
        var whichTask = await Task.WhenAny(emailTask, dialogTask);
        if (whichTask == emailTask)
        {
            ctsDialog.Cancel();
        }
        else
        {
            ctsSend.Cancel();
        }
        await Task.WhenAll(emailTask, dialogTask);
    }   
}

public static class SmtpClientEx
{
    public static async Task SendMailExAsync(
        SmtpClient @this, MailMessage message, 
        CancellationToken token = default(CancellationToken))
    {
        using (token.Register(() => 
            @this.SendAsyncCancel(), useSynchronizationContext: false))
        {
            await @this.SendMailAsync(message);
        }
    }
}

Upvotes: 1

Todd Menier
Todd Menier

Reputation: 39319

I would consider handling the Form.Shown event and sending the email from there. Since it'll fire asynchronously, you don't need to worry about "working around" ShowDialog's blocking nature, and you have a slightly cleaner way to synchronize closing the form and showing the success or failure message.

form.Shown += async (s, a) =>
{
    try
    {
        await client.SendMailAsync(message);
        form.Close();
        MessageBox.Show("Test mail sent", "Success");
    }
    catch(Exception ex)
    {
        form.Close();
        string text = string.Format(
            "Error sending test mail:\n{0}",
            ex.Message);
        MessageBox.Show(text, "Error");
    }
};

form.ShowDialog();

Upvotes: 10

Related Questions