MongoClient.close() can leave a socket handle alive briefly when it races an in-flight Monitor heartbeat connection

XMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Trivial - P5
    • None
    • Affects Version/s: None
    • Component/s: SDAM
    • 2
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Environment

      • mongodb: 7.2.0 (installed; behavior confirmed still present on main at time of filing)
      • Node.js: v22.22.0
      • OS: Windows 11 Pro (10.0.26200)
      • Also observed via agenda 6.2.5, which uses the driver internally — root cause is entirely inside mongodb's SDAM monitor, not agenda.

      Summary When MongoClient.close() (→ Topology/Server/Monitor close) happens while Monitor.checkServer() has an in-flight initial connection, the resulting socket is destroyed once the promise resolves, but two things make this awkward for callers that need a guaranteed-clean process exit immediately after close():

      1. Handle lingers past close() resolving. In src/sdam/monitor.ts, lines 382-393 open a socket via an async IIFE (makeSocket → makeConnection → performInitialHandshake), and isInCloseState(monitor) is only checked in the resolved .then() at line 396 — after the socket already exists. If close() runs while this is in flight, connection.destroy() is called correctly, but the OS-level teardown isn't synchronous with that call, so process._getActiveHandles()/process.getActiveResourcesInfo() can still report the handle for a few seconds after MongoClient.close() has already resolved.
      2. Not visible to global.setTimeout instrumentation. Line 1: import { clearTimeout, setTimeout } from 'timers'; — MonitorRTTPinger, and MonitorInterval all schedule their timers via this direct module import (e.g. lines 451, 498, 528, 674), never via global.setTimeout. Applications that patch global.setTimeout to track and cancel library-created timers (a common technique for avoiding open-handle leaks in CLIs/test runners) cannot see or intercept these.

      Neither issue is destructive — close() does eventually clean everything up — but together they make it hard to guarantee zero lingering handles immediately after close() resolves.

      Steps to reproduce

      1. Connect a MongoClient to a real server (replica set makes heartbeats more frequent/visible).
      2. Trigger heartbeat activity (normal background monitoring is enough, or force it via client.db().command({ping:1}) in a loop) and call client.close() at a point that lands while a checkServer() connection is between "socket created" (line 383) and "resolved" (line 394).
      3. Immediately after await client.close() resolves, call process.getActiveResourcesInfo().
      4. On some runs, a socket handle is still present and disappears only after a further delay (observed up to a few seconds).

      Expected behavior Once MongoClient.close()'s returned promise resolves, no driver-owned handles (sockets or timers) should remain active — or the driver should make it possible for callers to know they're gone without introspecting process._getActiveHandles() themselves.

      Actual behavior A small number of sockets/timers can outlive the resolved close() promise by up to a few seconds, and are invisible to global.setTimeout-based leak tracking because src/sdam/monitor.ts imports setTimeout/clearTimeout directly from timers.

      Workaround currently in use Snapshot process._getActiveHandles() before calling close(), then unref() (not destroy) anything still present afterward, so it can't block process exit without touching in-flight driver state.

      Possible fix directions

      • Have Monitor.close()/resetMonitorState() track and await/cancel the in-flight IIFE in checkServer() (lines 382-393) instead of relying purely on the post-hoc isInCloseState() check in the .then().
      • No strong opinion on the direct timers import — likely deliberate isolation from user monkeypatching — but worth documenting since it silently defeats a common leak-tracking pattern.

            Assignee:
            Unassigned
            Reporter:
            Shrikant Patwari (EXT)
            None
            Votes:
            0 Vote for this issue
            Watchers:
            4 Start watching this issue

              Created:
              Updated: