-
Type:
Bug
-
Resolution: Done
-
Priority:
Major - P3
-
Affects Version/s: None
-
Component/s: None
-
None
-
None
-
Dotnet Drivers
-
None
-
None
-
None
-
None
-
None
-
None
Summary
DeferredAsyncCursor<T>.Dispose() leaks its forked IClientSessionHandle — and therefore an implicit server session (lsid) — when the cursor is disposed without ever being iterated.
Root cause
DeferredAsyncCursor.Dispose() runs its dispose action only inside an if (_cursor != null) guard, and _cursor is populated lazily on the first MoveNext/MoveNextAsync:
public void Dispose() { if (_cursor != null) // never populated if the cursor was never iterated { _disposeAction(); _cursor.Dispose(); _cursor = null; _disposed = true; } }
In the aggregate-to-collection ($out/$merge) flow, _disposeAction is () => forkedSession.Dispose() (see MongoCollectionImpl.CreateAggregateToCollectionResultCursor, MongoCollectionImpl.cs:796 / :1256, and MongoDatabase.cs:584). session.Fork() increments the reference count on the shared ReferenceCountedCoreSession; the skipped forkedSession.Dispose() means the count never returns to zero, the underlying ICoreSession is never disposed, and its server session is never returned to CoreServerSessionPool.
Impact
- One implicit server session leaked per affected call. The client-side pool mints new sessions instead of reusing the leaked one; the leaked lsid lingers server-side until logicalSessionTimeoutMinutes (default ~30 min).
- There are no finalizers on the session types, so the leaked session is never reclaimed to the pool — but GC still frees the managed objects and the server-side lsid times out. Net effect is session-pool churn + delayed server-side release, not unbounded managed-memory growth.
- Reachable through ordinary usage, and — critically — even with correct disposal: {
Unknown macro: {using (var cursor = collection.Aggregate(pipelineEndingIn$out)) { ... }}}
where the block doesn't iterate (early return, a false condition, or an exception before the first MoveNext) leaks the session despite deterministic disposal. (AggregateToCollection returns no cursor and is unaffected; iterating the cursor, e.g. ToList/ToListAsync, releases the session correctly.)
Reproduction
End-to-end (public API, live server). A single-connection client (MaxConnectionPoolSize = 1) reuses one implicit server session for every operation, so distinct lsid}}s on the {{aggregate command reveal leaks directly. Running a {{[
{ $out: ... }]}} aggregate 5 times via IMongoCollection.Aggregate/AggregateAsync and disposing the returned cursor each time:
| iterate before dispose | distinct aggregate lsids over 5 calls |
|---|---|
| no | 5 (a fresh session leaked every call) |
| yes | 1 (session returned to pool and reused) |
Identical on sync and async paths.
Unit (deterministic, no server). Using the real production types (ClientSessionHandle → CoreSessionHandle → ReferenceCountedCoreSession), forked exactly as the aggregate-to-collection flow does: dispose-without-iterate → underlying ICoreSession.Dispose() invoked 0 times; dispose-after-MoveNext → invoked once.
Proposed fix
Hoist _disposeAction() out of the if (_cursor != null) guard so it always runs on disposal (disposing a never-executed forked session is safe — it only decrements the reference count):
public void Dispose() { if (!_disposed) { _disposeAction(); _cursor?.Dispose(); _cursor = null; _disposed = true; } }
Acceptance criteria
- Disposing a DeferredAsyncCursor that was never iterated invokes the dispose action exactly once.
- Dispose is idempotent (no double-dispose).
- Regression tests (both the e2e distinct-lsid test and the unit ICoreSession.Dispose() count test) pass.
- Backported to the latest supported 3.x patch line.
Affected versions
Present in current 3.x and earlier — exact floor to be confirmed during triage.
Relationship to CSHARP-5630
CSHARP-5630 (add IAsyncDisposable to cursors, 4.0-only) adds a new DisposeAsync() to DeferredAsyncCursor, written leak-free from the start (dispose action runs unconditionally). This ticket fixes the existing synchronous Dispose() so the fix can be backported to 3.x independently of the 4.0-only interface break in CSHARP-5630.