Skip to content

Microsoft.Data.Sqlite: the pool can hand one connection to two openers while it grows (race between Activate and ReclaimLeakedConnections) #39253

Closed as not planned

Description

Bug description

Under concurrent opens of a pooled connection string, the connection pool of Microsoft.Data.Sqlite can hand one SqliteConnectionInternal (one sqlite3* handle) to two SqliteConnection objects that are open at the same time. One opener's statements then run on the other's connection; with EF Core, the functions EF registers on open are registered again while the other opener has a statement running, and the open fails with:

SQLite Error 5: 'unable to delete/modify user-function due to active statements'.

This may be the cause of #36591 and #37668 (same message, both closed for lack of a repro). Below are the root cause in the 10.0.12 code and a small program that reproduces it reliably.

Root cause (decompiled 10.0.12)

SqliteConnectionFactory.GetConnection(outerConnection):

SqliteConnectionInternal obj = pool == null ? new SqliteConnectionInternal(...) : pool.GetConnection();
obj.Activate(outerConnection);   // outside the pool's lock

SqliteConnectionInternal.Activate:

_active = true;
_outerConnection.SetTarget(outerConnection);

SqliteConnectionInternal.Leaked is _active && !_outerConnection.TryGetTarget(out _).

Between the two statements of Activate, the connection is Leaked == true. A second opener that finds both stacks empty and an even Count calls ReclaimLeakedConnections() inside SqliteConnectionPool.GetConnection, sees this connection as leaked, Returns it to _warmPool and pops it on its next loop iteration, while the first opener finishes Activate on it. Both now hold the same inner connection.

Deactivate does not have the same window: it runs inside Return, under the same lock that ReclaimLeakedConnections takes to evaluate Leaked.

Suggested fix

Set the owner before marking the connection active, so it is never "active without an owner":

public void Activate(SqliteConnection outerConnection)
{
    _outerConnection.SetTarget(outerConnection);
    _active = true;
}

(or call Activate inside the pool's lock).

Your code

A console app (net10.0, Microsoft.Data.Sqlite 10.0.12). It opens a pooled connection string from many threads at once against a pool that has to grow, and counts how often two connections that are open at the same time share one handle. dotnet run -c Release -- 3000 64 opens in parallel; adding serialised takes a semaphore around Open() only.

using System.Collections.Concurrent;
using Microsoft.Data.Sqlite;

// Opens a pooled connection string from many threads at once, against a pool that has to grow, and checks
// whether two SqliteConnection objects that are open AT THE SAME TIME share one sqlite3 handle.
var path = Path.Combine(Path.GetTempPath(), $"poolrace-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString();
var threads = args.Length > 1 ? int.Parse(args[1]) : 32;
var rounds = args.Length > 0 ? int.Parse(args[0]) : 500;
int shared = 0, errors = 0;
var gate = args.Length > 2 && args[2] == "serialised" ? new SemaphoreSlim(1, 1) : null;

for (var round = 0; round < rounds; round++)
{
    SqliteConnection.ClearAllPools();
    var held = new ConcurrentDictionary<nint, int>();
    using var barrier = new Barrier(threads);
    var workers = Enumerable.Range(0, threads).Select(_ => Task.Factory.StartNew(() =>
    {
        barrier.SignalAndWait();
        try
        {
            using var connection = new SqliteConnection(connectionString);
            gate?.Wait();
            try { connection.Open(); } finally { gate?.Release(); }
            // A function is registered on open, as EF Core does for its own functions.
            connection.CreateFunction("probe", (long x) => x);
            var handle = connection.Handle!.DangerousGetHandle();
            if (held.AddOrUpdate(handle, 1, (_, n) => n + 1) > 1)
            {
                Interlocked.Increment(ref shared);
            }
            try
            {
                using var command = connection.CreateCommand();
                command.CommandText = "SELECT probe(1)";
                command.ExecuteScalar();
            }
            finally
            {
                // Released before the connection goes back to the pool, so a later legitimate reuse never counts.
                held.AddOrUpdate(handle, 0, (_, n) => n - 1);
            }
        }
        catch (SqliteException e)
        {
            Interlocked.Increment(ref errors);
            if (errors <= 3) Console.WriteLine($"round {round}: {e.Message}");
        }
    }, TaskCreationOptions.LongRunning)).ToArray();
    Task.WaitAll(workers);
}

Console.WriteLine($"{(gate is null ? "opens in parallel" : "opens serialised")}: {rounds} rounds x {threads} threads: {shared} times two open connections shared one handle, {errors} SQLite errors");
SqliteConnection.ClearAllPools();
File.Delete(path);
return shared > 0 ? 1 : 0;

Results on a 16-core Windows 11 machine:

opens in parallel: 3000 rounds x 64 threads: 142 times two open connections shared one handle, 23 SQLite errors
opens serialised:  3000 rounds x 64 threads:   0 times two open connections shared one handle,  0 SQLite errors

The errors seen with parallel opens include database is locked and no such function: probe (the function registered by one opener disappears under the other). None appear when only Open() is serialised. The work after opening runs in parallel in both modes.

We hit it in the test suite of an ASP.NET Core app (concurrent requests, Windows); the open failed in SqliteConnection.Open() called from RelationalConnection.OpenInternal, as in #36591. As a workaround we use a DbConnectionInterceptor that serialises Open/OpenAsync per connection string; the work after opening stays parallel.

Provider and version information

Microsoft.Data.Sqlite / Microsoft.Data.Sqlite.Core 10.0.12 (also EF Core 10.0.12 on top)
Target framework: .NET 10.0
Operating system: Windows 11 (the program above); the code path is platform-independent

Activity

  1. MihuBot commented on Oct 14, 2026

    @MihuBot

    I'm a bot. Here is a possible related and/or duplicate issue (I may be wrong):

  2. PatrizioRicciardelloAtSqlService commented on Oct 14, 2026

    @PatrizioRicciardelloAtSqlService
    Author

    Duplicate of #39008, which is fixed in Microsoft.Data.Sqlite 10.0.13 (Activate now sets the owner before _active). Sorry for the noise - my search missed it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions