3 min readBy Muhammad Shahid
C# TaskCompletionSource: Wrap Legacy Events as Tasks
TaskCompletionSource creates a Task you complete yourself. Use it to wrap event-based APIs (EAP) into TAP: TrySetResult when Connected fires, then callers await instead of WaitOne.
Part of Async & Threading
Quick answers
- How do I wrap a legacy event-based API into a Task with TaskCompletionSource?
- Create a TaskCompletionSource, subscribe once, TrySetResult / TrySetException / TrySetCanceled in the handler, return tcs.Task, and unsubscribe in a finally. Use RunContinuationsAsynchronously so the vendor event thread is not hijacked by your awaiters.
- Why TrySetResult instead of SetResult?
- SetResult throws if the Task is already completed. Events can double-fire, and a timeout or cancel can complete the Task first. TrySet* is idempotent and race-safe.
- What is TAP versus EAP?
- TAP is the Task-based Asynchronous Pattern (await). EAP is the Event-Based Asynchronous Pattern (FooCompleted). APM is Begin/End. TaskCompletionSource is the bridge. Do not .Result that Task on a UI thread (deadlock) or an ASP.NET Core request (starvation).
TaskCompletionSource<T> creates a Task you control. When the legacy Connected event fires, you call TrySetResult() and anyone awaiting that task continues. That is the bridge from EAP (Event-based Asynchronous Pattern: FooCompleted) to TAP (Task-based Asynchronous Pattern: await). APM is the even older Begin/End pair.
New to this → stay here. Merging a PR → wrong vs right. On-call / interview → vendor connect · if an interviewer asks.
Terms used here: RunContinuationsAsynchronously = do not run your await continuation on the vendor’s event thread (that thread can deadlock their lock).
Legacy Connected event → handler → tcs.TrySetResult()
↓
caller: await tcs.Task
Smallest example
var tcs = new TaskCompletionSource<int>(
TaskCreationOptions.RunContinuationsAsynchronously);
tcs.TrySetResult(42);
var n = await tcs.Task; // 42
In a unit test, a fake completes the TCS instead of raising a real event.
Wrong vs right
I would reject SetResult on a vendor event that can double-fire, and wrapping then calling .Result on Kestrel or a UI thread.
tcs.SetResult(); // throws if already completed (double-fire, cancel raced)
tcs.TrySetResult(); // Right — no-op if already done
Do not wrap then call .Result on an API thread (starvation) or a UI thread (deadlock).
A stream of MessageReceived is not a Task. That is IAsyncEnumerable or a Channel. TCS is one completion.
One-shot connect
The vendor SDK had Connected / Faulted and no Task. Samples used AutoResetEvent.WaitOne. We needed await ConnectAsync(ct).
public Task ConnectAsync(Uri endpoint, CancellationToken ct)
{
var tcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnOk(object? sender, EventArgs e)
{
Cleanup();
tcs.TrySetResult();
}
void OnFail(object? sender, ExceptionEventArgs e)
{
Cleanup();
tcs.TrySetException(e.Exception);
}
void Cleanup()
{
_client.Connected -= OnOk;
_client.Faulted -= OnFail;
}
ct.Register(() =>
{
Cleanup();
tcs.TrySetCanceled(ct);
try { _client.Abort(); } catch { /* vendor */ }
});
_client.Connected += OnOk;
_client.Faulted += OnFail;
_client.Connect(endpoint);
return tcs.Task;
}
Rules:
RunContinuationsAsynchronously—SetResulton the vendor thread otherwise runsawaitcontinuations inline on that thread.TrySet*— cancel, timeout, and the event race.- Unsubscribe — handlers on a long-lived client are leaks.
- Register cancellation that actually aborts the SDK.
Linked timeout: CreateLinkedTokenSource per operation; dispose it. Do not store a CTS on a singleton and cancel it from two requests.
Request/reply
public Task<Reply> SendAsync(Request request, CancellationToken ct)
{
var tcs = new TaskCompletionSource<Reply>(
TaskCreationOptions.RunContinuationsAsynchronously);
_pending[request.Id] = tcs;
ct.Register(() =>
{
if (_pending.TryRemove(request.Id, out var pending))
pending.TrySetCanceled(ct);
});
_client.Send(request);
return tcs.Task;
}
ConcurrentDictionary for _pending — use TryAdd, not GetOrAdd.
Libraries still need ConfigureAwait(false). Tokens: CancellationToken.
Common mistakes
- Completing on the event thread without
RunContinuationsAsynchronously async voidevent handler that you could have turned into TCS at the boundary- Fire-and-forget
_ = ConnectAsync()with no observation of faults
What this is not
A stream of events is not a Task: IAsyncEnumerable or a Channel. Tokens: CancellationToken. Libraries still need ConfigureAwait(false). Topic map: async & threading hub.
If an interviewer asks
TAP; wrapping EAP; testing async code by completing a TCS in a fake.
Strong answer: TrySet, RunContinuationsAsynchronously, unsubscribe, cancel aborts the SDK.
If you are stuck with an event-only vendor SDK on a Kestrel host, contact me. Bring the event list. One-shot vs stream is the whole design.