We have the following code:
public async Task<Result<TData>> SendAsync<TData>(
Func<HttpRequestMessage> requestFactory,
RequestOptions? requestOptions = null,
CancellationToken cancellationToken = default)
{
requestOptions ??= new RequestOptions();
var policy = requestOptions.Policy ?? DefaultPollyPolicy;
var result = await policy.ExecuteAndCaptureAsync(async () =>
{
using var request = requestFactory();
if (cancellationToken == CancellationToken.None)
{
// Default timeout 5 seconds
using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
cancellationToken = cancellationTokenSource.Token;
}
using var response =
await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
................
});
................
}
It sends a request using HttpClient, wrapped in a Polly policy that, by default, retries once. If the caller doesn't pass a CancellationToken, we create our own with a default timeout of 5 seconds.
Looks reasonable, right? Can you spot the bug? There are actually two of them :)
A using var is disposed when its enclosing scope ends. Here, the enclosing scope is the if block, not the lambda. So the CancellationTokenSource is disposed at the closing brace, before the request is even sent.
When a CancellationTokenSource is disposed, its timer is disposed as well. The token we got from it still "works", nothing throws, it will just never be cancelled.
Small repro:
var cancellationToken = CancellationToken.None;
var stopwatch = Stopwatch.StartNew();
if (cancellationToken == CancellationToken.None)
{
using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));
cancellationToken = cancellationTokenSource.Token;
}
try
{
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
Console.WriteLine($"Completed after {stopwatch.Elapsed.TotalSeconds:F1}s");
}
catch (Exception e)
{
Console.WriteLine($"{e.GetType().Name} after {stopwatch.Elapsed.TotalSeconds:F1}s");
}
Completed after 3.0s
The timeout is 1 second, but the delay runs for the full 3 seconds.
So in the real code, the only timeout left is HttpClient.Timeout, and the default for that is 100 seconds.
cancellationToken is a parameter of the method, and the lambda captures it. So when the lambda assigns it, it's not a local copy that changes, it's the same variable for every invocation of the lambda.
When Polly retries, cancellationToken == CancellationToken.None is no longer true. The retry skips the if block and reuses the token from the first attempt, the one that belongs to an already disposed CancellationTokenSource.
This also means that just moving the using var out of the if block isn't enough. That would fix the first attempt, but the retry would still run without a timeout.
using var timeoutCts = cancellationToken == CancellationToken.None
// Default timeout 5 seconds
? new CancellationTokenSource(TimeSpan.FromSeconds(5), _timeProvider)
: null;
var token = timeoutCts?.Token ?? cancellationToken;
using var response =
await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
The CancellationTokenSource now lives for the whole attempt, and each attempt gets its own local token instead of overwriting the captured parameter.
Since the CancellationTokenSource now takes a TimeProvider, it's also possible to test the timeout with FakeTimeProvider without having to wait 5 seconds.
Running the repro again with the fix:
TaskCanceledException after 1.0s
Two bugs in one small if statement, not bad :)
James and Frank explore Apple's next-gen Siri and iOS 27 updates with mixed results—the chatbot fell short of expectations despite hype. They dive into AI agents, smart home automation potential, and why Apple's scattered AI integration lags behind competitors like OpenAI and Microsoft. Plus, practical developer updates on Xcode, .NET, and automating your digital life.
⭐⭐ Review Us ⭐⭐
Machine transcription available on http://mergeconflict.fm


Microsoft has released Aspire 13.6. The dashboard now stores telemetry in SQLite and keeps up to ten completed runs per application. The release adds prerelease hosting packages for Java and Rust, portable volume paths, and new CLI options. Breaking changes include automatic TLS for local MongoDB resources and a new default Cosmos DB emulator image.
By Almir Vuk