DeferredAsyncCursor leaks a server session when disposed without iterating

XMLWordPrintableJSON

    • Type: Bug
    • Resolution: Done
    • Priority: Major - P3
    • 4.0.0
    • 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 (ClientSessionHandleCoreSessionHandleReferenceCountedCoreSession), 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.

            Assignee:
            Adelin Mbida Owona
            Reporter:
            Adelin Mbida Owona
            None
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated:
              Resolved: