DI037

Un-awaited Task Escapes The Scope That Created It

A task started on a service resolved from a using service scope and then allowed to leave that scope without being awaited — returned to the caller, discarded with _ = or as a bare statement, assigned to a field or property, or collected into a list declared outside the scope to be awaited after it ends.

Warning Default severity · Code fix: No

Why it matters

Disposing a scope disposes every scoped service it created. A task still running when the scope ends is work operating on services that have already been torn down: an ObjectDisposedException from a DbContext whose connection was closed underneath it, a half-written unit of work, or — worst of all — a silent partial success. The failure is timing-dependent, so short work passes locally and long work fails under load.

You cannot hand someone a cake and then take the oven away while it is still baking.

README problem example

public Task Dispatch(int orderId)
{
    using var scope = _scopeFactory.CreateScope();
    var archiver = scope.ServiceProvider.GetRequiredService<IOrderArchiver>();

    return archiver.ArchiveAsync(orderId);  // DI037: scope disposed as this returns
}

README better pattern

public async Task Dispatch(int orderId)
{
    using var scope = _scopeFactory.CreateScope();
    var archiver = scope.ServiceProvider.GetRequiredService<IOrderArchiver>();

    await archiver.ArchiveAsync(orderId);
}

No — the repair is a choice between awaiting inside the scope, making the caller own the scope, and giving the background work a scope of its own, and only the author knows which of the three the surrounding code can support.

Guardrails

When DI037 stays silent

The scope must be disposed by the body that starts the work — a using declaration or a using statement — because that is what fixes the moment of teardown; a scope without one has no proven disposal point here and is DI001's finding instead. The receiver must be scope-derived: the scope's ServiceProvider, a service resolved through it, or a local that holds either, grown transitively and dropped entirely if any of those locals is reassigned or passed by ref/out. The call must hand back a Task or ValueTask; a synchronous call finishes inside the scope by definition. A task consumed where it stands is not reported — await, await ... .ConfigureAwait(false), .GetAwaiter().GetResult(), .Wait(), an await of the Task.WhenAll it was passed to, or a wait on anything reached from the service, such as a completion property it exposes — and neither is one whose fate this rule cannot name, such as a local declared inside the scope. Work that finishes before it is handed back is not reported either: a body with no await on the path taken, or whose awaits are all of work already over — Task.CompletedTask, Task.FromResult, Task.Delay(0), Task.WhenAll of finished tasks, Task.WhenAny where one is finished, a local or readonly field holding any of those, or an await a preceding IsCompleted check has already settled — because such a call is done before the scope closes. A true or false argument counts here too: a guard clause the call site's own literal sends the body out of is a path with no await on it. Work started inside a lambda, a local function, or a query clause is skipped: a delegate runs when its consumer chooses, and background work started with Task.Run is DI023's finding rather than this rule's. Accepted false negatives: a task whose escape route runs through a helper method, a service resolved from a scope created in another method, and a scope-resolved singleton, which the scope does not own and therefore does not dispose. Accepted false positives: a token cancelled before the call, which leaves the work faulted at its first check but reads here as ordinary escaping work; and a join signalled through something this rule cannot connect back to the task, such as a ManualResetEventSlim the service sets when its work ends, still reads as an escape — waiting on an unrelated handle is far commoner than hand-rolled completion signalling, and treating every later wait as the join would silence real findings.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app un-awaited task note

        public Task ArchiveBad(int orderId)
        {
            using var scope = _scopeFactory.CreateScope();
            var archiver = scope.ServiceProvider.GetRequiredService<IOrderArchiver>();

            // DI037: the scope is disposed as this returns, tearing down the archiver while the
            // task it handed back is still running.
            return archiver.ArchiveAsync(orderId);
        }

Sample app await-inside-scope pattern

        public async Task ArchiveGood(int orderId)
        {
            await using var scope = _scopeFactory.CreateAsyncScope();
            var archiver = scope.ServiceProvider.GetRequiredService<IOrderArchiver>();

            // Awaiting inside the scope keeps the archiver alive for the whole of the work.
            await archiver.ArchiveAsync(orderId);
        }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules