Use Cancellationtoken Overload

ID

csharp.use_cancellationtoken_overload

Severity

high

Remediation Complexity

medium

Remediation Risk

low

Remediation Effort

medium

Resource

Code Smell

Language

CSharp

Tags

async, cancellation, code_smell, reliability

Description

Reports a cancellable framework call made without a token, from a method that has a CancellationToken in its own parameter list. The token is available and is not passed on, so cancellation stops at this call.

Rationale

Cancellation in .NET is cooperative. Nothing interrupts a running operation: the operation has to be handed the token and has to watch it. A method that accepts a token has been told that it may be asked to stop, and a call inside it that omits the token silently declines that contract for everything below it.

The failure is an absence rather than an error, which is what makes it hard to notice. A request the client abandoned keeps its connection, its thread and its database work until the underlying operation finishes on its own. A timeout expressed as a cancellation elapses without effect. A host asked to shut down waits for operations that were told to stop and never checked. Under load the abandoned work keeps consuming the same resources as the work that replaced it, so the effective capacity of the service drops as clients give up.

Three conditions must hold before anything is reported: the enclosing method declares a CancellationToken parameter, so a token is in scope and no plumbing is needed; the receiver resolves to one of a curated set of framework types whose member has a cancellable overload in the platform — the task delay, the stream read, write, copy and flush family, the HTTP client send members, the semaphore wait, and the database command and connection members; and no argument of the call carries a token.

StreamReader/StreamWriter’s own `ReadLineAsync/ReadToEndAsync/WriteAsync/WriteLineAsync/ FlushAsync are deliberately absent from that curated set even though they look like the rest of the stream family: their CancellationToken overloads were only added in .NET 7, so recommending them on the netstandard2.0/net462/net6-and-earlier targets still common in real projects would suggest an overload that does not exist there. The rule has no way to see the project’s target framework, so these members stay unreported rather than being wrong on some targets.

An argument counts as carrying a token when its type resolves to CancellationToken, when it reads a Token property, when it is a member of the CancellationToken type itself, when it is a bare default, or when it is named for the token parameter. Several of those are recognised structurally rather than by type, because a property type is only resolvable when the owning type is described in the metadata. The bias is deliberate: recognising a token too readily costs a missed report, while recognising it too rarely means telling someone to pass a token they already pass.

What this deliberately misses: the asynchronous LINQ operators of the common object-relational mapper are the other large family of cancellable calls, and they are not covered. They are extension methods whose receiver resolves to the queryable or to an application-declared context type, so the receiver never identifies the callee, and matching on the member name alone would report any application method that happens to share it. A token available as a field or a local rather than a parameter is not counted either — the parameter is what shows the method is part of a cancellable call chain.

using System.Threading;
using System.Threading.Tasks;

public class Retry
{
    public async Task PauseDropped(CancellationToken cancellationToken)
    {
        await Task.Delay(1000);   // FLAW — the delay runs to completion after cancellation
    }

    public async Task PauseForwarded(CancellationToken cancellationToken)
    {
        await Task.Delay(1000, cancellationToken);   // OK
    }

    public async Task PauseNoTokenInScope()
    {
        await Task.Delay(1000);   // OK, nothing to forward
    }
}

Remediation

Pass the token to the overload that accepts one. It is almost always the last parameter, and the overload exists precisely so that the token can travel down the call chain without changing anything else.

Then keep it travelling: every method on the path between the entry point that owns the token and the operation that can be cancelled should accept and forward it. A chain that drops the token halfway is as ineffective as one that never had it. Where an operation must not be cancelled — a compensating write, a cleanup step that has to finish — pass CancellationToken.None explicitly so that the decision is visible in the code rather than looking like an omission.

Cancellation only takes effect if somebody observes it, so the caller side matters too: catch the cancellation exception where the operation ends rather than letting it surface as an unhandled failure.