📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are linked cancellation tokens, and how do you combine a caller's token with your own timeout safely?

CreateLinkedTokenSource, disposal to avoid leaks, telling which source fired, and combining request, shutdown and timeout cancellation.

Senior Coding Advanced async cancellationtoken linked-tokens timeouts

🎯 Key points

  • CreateLinkedTokenSource cancels when any parent token is cancelled
  • Add your own timeout with CancelAfter on the linked source
  • Dispose linked sources; they register callbacks on parents
  • Undisposed links on long-lived tokens leak memory
  • Check which token fired and translate own timeouts to TimeoutException
  • WaitAsync stops waiting but does not cancel the work
  • Use TimeProvider to test timeouts

⏱ 30-second answer

CancellationTokenSource.CreateLinkedTokenSource(a, b, ...) creates a new source that is cancelled when any of the given tokens is cancelled, and can also be cancelled itself (for example with CancelAfter). It is how you combine "the caller cancelled" with "my own timeout" or "the host is shutting down". You must dispose the linked source, because it registers callbacks on the parent tokens; forgetting to do that on long-lived tokens leaks memory. To report the right error, check which source fired: if the caller's token is cancelled, rethrow cancellation; if only your timeout fired, throw a TimeoutException.

🗣 Full answer (senior)

How I would answer this in an interview

"CancellationTokenSource.CreateLinkedTokenSource(a, b, ...) creates a new source that becomes cancelled when any of its parent tokens is cancelled, and that I can also cancel myself, typically with CancelAfter(timeout). That's how I combine 'the caller cancelled', 'the host is shutting down' and 'my own deadline' into the single token I pass downstream. There are three rules. First, link, don't replace: if I swap the caller's token for my own timeout token, the caller can no longer cancel. Second, always dispose the linked source, because it registers a callback on each parent; with a long-lived parent like the host's stopping token, undisposed links accumulate and leak memory. Third, say which deadline fired: if the caller's token is cancelled, I let OperationCanceledException propagate, but if only my timeout fired, I translate it to a TimeoutException, so callers and dashboards can tell 'I gave up' from 'the dependency was slow'."


1. The pattern

public async Task<Price> GetPriceAsync(string sku, CancellationToken ct)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);   // 1. link
    linked.CancelAfter(_options.PricingTimeout);                                // 2. own deadline
    try
    {
        return await _http.GetFromJsonAsync<Price>($"prices/{sku}", linked.Token)  // 3. pass linked token
               ?? throw new InvalidOperationException("Empty response");
    }
    catch (OperationCanceledException) when (!ct.IsCancellationRequested)       // 4. only OUR timeout fired
    {
        throw new TimeoutException($"Pricing for {sku} exceeded {_options.PricingTimeout}");
    }
}                                                                               // 5. dispose: unregister from ct

2. Which source fired?

