Skip to content

Commit 69de253

Browse files
authored
fix(NODE-5993): memory leak in the Connection class (#4022)
1 parent 28b7040 commit 69de253

File tree

5 files changed

+169
-59
lines changed

5 files changed

+169
-59
lines changed

src/cmap/connection.ts

+25-43
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { type Readable, Transform, type TransformCallback } from 'stream';
22
import { clearTimeout, setTimeout } from 'timers';
3-
import { promisify } from 'util';
43

54
import type { BSONSerializeOptions, Document, ObjectId } from '../bson';
65
import type { AutoEncrypter } from '../client-side-encryption/auto_encrypter';
@@ -37,7 +36,7 @@ import {
3736
maxWireVersion,
3837
type MongoDBNamespace,
3938
now,
40-
promiseWithResolvers,
39+
once,
4140
uuidV4
4241
} from '../utils';
4342
import type { WriteConcern } from '../write_concern';
@@ -182,18 +181,18 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
182181
* Once connection is established, command logging can log events (if enabled)
183182
*/
184183
public established: boolean;
184+
/** Indicates that the connection (including underlying TCP socket) has been closed. */
185+
public closed = false;
185186

186187
private lastUseTime: number;
187188
private clusterTime: Document | null = null;
189+
private error: Error | null = null;
190+
private dataEvents: AsyncGenerator<Buffer, void, void> | null = null;
188191

189192
private readonly socketTimeoutMS: number;
190193
private readonly monitorCommands: boolean;
191194
private readonly socket: Stream;
192-
private readonly controller: AbortController;
193-
private readonly signal: AbortSignal;
194195
private readonly messageStream: Readable;
195-
private readonly socketWrite: (buffer: Uint8Array) => Promise<void>;
196-
private readonly aborted: Promise<never>;
197196

198197
/** @event */
199198
static readonly COMMAND_STARTED = COMMAND_STARTED;
@@ -213,6 +212,7 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
213212
constructor(stream: Stream, options: ConnectionOptions) {
214213
super();
215214

215+
this.socket = stream;
216216
this.id = options.id;
217217
this.address = streamIdentifier(stream, options);
218218
this.socketTimeoutMS = options.socketTimeoutMS ?? 0;
@@ -225,39 +225,12 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
225225
this.generation = options.generation;
226226
this.lastUseTime = now();
227227

228-
this.socket = stream;
229-
230-
// TODO: Remove signal from connection layer
231-
this.controller = new AbortController();
232-
const { signal } = this.controller;
233-
this.signal = signal;
234-
const { promise: aborted, reject } = promiseWithResolvers<never>();
235-
aborted.then(undefined, () => null); // Prevent unhandled rejection
236-
this.signal.addEventListener(
237-
'abort',
238-
function onAbort() {
239-
reject(signal.reason);
240-
},
241-
{ once: true }
242-
);
243-
this.aborted = aborted;
244-
245228
this.messageStream = this.socket
246229
.on('error', this.onError.bind(this))
247230
.pipe(new SizedMessageTransform({ connection: this }))
248231
.on('error', this.onError.bind(this));
249232
this.socket.on('close', this.onClose.bind(this));
250233
this.socket.on('timeout', this.onTimeout.bind(this));
251-
252-
const socketWrite = promisify(this.socket.write.bind(this.socket));
253-
this.socketWrite = async buffer => {
254-
return Promise.race([socketWrite(buffer), this.aborted]);
255-
};
256-
}
257-
258-
/** Indicates that the connection (including underlying TCP socket) has been closed. */
259-
public get closed(): boolean {
260-
return this.signal.aborted;
261234
}
262235

263236
public get hello() {
@@ -308,7 +281,7 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
308281
this.lastUseTime = now();
309282
}
310283

311-
public onError(error?: Error) {
284+
public onError(error: Error) {
312285
this.cleanup(error);
313286
}
314287

@@ -351,13 +324,15 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
351324
*
352325
* This method does nothing if the connection is already closed.
353326
*/
354-
private cleanup(error?: Error): void {
327+
private cleanup(error: Error): void {
355328
if (this.closed) {
356329
return;
357330
}
358331

359332
this.socket.destroy();
360-
this.controller.abort(error);
333+
this.error = error;
334+
this.dataEvents?.throw(error).then(undefined, () => null); // squash unhandled rejection
335+
this.closed = true;
361336
this.emit(Connection.CLOSE);
362337
}
363338

@@ -598,7 +573,7 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
598573
}
599574

600575
private throwIfAborted() {
601-
this.signal.throwIfAborted();
576+
if (this.error) throw this.error;
602577
}
603578

604579
/**
@@ -621,7 +596,8 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
621596

622597
const buffer = Buffer.concat(await finalCommand.toBin());
623598

624-
return this.socketWrite(buffer);
599+
if (this.socket.write(buffer)) return;
600+
return once(this.socket, 'drain');
625601
}
626602

627603
/**
@@ -634,13 +610,19 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
634610
* Note that `for-await` loops call `return` automatically when the loop is exited.
635611
*/
636612
private async *readMany(): AsyncGenerator<OpMsgResponse | OpQueryResponse> {
637-
for await (const message of onData(this.messageStream, { signal: this.signal })) {
638-
const response = await decompressResponse(message);
639-
yield response;
613+
try {
614+
this.dataEvents = onData(this.messageStream);
615+
for await (const message of this.dataEvents) {
616+
const response = await decompressResponse(message);
617+
yield response;
640618

641-
if (!response.moreToCome) {
642-
return;
619+
if (!response.moreToCome) {
620+
return;
621+
}
643622
}
623+
} finally {
624+
this.dataEvents = null;
625+
this.throwIfAborted();
644626
}
645627
}
646628
}

src/cmap/wire_protocol/on_data.ts

+2-16
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@ type PendingPromises = Omit<
1616
* https://nodejs.org/api/events.html#eventsonemitter-eventname-options
1717
*
1818
* Returns an AsyncIterator that iterates each 'data' event emitted from emitter.
19-
* It will reject upon an error event or if the provided signal is aborted.
19+
* It will reject upon an error event.
2020
*/
21-
export function onData(emitter: EventEmitter, options: { signal: AbortSignal }) {
22-
const signal = options.signal;
23-
21+
export function onData(emitter: EventEmitter) {
2422
// Setup pending events and pending promise lists
2523
/**
2624
* When the caller has not yet called .next(), we store the
@@ -89,19 +87,8 @@ export function onData(emitter: EventEmitter, options: { signal: AbortSignal })
8987
emitter.on('data', eventHandler);
9088
emitter.on('error', errorHandler);
9189

92-
if (signal.aborted) {
93-
// If the signal is aborted, set up the first .next() call to be a rejection
94-
queueMicrotask(abortListener);
95-
} else {
96-
signal.addEventListener('abort', abortListener, { once: true });
97-
}
98-
9990
return iterator;
10091

101-
function abortListener() {
102-
errorHandler(signal.reason);
103-
}
104-
10592
function eventHandler(value: Buffer) {
10693
const promise = unconsumedPromises.shift();
10794
if (promise != null) promise.resolve({ value, done: false });
@@ -119,7 +106,6 @@ export function onData(emitter: EventEmitter, options: { signal: AbortSignal })
119106
// Adding event handlers
120107
emitter.off('data', eventHandler);
121108
emitter.off('error', errorHandler);
122-
signal.removeEventListener('abort', abortListener);
123109
finished = true;
124110
const doneResult = { value: undefined, done: finished } as const;
125111

src/utils.ts

+25
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as crypto from 'crypto';
22
import type { SrvRecord } from 'dns';
3+
import { type EventEmitter } from 'events';
34
import * as http from 'http';
45
import { clearTimeout, setTimeout } from 'timers';
56
import * as url from 'url';
@@ -1295,3 +1296,27 @@ export function promiseWithResolvers<T>() {
12951296
}
12961297

12971298
export const randomBytes = promisify(crypto.randomBytes);
1299+
1300+
/**
1301+
* Replicates the events.once helper.
1302+
*
1303+
* Removes unused signal logic and It **only** supports 0 or 1 argument events.
1304+
*
1305+
* @param ee - An event emitter that may emit `ev`
1306+
* @param name - An event name to wait for
1307+
*/
1308+
export async function once<T>(ee: EventEmitter, name: string): Promise<T> {
1309+
const { promise, resolve, reject } = promiseWithResolvers<T>();
1310+
const onEvent = (data: T) => resolve(data);
1311+
const onError = (error: Error) => reject(error);
1312+
1313+
ee.once(name, onEvent).once('error', onError);
1314+
try {
1315+
const res = await promise;
1316+
ee.off('error', onError);
1317+
return res;
1318+
} catch (error) {
1319+
ee.off(name, onEvent);
1320+
throw error;
1321+
}
1322+
}

test/integration/connection-monitoring-and-pooling/connection.test.ts

+84
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { expect } from 'chai';
2+
import { type EventEmitter, once } from 'events';
3+
import * as sinon from 'sinon';
4+
import { setTimeout } from 'timers';
25

36
import {
47
addContainerMetadata,
8+
Binary,
59
connect,
610
Connection,
711
type ConnectionOptions,
@@ -15,7 +19,9 @@ import {
1519
ServerHeartbeatStartedEvent,
1620
Topology
1721
} from '../../mongodb';
22+
import * as mock from '../../tools/mongodb-mock/index';
1823
import { skipBrokenAuthTestBeforeEachHook } from '../../tools/runner/hooks/configuration';
24+
import { getSymbolFrom, sleep } from '../../tools/utils';
1925
import { assert as test, setupDatabase } from '../shared';
2026

2127
const commonConnectOptions = {
@@ -200,6 +206,84 @@ describe('Connection', function () {
200206
client.connect();
201207
});
202208

209+
context(
210+
'when a large message is written to the socket',
211+
{ requires: { topology: 'single', auth: 'disabled' } },
212+
() => {
213+
let client, mockServer: import('../../tools/mongodb-mock/src/server').MockServer;
214+
215+
beforeEach(async function () {
216+
mockServer = await mock.createServer();
217+
218+
mockServer
219+
.addMessageHandler('insert', req => {
220+
setTimeout(() => {
221+
req.reply({ ok: 1 });
222+
}, 800);
223+
})
224+
.addMessageHandler('hello', req => {
225+
req.reply(Object.assign({}, mock.HELLO));
226+
})
227+
.addMessageHandler(LEGACY_HELLO_COMMAND, req => {
228+
req.reply(Object.assign({}, mock.HELLO));
229+
});
230+
231+
client = new MongoClient(`mongodb://${mockServer.uri()}`, {
232+
minPoolSize: 1,
233+
maxPoolSize: 1
234+
});
235+
});
236+
237+
afterEach(async function () {
238+
await client.close();
239+
mockServer.destroy();
240+
sinon.restore();
241+
});
242+
243+
it('waits for an async drain event because the write was buffered', async () => {
244+
const connectionReady = once(client, 'connectionReady');
245+
await client.connect();
246+
await connectionReady;
247+
248+
// Get the only connection
249+
const pool = [...client.topology.s.servers.values()][0].pool;
250+
251+
const connections = pool[getSymbolFrom(pool, 'connections')];
252+
expect(connections).to.have.lengthOf(1);
253+
254+
const connection = connections.first();
255+
const socket: EventEmitter = connection.socket;
256+
257+
// Spy on the socket event listeners
258+
const addedListeners: string[] = [];
259+
const removedListeners: string[] = [];
260+
socket
261+
.on('removeListener', name => removedListeners.push(name))
262+
.on('newListener', name => addedListeners.push(name));
263+
264+
// Make server sockets block
265+
for (const s of mockServer.sockets) s.pause();
266+
267+
const insert = client
268+
.db('test')
269+
.collection('test')
270+
// Anything above 16Kb should work I think (10mb to be extra sure)
271+
.insertOne({ a: new Binary(Buffer.alloc(10 * (2 ** 10) ** 2), 127) });
272+
273+
// Sleep a bit and unblock server sockets
274+
await sleep(10);
275+
for (const s of mockServer.sockets) s.resume();
276+
277+
// Let the operation finish
278+
await insert;
279+
280+
// Ensure that we used the drain event for this write
281+
expect(addedListeners).to.deep.equal(['drain', 'error']);
282+
expect(removedListeners).to.deep.equal(['drain', 'error']);
283+
});
284+
}
285+
);
286+
203287
context('when connecting with a username and password', () => {
204288
let utilClient: MongoClient;
205289
let client: MongoClient;

test/integration/node-specific/resource_clean_up.test.ts

+33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import * as v8 from 'node:v8';
2+
13
import { expect } from 'chai';
24

5+
import { sleep } from '../../tools/utils';
36
import { runScript } from './resource_tracking_script_builder';
47

58
/**
@@ -86,4 +89,34 @@ describe('Driver Resources', () => {
8689
});
8790
});
8891
});
92+
93+
context('when 100s of operations are executed and complete', () => {
94+
beforeEach(function () {
95+
if (this.currentTest && typeof v8.queryObjects !== 'function') {
96+
this.currentTest.skipReason = 'Test requires v8.queryObjects API to count Promises';
97+
this.currentTest?.skip();
98+
}
99+
});
100+
101+
let client;
102+
beforeEach(async function () {
103+
client = this.configuration.newClient();
104+
});
105+
106+
afterEach(async function () {
107+
await client.close();
108+
});
109+
110+
it('does not leave behind additional promises', async () => {
111+
const test = client.db('test').collection('test');
112+
const promiseCountBefore = v8.queryObjects(Promise, { format: 'count' });
113+
for (let i = 0; i < 100; i++) {
114+
await test.findOne();
115+
}
116+
await sleep(10);
117+
const promiseCountAfter = v8.queryObjects(Promise, { format: 'count' });
118+
119+
expect(promiseCountAfter).to.be.within(promiseCountBefore - 5, promiseCountBefore + 5);
120+
});
121+
});
89122
});

0 commit comments

Comments
 (0)