Skip to content

Commit

Permalink
Merge branch 'NODE-6615/integration-client-close' into NODE-6620/sockets
Browse files Browse the repository at this point in the history
  • Loading branch information
aditi-khare-mongoDB authored Dec 30, 2024
2 parents 2f676ab + e60a42b commit 617e9af
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 65 deletions.
Empty file removed logs.txt
Empty file.
75 changes: 40 additions & 35 deletions test/integration/node-specific/client_close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ describe.skip('MongoClient.close() Integration', () => {
await runScriptAndGetProcessInfo(
'tls-file-read',
config,
async function run({ MongoClient, uri, log, chai }) {
async function run({ MongoClient, uri, expect }) {
const infiniteFile = '/dev/zero';
const client = new MongoClient(uri, { tlsCertificateKeyFile: infiniteFile });
client.connect();
log({ ActiveResources: process.getActiveResourcesInfo() });
chai.expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');
expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');
await client.close();
chai.expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');
expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');
}
);
});
Expand All @@ -41,27 +40,37 @@ describe.skip('MongoClient.close() Integration', () => {

describe('MongoClientAuthProviders', () => {
describe('Node.js resource: Token file read', () => {
let tokenFileEnvCache;

beforeEach(function () {
if (process.env.AUTH === 'auth') {
this.currentTest.skipReason = 'OIDC test environment requires auth disabled';
return this.skip();
}
tokenFileEnvCache = process.env.OIDC_TOKEN_FILE;
});

afterEach(function () {
process.env.OIDC_TOKEN_FILE = tokenFileEnvCache;
});

describe('when MongoClientAuthProviders is instantiated and token file read hangs', () => {
it('the file read is interrupted by client.close()', async () => {
await runScriptAndGetProcessInfo(
'token-file-read',
config,
async function run({ MongoClient, uri, log, chai }) {
async function run({ MongoClient, uri, expect }) {
const infiniteFile = '/dev/zero';
log({ ActiveResources: process.getActiveResourcesInfo() });

// speculative authentication call to getToken() is during initial handshake
const client = new MongoClient(uri, {
authMechanismProperties: { TOKEN_RESOURCE: infiniteFile }
});
process.env.OIDC_TOKEN_FILE = infiniteFile;
const options = {
authMechanismProperties: { ENVIRONMENT: 'test' },
authMechanism: 'MONGODB-OIDC'
};
const client = new MongoClient(uri, options);
client.connect();

log({ ActiveResources: process.getActiveResourcesInfo() });

chai.expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');
expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');
await client.close();

chai.expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');
expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');
}
);
});
Expand Down Expand Up @@ -220,9 +229,9 @@ describe.skip('MongoClient.close() Integration', () => {
describe('when KMSRequest reads an infinite TLS file', () => {
it('the file read is interrupted by client.close()', async () => {
await runScriptAndGetProcessInfo(
'tls-file-read',
'tls-file-read-auto-encryption',
config,
async function run({ MongoClient, uri, log, chai, ClientEncryption, BSON }) {
async function run({ MongoClient, uri, expect, ClientEncryption, BSON }) {
const infiniteFile = '/dev/zero';

const kmsProviders = BSON.EJSON.parse(process.env.CSFLE_KMS_PROVIDERS);
Expand Down Expand Up @@ -281,23 +290,23 @@ describe.skip('MongoClient.close() Integration', () => {
const encryptedClient = new MongoClient(uri, encryptionOptions);
await encryptedClient.connect();

expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');

const insertPromise = encryptedClient
.db('db')
.collection('coll')
.insertOne({ a: 1 });

chai.expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');
log({ activeResourcesBeforeClose: process.getActiveResourcesInfo() });
expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');

await keyVaultClient.close();
await encryptedClient.close();

chai.expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');
log({ activeResourcesAfterClose: process.getActiveResourcesInfo() });
expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');

const err = await insertPromise.catch(e => e);
chai.expect(err).to.exist;
chai.expect(err.errmsg).to.contain('Error in KMS response');
expect(err).to.exist;
expect(err.errmsg).to.contain('Error in KMS response');
}
);
});
Expand All @@ -316,9 +325,9 @@ describe.skip('MongoClient.close() Integration', () => {
describe('when KMSRequest reads an infinite TLS file read', () => {
it('the file read is interrupted by client.close()', async () => {
await runScriptAndGetProcessInfo(
'tls-file-read',
'tls-file-read-client-encryption',
config,
async function run({ MongoClient, uri, log, chai, ClientEncryption, BSON }) {
async function run({ MongoClient, uri, expect, ClientEncryption, BSON }) {
const infiniteFile = '/dev/zero';
const kmsProviders = BSON.EJSON.parse(process.env.CSFLE_KMS_PROVIDERS);
const masterKey = {
Expand All @@ -339,19 +348,15 @@ describe.skip('MongoClient.close() Integration', () => {

const dataKeyPromise = clientEncryption.createDataKey(provider, { masterKey });

chai.expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');

log({ activeResourcesBeforeClose: process.getActiveResourcesInfo() });
expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');

await keyVaultClient.close();

chai.expect(process.getActiveResourcesInfo()).to.include('FSReqPromise');

log({ activeResourcesAfterClose: process.getActiveResourcesInfo() });
expect(process.getActiveResourcesInfo()).to.not.include('FSReqPromise');

const err = await dataKeyPromise.catch(e => e);
chai.expect(err).to.exist;
chai.expect(err.errmsg).to.contain('Error in KMS response');
expect(err).to.exist;
expect(err.errmsg).to.contain('Error in KMS response');
}
);
});
Expand Down
31 changes: 13 additions & 18 deletions test/integration/node-specific/resource_tracking_script_builder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { fork, spawn } from 'node:child_process';
import { on, once } from 'node:events';
import * as fs from 'node:fs';
import { readFile, unlink, writeFile } from 'node:fs/promises';
import * as path from 'node:path';

Expand All @@ -21,8 +20,8 @@ export type HeapResourceTestFunction = (options: {
export type ProcessResourceTestFunction = (options: {
MongoClient: typeof MongoClient;
uri: string;
log: (out: any) => void;
chai: { expect: typeof expect };
log?: (out: any) => void;
expect: typeof expect;
ClientEncryption?: typeof ClientEncryption;
BSON?: typeof BSON;
}) => Promise<void>;
Expand All @@ -48,7 +47,7 @@ export async function testScriptFactory(

resourceScript = resourceScript.replace('DRIVER_SOURCE_PATH', DRIVER_SRC_PATH);
resourceScript = resourceScript.replace('FUNCTION_STRING', `(${func.toString()})`);
resourceScript = resourceScript.replace('NAME_STRING', JSON.stringify(name));
resourceScript = resourceScript.replace('SCRIPT_NAME_STRING', JSON.stringify(name));
resourceScript = resourceScript.replace('URI_STRING', JSON.stringify(uri));
resourceScript = resourceScript.replace('ITERATIONS_STRING', `${iterations}`);

Expand Down Expand Up @@ -141,11 +140,11 @@ export async function runScriptAndReturnHeapInfo(
* **The provided function is run in an isolated Node.js process**
*
* A user of this function will likely need to familiarize themselves with the surrounding scripting, but briefly:
* - Every MongoClient you construct should have an asyncResource attached to it like so:
* ```js
* mongoClient.asyncResource = new this.async_hooks.AsyncResource('MongoClient');
* ```
* - You can perform any number of operations and connects/closes of MongoClients
* - Many MongoClient operations (construction, connection, commands) can result in resources that keep the JS event loop running.
* - Timers
* - Active Sockets
* - File Read Hangs
*
* - This function performs assertions that at the end of the provided function, the js event loop has been exhausted
*
* @param name - the name of the script, this defines the name of the file, it will be cleaned up if the function returns successfully
Expand All @@ -169,23 +168,19 @@ export async function runScriptAndGetProcessInfo(
await writeFile(scriptName, scriptContent, { encoding: 'utf8' });
const logFile = 'logs.txt';

const processDiedController = new AbortController();
const script = spawn(process.argv[0], [scriptName], { stdio: ['ignore', 'ignore', 'ignore'] });

// Interrupt our awaiting of messages if the process crashed
script.once('close', exitCode => {
if (exitCode !== 0) {
processDiedController.abort(new Error(`process exited with: ${exitCode}`));
}
});

const willClose = once(script, 'close');

// make sure the process ended
const [exitCode] = await willClose;

// format messages from child process as an object
const messages = (await readFile(logFile, 'utf-8')).trim().split('\n').map(line => JSON.parse(line)).reduce((acc, curr) => ({ ...acc, ...curr }), {});
const messages = (await readFile(logFile, 'utf-8'))
.trim()
.split('\n')
.map(line => JSON.parse(line))
.reduce((acc, curr) => ({ ...acc, ...curr }), {});

// delete temporary files
await unlink(scriptName);
Expand Down
47 changes: 35 additions & 12 deletions test/tools/fixtures/process_resource_script.in.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,62 @@
/* eslint-disable no-unused-vars */
const driverPath = DRIVER_SOURCE_PATH;
const func = FUNCTION_STRING;
const name = NAME_STRING;
const scriptName = SCRIPT_NAME_STRING;
const uri = URI_STRING;

const { MongoClient, ClientEncryption, BSON } = require(driverPath);
const process = require('node:process');
const util = require('node:util');
const timers = require('node:timers');
const fs = require('node:fs');
const chai = require('chai');
const { expect } = require('chai');
const { setTimeout } = require('timers');

let originalReport;
const logFile = 'logs.txt';

const run = func;
const serverType = ['tcp', 'udp'];

// Returns an array containing new the resources created after script start
/**
*
* Returns an array containing the new resources created after script started.
* A new resource is something that will keep the event loop running.
*
* In order to be counted as a new resource, a resource MUST:
* - Must NOT share an address with a libuv resource that existed at the start of script
* - Must be referenced. See [here](https://nodejs.org/api/timers.html#timeoutref) for more context.
* - Must NOT be an inactive server
*
* We're using the following tool to track resources: `process.report.getReport().libuv`
* For more context, see documentation for [process.report.getReport()](https://nodejs.org/api/report.html), and [libuv](https://docs.libuv.org/en/v1.x/handle.html).
*
*/
function getNewLibuvResourceArray() {
let currReport = process.report.getReport().libuv;
const originalReportAddresses = originalReport.map(resource => resource.address);
currReport = currReport.filter(
resource =>

/**
* @typedef {Object} LibuvResource
* @property {boolean} is_active Is the resource active? For a socket, this means it is allowing I/O. For a timer, this means a timer is has not expired.
* @property {string} type What is the resource type? For example, 'tcp' | 'timer' | 'udp' | 'tty'... (See more in [docs](https://docs.libuv.org/en/v1.x/handle.html)).
* @property {boolean} is_referenced Is the resource keeping the JS event loop active?
*
* @param {LibuvResource} resource
*/
function isNewLibuvResource(resource) {
const serverType = ['tcp', 'udp'];
return (
!originalReportAddresses.includes(resource.address) &&
resource.is_referenced && // if a resource is unreferenced, it's not keeping the event loop open
(!serverType.includes(resource.type) || resource.is_active)
);
);
}

currReport = currReport.filter(resource => isNewLibuvResource(resource));
return currReport;
}

// A log function for debugging
function log(message) {
// remove outer parentheses for easier parsing
const messageToLog = JSON.stringify(message) + ' \n';
Expand All @@ -45,22 +71,19 @@ async function main() {
process.on('beforeExit', () => {
log({ beforeExitHappened: true });
});
await run({ MongoClient, uri, log, chai, ClientEncryption, BSON });
await run({ MongoClient, uri, log, expect, ClientEncryption, BSON });
log({ newLibuvResources: getNewLibuvResourceArray() });
}

main()
.then(() => {
log({ exitCode: 0 });
})
.then(() => {})
.catch(e => {
log({ exitCode: 1, error: util.inspect(e) });
});

setTimeout(() => {
// this means something was in the event loop such that it hung for more than 10 seconds
// so we kill the process
log({ exitCode: 99 });
process.exit(99);
// using `unref` will ensure this setTimeout call is not a resource / does not keep the event loop running
}, 10000).unref();

0 comments on commit 617e9af

Please sign in to comment.