ct (caller) cancelled? linked cancelled? Meaning Surface as
Yes Yes Caller cancelled (or both) OperationCanceledException (caller's intent wins)
No Yes Our timeout TimeoutException
No No Real failure The original exception

Check the caller's token first: if both fired, the caller's intent wins.

3. Why disposal matters

Parent token lifetime Undisposed linked source per operation Result
Short (request token) Registration dies with the request Mostly harmless, still sloppy
Long (host stopping token, service-wide token) Registration stays until the parent is cancelled or disposed Memory leak: callback nodes plus captured state accumulate
Timer from CancelAfter Timer stays scheduled until it fires Extra timers

4. Common combinations

Combine Purpose
Request token + per-call timeout Stop slow dependencies and abandoned requests
stoppingToken + per-message timeout Workers that bound each item's processing time
Several user actions (Cancel button, navigation, window close) Cancel when any happens
Overall budget + per-attempt timeout (retries) Total deadline across retries, plus a deadline per attempt

5. Alternatives and helpers

Tool Behaviour
task.WaitAsync(timeout, ct) (.NET 6+) Stops waiting; the operation continues
HttpClient.Timeout Per-request overall timeout (throws TaskCanceledException with an inner TimeoutException)
Microsoft.Extensions.Http.Resilience / Polly v8 timeout strategies Manage linked tokens, per-attempt and total timeouts for you
TimeProvider (.NET 8+) Testable time: new CancellationTokenSource(timeout, timeProvider) + FakeTimeProvider

Production example

Our [queue worker] created a linked source from the host's stoppingToken for every message, to give each message a [30-second] processing timeout, but never disposed it. Over about [three days] memory grew by [hundreds of MB] until the pod was OOM-killed. A dotnet-gcdump showed millions of CancellationTokenSource.CallbackNode objects rooted by the stopping token, one registration per processed message. Wrapping each linked source in using removed the registration after each message and fixed the leak. We also translated per-message timeouts into TimeoutException so they appeared as "slow dependency" in dashboards instead of being mixed with shutdown cancellations, and added a soak test plus an analyser rule requiring disposal of CancellationTokenSource.

🔍 What the interviewer is checking

Checks careful cancellation composition.

Green flags

  • Uses CreateLinkedTokenSource with CancelAfter.
  • Disposes linked sources and explains the leak.
  • Distinguishes caller cancellation from timeout.
  • Knows WaitAsync does not cancel work.

Red flags

  • Ignores the caller's token.
  • Never disposes sources.

📚 Learn in depth

1. How linking works internally

CreateLinkedTokenSource(a, b):
    linked = new CancellationTokenSource()
    regA = a.UnsafeRegister(s => ((CTS)s).Cancel(), linked)
    regB = b.UnsafeRegister(...)
    (if any parent is already cancelled -> linked is cancelled immediately)

linked.Dispose():
    regA.Dispose(); regB.Dispose()     // removes callback nodes from a and b
    dispose own timer (if CancelAfter was used)

Each link is one callback registration per parent. The parent holds a reference to the linked source until the registration is disposed, which is the leak mechanism.

2. Layered deadlines

ASP.NET Core request (RequestAborted)
  └─ endpoint: total budget 2 s  (linked: RequestAborted + 2 s)
        └─ pricing call: 800 ms   (linked: endpoint token + 800 ms)
        └─ stock call:   800 ms   (linked: endpoint token + 800 ms)

Each layer links to its parent's token, never to a fresh one, so cancellation still flows from the top. Inner timeouts should be shorter than outer budgets, otherwise the inner timeout never fires and the outer one masks which dependency was slow.

3. Retries with total and per-attempt timeouts

using var total = CancellationTokenSource.CreateLinkedTokenSource(ct);
total.CancelAfter(TimeSpan.FromSeconds(5));

for (int attempt = 1; attempt <= 3; attempt++)
{
    using var perAttempt = CancellationTokenSource.CreateLinkedTokenSource(total.Token);
    perAttempt.CancelAfter(TimeSpan.FromSeconds(1.5));
    try { return await CallAsync(perAttempt.Token); }
    catch (OperationCanceledException) when (!total.IsCancellationRequested) { /* attempt timed out; retry */ }
    catch (HttpRequestException) when (attempt < 3) { await Task.Delay(200 * attempt, total.Token); }
}

In production I'd use the standard resilience handler instead of hand-writing this, but it shows the structure.

4. Testing timeouts deterministically

var time = new FakeTimeProvider();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5), time);
var task = service.DoAsync(cts.Token);
time.Advance(TimeSpan.FromSeconds(6));          // no real waiting
await Assert.ThrowsAsync<TimeoutException>(() => task);

The code under test must also use TimeProvider (e.g. Task.Delay(delay, timeProvider, ct)) for fully deterministic tests.

5. Detecting the leak

  • dotnet-counters: GC heap growing steadily.
  • dotnet-gcdump / dumpheap -stat: many CallbackNode / CancellationTokenSource instances.
  • gcroot on one: path through the long-lived parent token's callback list.

6. Version notes

Version Change
.NET Framework 4.0 CreateLinkedTokenSource
.NET Framework 4.5 CancelAfter
.NET Core 3.0 UnsafeRegister (used internally for cheaper links)
.NET 6 TryReset, WaitAsync
.NET 8 TimeProvider, CTS constructor with TimeProvider, CancelAsync
.NET 10 Same

7. Try it yourself

  1. Run the code example: first call times out (TimeoutException), second is cancelled by the caller.
  2. Remove using and create 1,000,000 linked sources from one long-lived token; watch memory with dotnet-counters.
  3. Take a gcdump and find the CallbackNode objects.
  4. Build a three-level deadline chain (request → endpoint → call) and log which level fired.
  5. Rewrite a timeout test using FakeTimeProvider so it runs instantly.
  6. Compare WaitAsync(timeout) with a linked token by checking whether the underlying operation keeps running.

💻 Code

using var callerCts = new CancellationTokenSource();

try
{
    await PricingClient.GetPriceAsync("SKU-1", TimeSpan.FromMilliseconds(100), callerCts.Token);
}
catch (TimeoutException ex)
{
    Console.WriteLine($"Timeout: {ex.Message}");
}

