Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adds freezeCoreModules configuration option to mitigate memory leaks #8331

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

### Features

- `[jest-config]` Add `freezeCoreModules` configuration option to mitigate memory leaks described in the following issues: [#6399](https://github.com/facebook/jest/issues/6399), [#6814](https://github.com/facebook/jest/issues/6814) ([#8331](https://github.com/facebook/jest/pull/8331))
- `[babel-plugin-jest-hoist]` Show codeframe on static hoisting issues ([#8865](https://github.com/facebook/jest/pull/8865))
- `[babel-plugin-jest-hoist]` Add `BigInt` to `WHITELISTED_IDENTIFIERS` ([#8382](https://github.com/facebook/jest/pull/8382))
- `[babel-preset-jest]` Add `@babel/plugin-syntax-bigint` ([#8382](https://github.com/facebook/jest/pull/8382))
Expand Down
3 changes: 3 additions & 0 deletions TestUtils.ts
Expand Up @@ -81,6 +81,8 @@ const DEFAULT_PROJECT_CONFIG: Config.ProjectConfig = {
extraGlobals: [],
filter: null,
forceCoverageMatch: [],
freezeCoreModules: false,
freezeCoreModulesWhitelist: ['crypto'],
globalSetup: null,
globalTeardown: null,
globals: {},
Expand Down Expand Up @@ -120,6 +122,7 @@ const DEFAULT_PROJECT_CONFIG: Config.ProjectConfig = {
transform: [],
transformIgnorePatterns: [],
unmockedModulePathPatterns: null,
verbose: false,
watchPathIgnorePatterns: [],
};

Expand Down
16 changes: 16 additions & 0 deletions docs/Configuration.md
Expand Up @@ -367,6 +367,22 @@ You can collect coverage from those files with setting `forceCoverageMatch`.
}
```

### `freezeCoreModules` [boolean]

Default: `false`

Prevents overriding node's core module methods so to prevent memory leaks.

Attempt to override such a property will fail silently.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our > 10K test suite runs successfully with these changes, so i guess that in most cases, there is no actual need/use of these overrides.


Running with [`verbose`](#verbose-boolean) flag will enable informative print outs.

### `freezeCoreModulesWhitelist` [Array<string>]

Default: `['crypto']`
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proxies have issues with bound native methods, hence crypto.randomBytes will fail. Tried to hack it from multiple directions with no success.


Array of node core modules which will not be frozen.

### `globals` [object]

Default: `{}`
Expand Down
5 changes: 5 additions & 0 deletions e2e/__tests__/__snapshots__/showConfig.test.ts.snap
Expand Up @@ -19,6 +19,10 @@ exports[`--showConfig outputs config info and exits 1`] = `
"errorOnDeprecated": false,
"filter": null,
"forceCoverageMatch": [],
"freezeCoreModules": false,
"freezeCoreModulesWhitelist": [
"crypto"
],
"globalSetup": null,
"globalTeardown": null,
"globals": {},
Expand Down Expand Up @@ -78,6 +82,7 @@ exports[`--showConfig outputs config info and exits 1`] = `
"transformIgnorePatterns": [
"/node_modules/"
],
"verbose": null,
"watchPathIgnorePatterns": []
}
],
Expand Down
Expand Up @@ -72,6 +72,14 @@ module.exports = {
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],

// Prevents overriding node's core module methods so to prevent memory leaks
// freezeCoreModules: false,

// Array of node core modules which will not be frozen
// freezeCoreModulesWhitelist: [
// \\"crypto\\"
// ],

// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,

Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/src/Defaults.ts
Expand Up @@ -31,6 +31,8 @@ const defaultOptions: Config.DefaultOptions = {
expand: false,
filter: null,
forceCoverageMatch: [],
freezeCoreModules: false,
freezeCoreModulesWhitelist: ['crypto'],
globalSetup: null,
globalTeardown: null,
globals: {},
Expand Down
4 changes: 4 additions & 0 deletions packages/jest-config/src/Descriptions.ts
Expand Up @@ -31,6 +31,10 @@ const descriptions: {[key in keyof Config.InitialOptions]: string} = {
'Make calling deprecated APIs throw helpful error messages',
forceCoverageMatch:
'Force coverage collection from ignored files using an array of glob patterns',
freezeCoreModules:
"Prevents overriding node's core module methods so to prevent memory leaks",
freezeCoreModulesWhitelist:
'Array of node core modules which will not be frozen',
globalSetup:
'A path to a module which exports an async function that is triggered once before all test suites',
globalTeardown:
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/src/ValidConfig.ts
Expand Up @@ -48,6 +48,8 @@ const initialOptions: Config.InitialOptions = {
filter: '<rootDir>/filter.js',
forceCoverageMatch: ['**/*.t.js'],
forceExit: false,
freezeCoreModules: false,
freezeCoreModulesWhitelist: ['crypto'],
globalSetup: 'setup.js',
globalTeardown: 'teardown.js',
globals: {__DEV__: true},
Expand Down
3 changes: 3 additions & 0 deletions packages/jest-config/src/index.ts
Expand Up @@ -174,6 +174,8 @@ const groupOptions = (
extraGlobals: options.extraGlobals,
filter: options.filter,
forceCoverageMatch: options.forceCoverageMatch,
freezeCoreModules: options.freezeCoreModules,
freezeCoreModulesWhitelist: options.freezeCoreModulesWhitelist,
globalSetup: options.globalSetup,
globalTeardown: options.globalTeardown,
globals: options.globals,
Expand Down Expand Up @@ -211,6 +213,7 @@ const groupOptions = (
transform: options.transform,
transformIgnorePatterns: options.transformIgnorePatterns,
unmockedModulePathPatterns: options.unmockedModulePathPatterns,
verbose: options.verbose,
watchPathIgnorePatterns: options.watchPathIgnorePatterns,
}),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -852,6 +852,8 @@ export default function normalize(
case 'findRelatedTests':
case 'forceCoverageMatch':
case 'forceExit':
case 'freezeCoreModules':
case 'freezeCoreModulesWhitelist':
case 'lastCommit':
case 'listTests':
case 'logHeapUsage':
Expand Down
Expand Up @@ -16,6 +16,10 @@ exports[`prints the config object 1`] = `
"extraGlobals": [],
"filter": null,
"forceCoverageMatch": [],
"freezeCoreModules": false,
"freezeCoreModulesWhitelist": [
"crypto"
],
"globalSetup": null,
"globalTeardown": null,
"globals": {},
Expand Down Expand Up @@ -61,6 +65,7 @@ exports[`prints the config object 1`] = `
"transform": [],
"transformIgnorePatterns": [],
"unmockedModulePathPatterns": null,
"verbose": false,
"watchPathIgnorePatterns": []
},
"globalConfig": {
Expand Down
48 changes: 48 additions & 0 deletions packages/jest-runtime/src/__tests__/runtime_require_module.test.js
Expand Up @@ -184,6 +184,54 @@ describe('Runtime requireModule', () => {
}).not.toThrow();
}));

it('prevents overriding core module methods when `config.freezeCoreModules` is set', () =>
createRuntime(__filename, {freezeCoreModules: true}).then(runtime => {
const fs = runtime.requireModule(runtime.__mockRootPath, 'fs');
const originalFsClose = fs.close;

fs.close = () => {};

expect(fs.close).toBe(originalFsClose);
}));

it('allows mocking core module methods when `config.freezeCoreModules` is set', () =>
createRuntime(__filename, {freezeCoreModules: true}).then(runtime => {
const root = runtime.requireModule(runtime.__mockRootPath);
const fs = runtime.requireModule(runtime.__mockRootPath, 'fs');
let mockImplementationCalled = false;
const spy = root.jest.spyOn(fs, 'close').mockImplementation(() => {
mockImplementationCalled = true;
});

fs.close();

expect(mockImplementationCalled).toBe(true);
expect(spy).toHaveBeenCalled();
}));

it('allows overriding `crypto` core module methods by default', () =>
createRuntime(__filename, {freezeCoreModules: true}).then(runtime => {
const crypto = runtime.requireModule(runtime.__mockRootPath, 'crypto');
const cryptoRandomBytesOverride = () => {};

crypto.randomBytes = cryptoRandomBytesOverride;

expect(crypto.randomBytes).toBe(cryptoRandomBytesOverride);
}));

it('allows overriding core module methods when module is in `config.freezeCoreModulesWhitelist`', () =>
createRuntime(__filename, {
freezeCoreModules: true,
freezeCoreModulesWhitelist: ['fs'],
}).then(runtime => {
const fs = runtime.requireModule(runtime.__mockRootPath, 'fs');
const fsCloseOverride = () => {};

fs.close = fsCloseOverride;

expect(fs.close).toBe(fsCloseOverride);
}));

it('finds and loads JSON files without file extension', () =>
createRuntime(__filename).then(runtime => {
const exports = runtime.requireModule(
Expand Down
45 changes: 43 additions & 2 deletions packages/jest-runtime/src/index.ts
Expand Up @@ -18,7 +18,7 @@ import jestMock = require('jest-mock');
import HasteMap = require('jest-haste-map');
import {formatStackTrace, separateMessageFromStack} from 'jest-message-util';
import Resolver = require('jest-resolve');
import {createDirectory, deepCyclicCopy} from 'jest-util';
import {ErrorWithStack, createDirectory, deepCyclicCopy} from 'jest-util';
import {escapePathForRegex} from 'jest-regex-util';
import Snapshot = require('jest-snapshot');
import {
Expand Down Expand Up @@ -84,6 +84,7 @@ class Runtime {

private _cacheFS: CacheFS;
private _config: Config.ProjectConfig;
private _coreModulesProxyCache: {[moduleName: string]: any};
private _coverageOptions: ShouldInstrumentOptions;
private _currentlyExecutingModulePath: string;
private _environment: JestEnvironment;
Expand Down Expand Up @@ -125,6 +126,7 @@ class Runtime {
collectCoverageFrom: [],
collectCoverageOnlyFrom: null,
};
this._coreModulesProxyCache = Object.create(null);
this._currentlyExecutingModulePath = '';
this._environment = environment;
this._explicitShouldMock = Object.create(null);
Expand Down Expand Up @@ -776,7 +778,46 @@ class Runtime {
return this._environment.global.process;
}

return require(moduleName);
const module = require(moduleName);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to check _coreModulesProxyCache first (see below) before possibly needlessly calling require.


if (
!this._config.freezeCoreModules ||
this._config.freezeCoreModulesWhitelist.includes(moduleName)
) {
return module;
}

if (this._coreModulesProxyCache[moduleName]) {
return this._coreModulesProxyCache[moduleName];
}

const set = (
target: object,
property: PropertyKey,
value: any,
receiver: any,
) => {
if (typeof value !== 'function' || value._isMockFunction) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add target !== module as additional case, such that the trap only triggers on the original module (see #8331 (comment)).

return Reflect.set(target, property, value, receiver);
}

if (this._config.verbose) {
console.warn(
new ErrorWithStack(
`Trying to override method '${property.toString()}' of a frozen core module '${moduleName}'`,
set,
),
);
}

return true;
};

const proxy = new Proxy(module, {set});

this._coreModulesProxyCache[moduleName] = proxy;

return proxy;
}

private _generateMock(from: Config.Path, moduleName: string) {
Expand Down
Expand Up @@ -17,6 +17,10 @@ Object {
"extraGlobals": Array [],
"filter": null,
"forceCoverageMatch": Array [],
"freezeCoreModules": false,
"freezeCoreModulesWhitelist": Array [
"crypto",
],
"globalSetup": null,
"globalTeardown": null,
"globals": Object {},
Expand Down Expand Up @@ -67,6 +71,7 @@ Object {
"/node_modules/",
],
"unmockedModulePathPatterns": null,
"verbose": false,
"watchPathIgnorePatterns": Array [],
},
"instrument": true,
Expand Down Expand Up @@ -218,7 +223,7 @@ exports[`ScriptTransformer uses multiple preprocessors 1`] = `
const TRANSFORMED = {
filename: '/fruits/banana.js',
script: 'module.exports = \\"banana\\";',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"],[\\"^.+\\\\\\\\.css$\\",\\"css-preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"watchPathIgnorePatterns\\":[]}',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"freezeCoreModules\\":false,\\"freezeCoreModulesWhitelist\\":[\\"crypto\\"],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"],[\\"^.+\\\\\\\\.css$\\",\\"css-preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"verbose\\":false,\\"watchPathIgnorePatterns\\":[]}',
};

}});"
Expand All @@ -244,7 +249,7 @@ exports[`ScriptTransformer uses the supplied preprocessor 1`] = `
const TRANSFORMED = {
filename: '/fruits/banana.js',
script: 'module.exports = \\"banana\\";',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"watchPathIgnorePatterns\\":[]}',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"freezeCoreModules\\":false,\\"freezeCoreModulesWhitelist\\":[\\"crypto\\"],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"verbose\\":false,\\"watchPathIgnorePatterns\\":[]}',
};

}});"
Expand Down
7 changes: 7 additions & 0 deletions packages/jest-types/src/Config.ts
Expand Up @@ -52,6 +52,8 @@ export type DefaultOptions = {
expand: boolean;
filter: Path | null | undefined;
forceCoverageMatch: Array<Glob>;
freezeCoreModules: boolean;
freezeCoreModulesWhitelist: Array<string>;
globals: ConfigGlobals;
globalSetup: string | null | undefined;
globalTeardown: string | null | undefined;
Expand Down Expand Up @@ -150,6 +152,8 @@ export type InitialOptions = Partial<{
findRelatedTests: boolean;
forceCoverageMatch: Array<Glob>;
forceExit: boolean;
freezeCoreModules?: boolean;
freezeCoreModulesWhitelist?: Array<string>;
json: boolean;
globals: ConfigGlobals;
globalSetup: string | null | undefined;
Expand Down Expand Up @@ -386,6 +390,8 @@ export type ProjectConfig = {
extraGlobals: Array<keyof NodeJS.Global>;
filter: Path | null | undefined;
forceCoverageMatch: Array<Glob>;
freezeCoreModules: boolean;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not quite sure if these should be on ProjectConfig or GlobalConfig. probably GlobalConfig 😵 . kindly assist.

freezeCoreModulesWhitelist: Array<string>;
globalSetup: string | null | undefined;
globalTeardown: string | null | undefined;
globals: ConfigGlobals;
Expand Down Expand Up @@ -424,6 +430,7 @@ export type ProjectConfig = {
transformIgnorePatterns: Array<Glob>;
watchPathIgnorePatterns: Array<string>;
unmockedModulePathPatterns: Array<string> | null | undefined;
verbose: boolean | null | undefined;
};

export type Argv = Arguments<
Expand Down