Task.ContinueWith implicit type conversion gotcha
I was recently writing an e-mail sending service using MailKit. I thought I was done with it, but when it came to testing, it turned out that the emails rarely get sent. The reason for that was this exception:
System.NotSupportedException: The ReadAsync method cannot be called when another read operation is pending.
at MailKit.Net.Smtp.SmtpClient.FlushCommandQueueAsync(MimeMessage message, MailboxAddress sender, IList`1 recipients, Boolean doAsync, CancellationToken cancellationToken)
at MailKit.Net.Smtp.SmtpClient.SendAsync(FormatOptions options, MimeMessage message, MailboxAddress sender, IList`1 recipients, Boolean doAsync, CancellationToken cancellationToken, ITransferProgress progress)
This is what my code looked like:
var client = new SmtpClient();
var connectTask = client.ConnectAsync(_smtpConfig.Host, _smtpConfig.Port, _smtpConfig.Security);
if (!string.IsNullOrEmpty(_smtpConfig.Username) && !string.IsNullOrEmpty(_smtpConfig.Password))
{
connectTask = connectTask.ContinueWith(_ => client.AuthenticateAsync(_smtpConfig.Username, _smtpConfig.Password), TaskContinuationOptions.OnlyOnRanToCompletion);
}
var message = new MimeMessage();
...
await connectTask;
await client.SendAsync(message);
await client.DisconnectAsync(true);
If you can spot the error right away, kudos to you! But I couldn't. So I put a breakpoint and started debugging the problem.
The first time the debugger hit the breakpoint and let me go through each instruction one by one, the mail was sent successfully. So I played with it, toggling the breakpoint between on and off.
Every time I took my time analysing each line, the mail was sent, and most of the time I let the code run by itself - the exception appeared. That, of course, pointed me towards my usage of Tasks and, in particular, the ContinueWith
method - which I'll admit I do not use often.
There's pretty comprehensive documentation about chaining Tasks at Chaining tasks using continuation tasks (it's in the .NET Fundamentals category 😅). Particularly the Continuations that return Task types section caught my eye because, well, my continuation function did return a Task.
Then it all clicked. So the connectTask.ContinueWith(...)
call actually returned Task<Task>
, and it's that inner Task I should have called await
on to make sure that the client.AuthenticateAsync(...)
call is completed.
BUT since I decided to reuse the existing connectTask
variable, which was of Task
type, the await
call did not wait for the inner task to complete. Implicit conversion from Task<Task>
to Task
caused that I no longer had control over the continuation task.
Maybe if I didn't reuse the connectTask
variable, I would've noticed the funny Task<Task>
type, maybe not - but the implicit conversion certainly did not make things easier.
The solution was simple - adding .Unwrap()
after connectTask.ContinueWith(...)
caused the connectTask
to point to the inner Task of the Task<Task>
returned by ContinueWith
method (or rather an UnwrapPromise
which acts like a proxy between the tasks).
var client = new SmtpClient();
var connectTask = client.ConnectAsync(_smtpConfig.Host, _smtpConfig.Port, _smtpConfig.Security);
if (!string.IsNullOrEmpty(_smtpConfig.Username) && !string.IsNullOrEmpty(_smtpConfig.Password))
{
connectTask = connectTask
.ContinueWith(
_ => client.AuthenticateAsync(_serverConfiguration.Username, _serverConfiguration.Password),
TaskContinuationOptions.OnlyOnRanToCompletion)
.Unwrap(); // <-- the important bit
}
var message = new MimeMessage();
...
await connectTask;
await client.SendAsync(message);
await client.DisconnectAsync(true);
I hope I explained this well enough. I produced this little demo in LINQPad to get the point across without all the MailKit noise:
async Task Main()
{
var stack = new Stack<int>();
stack.Dump("Before starting first Task");
var task = Method1(stack);
task = task.ContinueWith(_ => Method2(stack));
stack.Dump("After starting the first task and chaining the second one");
await task;
stack.Dump("After awaiting the 'task' variable");
}
private async Task Method1(Stack<int> stack)
{
stack.Push(1);
await Task.Delay(1000);
}
private async Task Method2(Stack<int> stack)
{
stack.Push(2);
await Task.Delay(1000);
stack.Push(3);
}
Here's the output it produces:
And here's the output after adding .Unwrap()
at the end of line #6 in the above snippet:
There are two more resources I recommend if something is not clear. Another MSDN article: How to: Unwrap a Nested Task, and this StackOverflow answer (particularly the second snippet, which uses the Unwrap()
method).
Cover photo by Karine Avetisyan on Unsplash