DI023

Fire-and-Forget Background Work Captures a Scope

A using scope, a local bound to its ServiceProvider, or any local resolved from it, captured by background work started with Task.Run or TaskFactory.StartNew whose task is thrown away — an expression statement, a _ = discard, or a finite/cancelable Wait(...) whose Boolean result is stored or returned while the task may continue.

Warning Default severity · Code fix: No

Why it matters

using disposes the scope the instant the starting method returns, which for a discarded task is almost always before the work has finished. The background work then resolves from, or calls into, a disposed scope: ObjectDisposedException at best, and at worst a service that quietly operates on torn-down state such as a closed DbContext connection. The failure is timing-dependent, so it passes locally and fails under load.

README problem example

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

    _ = Task.Run(async () => await archiver.ArchiveAsync(orderId));  // DI023
}   // <- scope disposed here, while ArchiveAsync is still running

README better pattern

public void Handle(int orderId)
{
    _ = Task.Run(async () =>
    {
        using var scope = _scopeFactory.CreateScope();
        var archiver = scope.ServiceProvider.GetRequiredService<IOrderArchiver>();
        await archiver.ArchiveAsync(orderId);
    });
}

No — the repair is a design choice between awaiting the task and moving scope creation inside the background work, and the two produce different execution semantics.

Guardrails

When DI023 stays silent

Capture tracking follows any number of hops (scopeproviderservice) and covers method groups and delegate locals, not just inline lambdas. Values that cannot hold the scope's graph stay quiet: primitives, enums, and strings derived from it (scope.GetHashCode()), a local proved to be reassigned to something not scope-derived before the capture (same-block dominance; a branch-only or conditionally evaluated overwrite still reports), assignment targets, and nameof(service). The scope must be disposed by a using in the same method — an undisposed scope has no proven teardown point here and is DI001's finding instead. A task that is awaited, returned, stored in a local, or waited on to guaranteed completion (parameterless .Wait(), infinite timeout with a non-cancelable token, .GetAwaiter().GetResult()) keeps the frame alive and stays silent. A framework Task.Wait with a finite timeout or cancelable token does not, even when its Boolean result is stored or returned, since either exit can leave the task running; arguments are bound to their parameters, so named and reordered forms classify correctly. User-defined extension methods named Wait and background work that captures nothing scope-derived stay silent.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app fire-and-forget scope-capture warning

    public sealed class Bad_FireAndForgetScopeCapture
    {
        private readonly IServiceScopeFactory _scopeFactory;

        public Bad_FireAndForgetScopeCapture(IServiceScopeFactory scopeFactory)
        {
            _scopeFactory = scopeFactory;
        }

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

            // DI023: the background work outlives the scope it resolved from.
            _ = Task.Run(async () => await archiver.ArchiveAsync(orderId));
        }
    }

Sample app scope-inside-background-work pattern

    public sealed class Good_ScopeInsideBackgroundWork
    {
        private readonly IServiceScopeFactory _scopeFactory;

        public Good_ScopeInsideBackgroundWork(IServiceScopeFactory scopeFactory)
        {
            _scopeFactory = scopeFactory;
        }

        public void Handle(int orderId)
        {
            _ = Task.Run(async () =>
            {
                await using var scope = _scopeFactory.CreateAsyncScope();
                var archiver = scope.ServiceProvider.GetRequiredService<IOrderArchiver>();
                await archiver.ArchiveAsync(orderId);
            });
        }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules