-
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():
- 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.
- Not visible to global.setTimeout instrumentation. Line 1: import { clearTimeout, setTimeout } from 'timers'; — Monitor, RTTPinger, 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
- Connect a MongoClient to a real server (replica set makes heartbeats more frequent/visible).
- 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).
- Immediately after await client.close() resolves, call process.getActiveResourcesInfo().
- 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.