callerCts.CancelAfter(10);
try
{
    await PricingClient.GetPriceAsync("SKU-1", TimeSpan.FromSeconds(5), callerCts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Caller cancelled");
}

public static class PricingClient
{
    public static async Task<decimal> GetPriceAsync(string sku, TimeSpan timeout, CancellationToken ct)
    {
        // Dispose the linked source: it registers callbacks on ct.
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
        linked.CancelAfter(timeout);

        try
        {
            await Task.Delay(1000, linked.Token); // stands in for a slow HTTP call
            return 42m;
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            // Only our timeout fired: report it as a timeout, not a caller cancellation.
            throw new TimeoutException($"Pricing for {sku} exceeded {timeout.TotalMilliseconds} ms");
        }
    }
}

⚠ Common mistakes

1. Not disposing linked sources

What happens: var linked = CreateLinkedTokenSource(stoppingToken); per message. Why it is wrong: registrations on the long-lived parent accumulate (memory leak). Do instead: using var linked = ....

2. Replacing the caller's token

What happens: using var cts = new CancellationTokenSource(timeout); await CallAsync(cts.Token);. Why it is wrong: the caller's cancellation no longer reaches the operation. Do instead: link the caller's token with your timeout.

3. Reporting your timeout as cancellation

What happens: OperationCanceledException bubbles up for a slow dependency. Why it is wrong: indistinguishable from a client disconnect; outages hide as "cancellations". Do instead: translate to TimeoutException when only your timeout fired.

4. Checking the wrong token first

What happens: code checks linked.IsCancellationRequested to decide it's a timeout. Why it is wrong: the linked token is also cancelled when the caller cancels. Do instead: check the caller's token first: when (!ct.IsCancellationRequested).

5. Inner timeouts longer than outer budgets

What happens: per-call 5 s inside an endpoint budget of 2 s. Why it is wrong: the inner timeout never fires; diagnostics point to the wrong layer. Do instead: inner deadlines shorter than their parent's.

6. Assuming WaitAsync cancels the work

What happens: await op.WaitAsync(TimeSpan.FromSeconds(2)) as the timeout strategy. Why it is wrong: the operation keeps running and consuming resources. Do instead: pass a linked token into the operation.

7. Creating linked sources in tight loops

What happens: one linked source per item in a loop over millions of items. Why it is wrong: registration and timer overhead. Do instead: one deadline per batch, or TryReset with pooling.

8. Hard-coded timeouts

What happens: CancelAfter(5000) scattered through the code. Why it is wrong: can't tune per environment; hard to test. Do instead: options/config plus TimeProvider.

9. Swallowing the timeout

What happens: catch (OperationCanceledException) { return default; } after a timeout. Why it is wrong: callers believe the call succeeded with no data. Do instead: throw TimeoutException or return an explicit failure result.

10. Using Thread.Sleep-based tests for timeouts

What happens: tests wait real seconds and are flaky on slow CI agents. Why it is wrong: slow, nondeterministic suites. Do instead: FakeTimeProvider.

🏛 Senior insight: trade-offs

Linked tokens are where cancellation design gets real: every layer may add its own deadline, and errors must say which deadline was hit. I keep the rule "link, dispose, and translate timeouts" and make timeouts configurable and testable with TimeProvider.

Concern Practice
Combining sources CreateLinkedTokenSource
Lifetime using on every linked source
Error semantics TimeoutException for own timeouts
Testing TimeProvider and FakeTimeProvider
HTTP calls Resilience handler with attempt and total timeouts

🔗 Follow-ups the interviewer may ask

  1. When: Candidate talks about forwarding tokens Why: Checks cancellation fundamentals
    How to answer

    Cancellation in .NET is cooperative. A CancellationTokenSource owns the decision to cancel; it hands out CancellationTokens that code checks with IsCancellationRequested or ThrowIfCancellationRequested(), or passes to APIs that support it. When cancelled, the convention is to throw OperationCanceledException, which makes the task Canceled. Good APIs accept a CancellationToken as the last parameter (defaulting to default), pass it to every async call they make, and never swallow cancellation. In ASP.NET Core, a CancellationToken parameter is bound to HttpContext.RequestAborted, so client disconnects stop the work.

  2. When: Candidate translates to TimeoutException Why: Probes timeout layering
    How to answer

    A timeout should cancel the work, not just stop waiting. Use a CancellationTokenSource with CancelAfter (linked to the caller's token) and pass it down. Task.WaitAsync(timeout) only stops waiting. For HTTP, HttpClient.Timeout is a single total timeout (100 seconds by default) that throws TaskCanceledException with an inner TimeoutException; in production I prefer AddStandardResilienceHandler, which sets attempt and total timeouts plus retries. In ASP.NET Core 8+, the request timeouts middleware cancels RequestAborted after a configured time. Timeouts must be layered: each inner timeout shorter than the outer one.

🛠 Real-world problems: how you handle them

Memory leak from undisposed linked token sources
Situation
A [queue worker] created a linked source from the host stopping token for every message to add a per-message timeout.
Signals
Memory grew by [hundreds of MB] over [three days] until the pod was OOM-killed.
Investigation
A `dotnet-gcdump` showed millions of `CancellationTokenSource.CallbackNode` objects rooted by the stopping token.
Resolution
Wrapped each linked source in `using`, so its registration was removed after each message.
Prevention
Added a soak test and an analyser rule requiring disposal of `CancellationTokenSource`.

How to tell it (STAR)

Our [queue worker] slowly ran out of memory. A GC dump showed millions of cancellation callbacks attached to the host's stopping token. Each message created a linked source that was never disposed. Adding `using` fixed the leak, and we added a soak test to catch it in future.

↩ Usually asked after

More in Async Advanced

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me