Module 7 · 7. Async, Concurrency, Cancellation, and Performance · Lesson 20 of 24
Cancellation, Timeouts, and End-to-End Time Budgets
What you will learn
Forward a cancellation request, keep caller and deadline observations separate, and give two dependent stages one shared time budget. The examples use only in-memory work. A separate semantic harness advances a test clock instead of sleeping, so deadline behavior can be checked without hoping a timer fires at the right instant.
1. A request to stop needs an observer
Cancellation is cooperative: a token carries a request, while each operation decides when it can stop safely. Creating a CancellationToken parameter but never forwarding it does not help the next operation stop.
Think of a workshop coordinator raising a stop card. Workers check it at agreed safe checkpoints. The card does not undo an already completed cut, and raising it does not prove every worker saw it. In our code, a checkpoint is an explicit token check or a token-aware wait. This analogy describes cooperation, not permission to interrupt arbitrary machine instructions.
ThrowIfCancellationRequested throws an OperationCanceledException associated with that token when a request is present.
A linked CancellationTokenSource becomes requested when either supplied source is requested. A timed source requests cancellation after its delay; the TimeProvider overload lets a test supply the clock. CancelAfter schedules a request and repeated calls can reset its delay. Therefore, do not restart a full end-to-end budget for every stage.
2. Report what was observed, without inventing a cause
OperationCanceledException exposes an associated CancellationToken. Our boundary handles it only when that token matches the linked token and cancellation is requested. An unrelated exception is allowed to propagate.
The reporting policy is deliberately explicit: success carries a value; recognized cancellation carries one of caller-requested, deadline-requested, or both-requested. These labels describe token states read at the boundary. They do not prove which event initiated the exception. The two reads are not an atomic snapshot of simultaneous events, and a later request cannot rewrite a report already returned.
CancellationBoundary.cs
internal sealed record StopReport(string Kind, string? Value);
internal static class CancellationBoundary
{
public static async Task<StopReport> ObserveAsync(
CancellationToken caller, CancellationToken deadline,
Func<CancellationToken, Task<string>> operation)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(caller, deadline);
CancellationToken token = linked.Token;
try
{
token.ThrowIfCancellationRequested();
string value = await operation(token);
token.ThrowIfCancellationRequested();
return new StopReport("success", value);
}
catch (OperationCanceledException error)
when (error.CancellationToken == token && token.IsCancellationRequested)
{
// Observations at this boundary, not proof of the initiating cause.
bool callerRequested = caller.IsCancellationRequested;
bool deadlineRequested = deadline.IsCancellationRequested;
string kind = (callerRequested, deadlineRequested) switch
{
(true, true) => "both-requested",
(true, false) => "caller-requested",
(false, true) => "deadline-requested",
_ => "linked-requested"
};
return new StopReport(kind, null);
}
}
}Read this contract carefully
- The first check prevents a pre-canceled operation from starting. The final check refuses to report success if a request is visible there. A completed side effect is not undone even if that final check returns a stop report instead of the value. A request arriving after the final check may race with successful return; no general atomic cancel-versus-success transaction is promised.
- The filter uses both token identity and requested state. A different operation's cancellation is not relabeled merely because our caller also happened to cancel. Some APIs do not preserve the supplied token in their exception; integrating those APIs needs an explicit contract-specific policy rather than weakening this check blindly.
- This is a reporting boundary: ObserveAsync returns a successful Task containing a StopReport for recognized cancellation. Its task is not itself canceled. Ordinary lower-level methods should still propagate cancellation; this example normalizes it only where a caller explicitly wants a report. Unexpected exceptions remain exceptional.
- The fallback linked-requested label is defensive. With these two monotonic sources and no custom behavior, at least one source should already be requested when this branch is entered. It is not a third cancellation cause.
3. Run four controlled outcomes
Put the following Program.cs, CancellationBoundary.cs, and DeadlineBudget.cs in one folder named CancellationWalkthrough, together with the project file. Run dotnet run --configuration Release using .NET 10. No packages are needed. The four scenarios request cancellation directly so the demonstration needs no real waiting.
Program.cs
internal static class Program
{
public static async Task Main()
{
foreach (string scenario in new[] { "none", "caller", "deadline", "both" })
{
using var caller = new CancellationTokenSource();
using var deadline = new CancellationTokenSource();
StopReport report = await CancellationBoundary.ObserveAsync(
caller.Token, deadline.Token, token =>
{
if (scenario is "caller" or "both") caller.Cancel();
if (scenario is "deadline" or "both") deadline.Cancel();
token.ThrowIfCancellationRequested();
return Task.FromResult("ready");
});
Console.WriteLine($"{scenario}: {report.Kind}");
}
TimeSpan total = TimeSpan.FromSeconds(2);
TimeSpan remaining = DeadlineBudget.Remaining(total, TimeSpan.FromMilliseconds(1500));
Console.WriteLine($"remaining after 1500 ms: {remaining.TotalMilliseconds} ms");
Console.WriteLine($"remaining after 2500 ms: {DeadlineBudget.Remaining(total,
TimeSpan.FromMilliseconds(2500)).TotalMilliseconds} ms");
}
}In the both scenario, both requests are made before the checkpoint throws. The report is both-requested even though this particular controller called Cancel in a known order. The lesson is that reading two true flags is not itself evidence of causal order. Reporting caller-requested with a caller-priority policy would also require labeling it as policy, not as proof that the caller caused the exception.
4. Give the entire workflow one budget
Suppose the request has 2,000 milliseconds. Stage one consumes 1,500 milliseconds, so stage two has at most 500 left. Giving it another 2,000 would allow up to 3,500 milliseconds before overhead. Any waiting, retries, and local processing must consume the same overall allowance.
TimeProvider supplies timestamps, elapsed-time calculations and timers. The default here is TimeProvider.System. Injecting a clock makes time-dependent branches testable.
DeadlineBudget.cs
internal static class DeadlineBudget
{
public static TimeSpan Remaining(TimeSpan total, TimeSpan elapsed) =>
elapsed >= total ? TimeSpan.Zero : total - elapsed;
public static async Task<StopReport> RunTwoStagesAsync(
TimeSpan total, CancellationToken caller,
Func<string, TimeSpan, CancellationToken, Task<string>> stage,
TimeProvider? clock = null)
{
if (total <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(total));
clock ??= TimeProvider.System;
long started = clock.GetTimestamp();
using var deadline = new CancellationTokenSource(total, clock);
return await CancellationBoundary.ObserveAsync(caller, deadline.Token, async token =>
{
TimeSpan CheckBudget()
{
TimeSpan remaining = Remaining(total, clock.GetElapsedTime(started));
if (remaining == TimeSpan.Zero) deadline.Cancel();
token.ThrowIfCancellationRequested();
return remaining;
}
string first = await stage("first", CheckBudget(), token);
string second = await stage("second", CheckBudget(), token);
CheckBudget();
return $"{first} | {second}";
});
}
}Trace the shared deadline
- RunTwoStagesAsync requires a positive total budget. It records one start timestamp and creates one timed source for the entire workflow. The constructor's supported delay range still applies; invalid values are not silently treated as infinite.
- The reporting boundary links that deadline with the caller request. Every stage receives the same linked token and a freshly computed remaining duration.
- CheckBudget subtracts elapsed time from the original total. It clamps exhaustion to zero and requests the deadline immediately at that checkpoint, so a delayed timer callback does not grant extra time to a new stage.
- Stage two is started only after stage one succeeds and the budget check passes. After stage two, a final check prevents a late result from being reported as an on-time success under this example's policy.
Remaining is a small arithmetic helper whose inputs here are a positive total and nonnegative elapsed time. The passed duration is a ceiling for the next dependency, not a fresh end-to-end timeout. The token covers cancellation during the stage; the explicit checks cover boundaries between stages. A dependency can still ignore the token. This wrapper awaits it and cannot forcibly stop it or roll back its side effects.
The original HTTP-shaped sketch needed an undeclared client. This replacement keeps the same budget-and-linked-token idea but takes an explicit stage delegate, making its ownership and outcomes executable without a network. A real HTTP integration should forward the token to the HTTP API and decide response semantics at its own boundary; no universal HTTP status mapping is assumed here.
CancellationWalkthrough.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>Expected console output
none: success caller: caller-requested deadline: deadline-requested both: both-requested remaining after 1500 ms: 500 ms remaining after 2500 ms: 0 ms
5. Worked tests and exercises
Exercise A: spend the budget once
At elapsed times 0, 1,500, and 2,500 milliseconds, what is left from a 2,000-millisecond budget?
Solution: 2,000, 500, and 0 milliseconds. The separate harness advances its clock by 1,500 in stage one and 400 in stage two. It verifies that the stages receive 2,000 and 500 respectively and the result succeeds at 1,900. When stage one consumes the full 2,000 instead, it verifies that stage two is never called. A retry at that point would violate the same budget policy.
Exercise B: cancellation while waiting
What must a test control to verify a timeout during stage two without a sleep?
Solution: First arrange an unfinished in-memory reply and a separate signal that confirms stage two reached it. Await that entry signal, advance the manual clock to the deadline, and await the workflow. The actual CancellationTokenSource timer callback requests its token, and the token-aware wait ends. The harness checks the deadline-requested report.
Calling Task.WaitAsync(token) can cancel the wait; it does not cancel or complete the underlying task. A timeout overload similarly bounds waiting rather than forcibly stopping the represented operation.
Our underlying object is only a classroom readiness signal. The test verifies it remains unfinished after the canceled wait, then explicitly completes it. A real background operation would need its own lifetime owner and a way to observe its eventual result. Adding WaitAsync around noncooperative work does not supply those responsibilities.
Exercise C: the misleading catch
An operation throws cancellation with a different token just after the caller requests cancellation. May our boundary return caller-requested?
Solution: No. That exception fails the token-identity guard and propagates unchanged. The test deliberately makes both facts true and checks the original exception token. A current caller request is insufficient evidence to relabel an unrelated failure.
Exercise D: preserve outcome meaning
The report says both-requested. Can a metric count it as a confirmed caller-caused cancellation? Can a handler pretend the operation succeeded?
Solution: Neither follows. Store a separate both-requested category, or document a precedence policy for reporting while retaining ambiguity. A recognized stop has no Value in this contract. Its reporting task completed normally, but the requested business operation did not succeed.
6. Interview checklist
- Who owns each cancellation source and the lifetime of the operations it controls?
- Does every dependency receive the token, and where are safe checkpoints?
- Is this a per-call timeout or one end-to-end budget? Are retries and queueing included?
- Are you recording observed state, a chosen precedence policy, or actual evidence of cause?
- Does canceling a wait leave work running, and who will observe its final outcome?
- Will expected caller departure be distinguished from deadline expiry and unexpected failure, without automatically logging all cancellations at high severity?