DI021

Non-Thread-Safe Service Shared Across Concurrent Handler Invocations

A documented non-thread-safe service (EF Core DbContext and derived contexts, DbConnection/DbCommand/DbTransaction/DbDataReader and their interfaces, IDbContextTransaction, HttpContext) created or resolved once and then captured — through a field, a closure over an outer local, or an enclosing method parameter — into a handler that a framework invokes concurrently: ServiceBusProcessor/ServiceBusSessionProcessor message and error handlers, EventProcessorClient event handlers, RabbitMQ EventingBasicConsumer.Received/AsyncEventingBasicConsumer.Received/ReceivedAsync consumer handlers (instance-correlated through the consumer's own factory/connection/channel chain: proven ConsumerDispatchConcurrency above 1 warns, proven 1 or a fresh default factory stays silent, untraceable chains stay config-gated; fallback constants must bind to the real RabbitMQ property), System.Threading.Timer callbacks with a finite period, System.Timers.Timer.Elapsed, Parallel.For/ForEach/ForEachAsync/Invoke bodies, PLINQ ForAll bodies (sequential only when WithDegreeOfParallelism(1) is proven on the query chain), TPL Dataflow ActionBlock/TransformBlock/TransformManyBlock delegates (sequential by default; reported when MaxDegreeOfParallelism is provably above 1, config-gated DI022 when the options are unprovable), and EventProcessor<TPartition> batch/error overrides (the override body is the handler; partitions run concurrently). Resolving from a long-lived scope captured from outside the handler is reported too — it hands the same instance to every concurrent invocation. Both generic requests and exact framework non-generic IServiceProvider.GetService(typeof(T)) / GetRequiredService(typeof(T)) requests participate; direct-static calls bind the provider by declared parameter only for exact framework extension containers, and concrete provider implementations bind the System.Type contract parameter regardless of its source name. Generic service-looking calls must bind to the exact framework extension symbol; same-named user-defined generic helpers remain silent. The adversarial boundary is recorded in docs/adversarial/DI021.md. Built-in identity, reference, and boxing conversions remain transparent while tracing provider origins, preserving coverage for captured value-type and IServiceProvider-constrained providers; cyclic constraint graphs in temporarily invalid source are bounded by symbol identity. Runtime Type values, user-defined conversions on the requested type or provider receiver, and user-defined same-named helpers remain conservative and silent.

Warning Default severity · Code fix: Yes

Why it matters

This is the deferred form of the captive dependency. The lifetimes can look correct, but one instance is shared across overlapping invocations and fails at runtime ("A second operation was started on this context instance before a previous operation completed"). It works in development with one message at a time and fails under production load.

One pencil shared by the whole class works fine while pupils write one at a time. The moment everyone writes at once, the pencil snaps.

README problem example

public class OrderProcessor : BackgroundService
{
    private readonly AppDbContext _db; // resolved once

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _processor.ProcessMessageAsync += HandleAsync; // invoked concurrently
        await _processor.StartProcessingAsync(stoppingToken);
    }

    private async Task HandleAsync(ProcessSessionMessageEventArgs args)
    {
        _db.Add(args);                // one DbContext, N concurrent handlers
        await _db.SaveChangesAsync();
    }
}

README better pattern

private async Task HandleAsync(ProcessSessionMessageEventArgs args)
{
    await using var scope = _scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Add(args);
    await db.SaveChangesAsync();
}

Yes. Rewrites the handler to resolve the service from a new scope per invocation, plumbs IServiceScopeFactory through the constructor when needed, and removes the now-dead captured field. The plumbing stays deliberate where a rewrite could break the build or runtime: partial types (a constructor or field reference may live in another part), multiple or expression-bodied constructors, and constructors whose parameters or locals already use the scopeFactory name are left diagnostic-only.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app concurrent capture warning

    public class Bad_TimerCallbackSharedConnection
    {
        private readonly DbConnection _connection;
        private Timer? _timer;

        public Bad_TimerCallbackSharedConnection(DbConnection connection)
        {
            _connection = connection;
        }

        public void Start()
        {
            _timer = new Timer(Poll, null, 0, 5000);
        }

        private void Poll(object? state)
        {
            // [DI021] '_connection' is shared across concurrent invocations of System.Threading.Timer callbacks
            var command = _connection.CreateCommand();
            command.CommandText = "SELECT 1";
            command.ExecuteNonQuery();
        }
    }

Sample app per-invocation pattern

    public class Good_ConnectionPerInvocation
    {
        private readonly Func<DbConnection> _connectionFactory;
        private Timer? _timer;

        public Good_ConnectionPerInvocation(Func<DbConnection> connectionFactory)
        {
            _connectionFactory = connectionFactory;
        }

        public void Start()
        {
            _timer = new Timer(Poll, null, 0, 5000);
        }

        private void Poll(object? state)
        {
            // Create the non-thread-safe resource per invocation instead of sharing one instance.
            using var connection = _connectionFactory();
            using var command = connection.CreateCommand();
            command.CommandText = "SELECT 1";
            command.ExecuteNonQuery();
        }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules