Repository navigation
Microsoft.Data.Sqlite: the pool can hand one connection to two openers while it grows (race between Activate and ReclaimLeakedConnections) #39253
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 lockSqliteConnectionInternal.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
MihuBot commentedon Oct 14, 2026
I'm a bot. Here is a possible related and/or duplicate issue (I may be wrong):
PatrizioRicciardelloAtSqlService commentedon Oct 14, 2026
Duplicate of #39008, which is fixed in Microsoft.Data.Sqlite 10.0.13 (
Activatenow sets the owner before_active). Sorry for the noise - my search missed it.