Repository navigation
DbContext pooling recycles a context whose ConcurrencyDetector is stuck in the critical section; every later rent fails with "A second operation was started on this context instance" #38983
Description
Bug description
ConcurrencyDetector is not reset when a pooled DbContext is returned to the pool, and EnterCriticalSection/ExitCriticalSection toggle _inCriticalSection around an allocating AsyncLocal<int> write. If that write throws (in practice: OutOfMemoryException under memory pressure), the flag stays set, the context is returned to the pool as usual, and every subsequent operation on that instance — for the rest of the process lifetime — throws InvalidOperationException: A second operation was started on this context instance....
This turns one transient failure in one request into a permanent failure of the process. In our case a single low-traffic pod (the pool held one or two instances, so the poisoned one was rented almost every time) failed every database call for 45 minutes until it was redeployed: the original OOM hit one endpoint 24 times, the poisoned context produced ~1,000 errors on unrelated endpoints.
This is the mechanism behind #22802, which was closed with "OutOfMemoryException is not something that can be recovered from". Agreed that the request that hit OOM is lost; the ask here is different: the pool should not hand a known-bad instance to the next request, and the detector should not have a window where a failed allocation leaves it permanently locked. Both are cheap to fix and need no OOM handling.
Interlocked.CompareExchange(ref _inCriticalSection, 1, 0) has already run when ThreadAcquiredLocksCount.Value++ throws, so EnterCriticalSection never returns a disposer and nothing ever clears the flag. ExitCriticalSection has the mirror-image window: ThreadAcquiredLocksCount.Value-- (also an allocating write) runs before _inCriticalSection = 0.
Your code
The pooling half is deterministic and reproduces without OOM, using the standard AddDbContextPool + scope-per-request setup:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddDbContextPool<BlogContext>(o => o.UseSqlite("Data Source=repro.db"));
await using var provider = services.BuildServiceProvider();
using (var scope = provider.CreateScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<BlogContext>();
ctx.Database.EnsureDeleted();
ctx.Database.EnsureCreated();
}
var first = await Request1();
await Request2(first);
// Separate async method: AsyncLocal changes made here do not flow back to the caller,
// so Request2 runs on a fresh flow like a real next request would.
async Task<BlogContext> Request1()
{
using var scope = provider.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<BlogContext>();
await ctx.Blogs.ToListAsync();
// Simulates EnterCriticalSection succeeding but the matching Exit never running
// (what happens when the AsyncLocal write inside Enter/Exit throws).
ctx.GetService<IConcurrencyDetector>().EnterCriticalSection(); // disposer dropped
return ctx;
} // scope disposed -> context returned to pool; ResetState() does not touch ConcurrencyDetector
async Task Request2(BlogContext first)
{
using var scope = provider.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<BlogContext>();
Console.WriteLine($"same instance: {ReferenceEquals(first, ctx)}"); // True
await ctx.Blogs.ToListAsync(); // InvalidOperationException: A second operation was started on this context instance...
}
public class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
}
public class Blog { public int Id { get; set; } }Note the two requests must run on different async flows (separate async methods, or ExecutionContext.SuppressFlow()); on the same flow the leaked ThreadAcquiredLocksCount makes the detector treat the second call as re-entrant and the bug is masked. That is probably why this was hard to reproduce in #22802.
Stack traces
The other half (how the disposer gets dropped in production) is the stack trace from #22802 and from our incident:
System.OutOfMemoryException
at System.Threading.AsyncLocalValueMap.MultiElementAsyncLocalValueMap.Set(IAsyncLocal key, Object value, Boolean treatNullValueAsNonexistent)
at System.Threading.ExecutionContext.SetLocalValue(IAsyncLocal local, Object newValue, Boolean needChangeNotifications)
at System.Threading.AsyncLocal`1.set_Value(T value)
at Microsoft.EntityFrameworkCore.Infrastructure.Internal.ConcurrencyDetector.EnterCriticalSection()
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
Verbose output
No response
EF Core version
10.0.9 (also verified against current main; ConcurrencyDetector is unchanged since 8.x)
Database provider
Microsoft.EntityFrameworkCore.SqlServer (repro above uses Sqlite)
Target framework
.NET 10
Operating system
Linux (Kubernetes); #22802 reported the same on Windows
IDE
No response
Proposed fix
Two small, allocation-free changes; no behaviour change on the happy path:
ConcurrencyDetectorimplementsIResettableServiceandStateManager.ResetState()resets it (same way it already resets_changeDetector), so a pooled context always starts its next lease clean.- Reorder the bookkeeping so the flag is only kept if the AsyncLocal write succeeds, and cleared before the AsyncLocal write on exit:
public virtual ConcurrencyDetectorCriticalSectionDisposer EnterCriticalSection()
{
if (Interlocked.CompareExchange(ref _inCriticalSection, 1, 0) == 1
&& ThreadAcquiredLocksCount.Value == 0)
{
throw new InvalidOperationException(CoreStrings.ConcurrentMethodInvocation);
}
try
{
ThreadAcquiredLocksCount.Value++;
}
catch
{
if (_currentContextRefCount == 0)
{
_inCriticalSection = 0;
}
throw;
}
_currentContextRefCount++;
return new ConcurrencyDetectorCriticalSectionDisposer(this);
}
public virtual void ExitCriticalSection()
{
if (--_currentContextRefCount == 0)
{
_inCriticalSection = 0;
}
ThreadAcquiredLocksCount.Value--;
}I have this implemented with tests (pooled context reusable after a leaked section — red before, green after; EFCore.Tests fully green) and can open a PR if the team is open to it.
Activity
AndriySvyryd commentedon Sep 14, 2026
@LuKuanTsen While this is still an unsupported scenario the proposed fix is reasonable, so go ahead. Just add a comment new the new try/catch explaining its purpose
LuKuanTsen commentedon Sep 15, 2026
One design note on the "reset on return to pool" half, since there are two ways to wire it and I'd like the team's preference before the PR is reviewed.
A. Reset from
StateManager(what the PR does)ConcurrencyDetectorimplementsIResettableService, andStateManager.ResetState()calls it the same way it already resets_changeDetector:No public API change, so the same diff can be backported to
release/10.0if the fix qualifies for servicing. Downside: the cast probes for a capability thatIConcurrencyDetectordoes not declare, so a replacedIConcurrencyDetectorimplementation would not be reset.B. Register it like the other resettable services
This is the pattern the rest of the codebase uses (
IStateManager,IDbContextTransactionManager,ILazyLoaderFactoryall extendIResettableServiceand are registered inEntityFrameworkServicesBuilder):StateManageris then untouched andDbContext.ResetState()picks the detector up throughGetResettableServices(). Cleaner, but adding a base interface to the publicIConcurrencyDetectoris a compile-time break for anyone implementing it, so it is main/EF 11 only. I have this variant implemented and green as well (EntityFrameworkServicesBuilderTestneeds its resettable-service count bumped from 4 to 5); happy to switch the PR to it if you prefer, and keep A only for a servicing PR.The
EnterCriticalSection/ExitCriticalSectionhalf is the same in both.Reset ConcurrencyDetector when a pooled context is returned (#38995)
Reset ConcurrencyDetector when a pooled context is returned (#38995)