Skip to content

Mitigate memory leaks in jest-environment-node #15215

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions packages/jest-circus/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
*/

import type {Circus, Global} from '@jest/types';
import {protectProperties, setGlobal} from 'jest-util';
import eventHandler from './eventHandler';
import formatNodeAssertErrors from './formatNodeAssertErrors';
import {EVENT_HANDLERS, STATE_SYM} from './types';
import {makeDescribe} from './utils';

const handlers: Array<Circus.EventHandler> = ((globalThis as Global.Global)[
EVENT_HANDLERS
] = ((globalThis as Global.Global)[
EVENT_HANDLERS
] as Array<Circus.EventHandler>) || [eventHandler, formatNodeAssertErrors]);
] as Array<Circus.EventHandler>) || [eventHandler, formatNodeAssertErrors];

Check warning on line 17 in packages/jest-circus/src/state.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-circus/src/state.ts#L17

Added line #L17 was not covered by tests
setGlobal(globalThis, EVENT_HANDLERS, handlers, 'retain');
Copy link
Contributor

Choose a reason for hiding this comment

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

lgtm


export const ROOT_DESCRIBE_BLOCK_NAME = 'ROOT_DESCRIBE_BLOCK';

Expand All @@ -39,17 +39,29 @@
};
};

export const getState = (): Circus.State =>
(globalThis as Global.Global)[STATE_SYM] as Circus.State;
export const setState = (state: Circus.State): Circus.State => {
setGlobal(globalThis, STATE_SYM, state);
protectProperties(state, [
'hasFocusedTests',
'hasStarted',
'includeTestLocationInResult',
'maxConcurrency',
'seed',
'testNamePattern',
'testTimeout',
'unhandledErrors',
'unhandledRejectionErrorByPromise',
]);
return state;
};
export const resetState = (): void => {
(globalThis as Global.Global)[STATE_SYM] = createState();
setState(createState());
};

resetState();

export const getState = (): Circus.State =>
(globalThis as Global.Global)[STATE_SYM] as Circus.State;
export const setState = (state: Circus.State): Circus.State =>
((globalThis as Global.Global)[STATE_SYM] = state);

export const dispatch = async (event: Circus.AsyncEvent): Promise<void> => {
for (const handler of handlers) {
await handler(event, getState());
Expand Down
128 changes: 125 additions & 3 deletions packages/jest-environment-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers';
import type {Global} from '@jest/types';
import {ModuleMocker} from 'jest-mock';
import {installCommonGlobals} from 'jest-util';
import {
canDeleteProperties,
deleteProperties,
installCommonGlobals,
protectProperties,
} from 'jest-util';

type Timer = {
id: number;
Expand Down Expand Up @@ -80,12 +85,13 @@
moduleMocker: ModuleMocker | null;
customExportConditions = ['node', 'node-addons'];
private readonly _configuredExportConditions?: Array<string>;
private _globalProxy: GlobalProxy;

// while `context` is unused, it should always be passed
constructor(config: JestEnvironmentConfig, _context: EnvironmentContext) {
const {projectConfig} = config;
this.context = createContext();

this._globalProxy = new GlobalProxy();
this.context = createContext(this._globalProxy.proxy());
const global = runInContext(
'this',
Object.assign(this.context, projectConfig.testEnvironmentOptions),
Expand Down Expand Up @@ -194,6 +200,8 @@
config: projectConfig,
global,
});

this._globalProxy.envSetupCompleted();
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
Expand All @@ -209,6 +217,7 @@
this.context = null;
this.fakeTimers = null;
this.fakeTimersModern = null;
this._globalProxy.clear();

Check warning on line 220 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L220

Added line #L220 was not covered by tests
}

exportConditions(): Array<string> {
Expand All @@ -221,3 +230,116 @@
}

export const TestEnvironment = NodeEnvironment;

/**
* Creates a new empty global object and wraps it with a {@link Proxy}.
*
* The purpose is to register any property set on the global object,
* and {@link #deleteProperties} on them at environment teardown,
* to clean up memory and prevent leaks.
*/
class GlobalProxy implements ProxyHandler<typeof globalThis> {
private global: typeof globalThis = Object.create(
Object.getPrototypeOf(globalThis),
);
private globalProxy: typeof globalThis = new Proxy(this.global, this);
private isEnvSetup = false;
private propertyToValue = new Map<string | symbol, unknown>();
private leftovers: Array<{property: string | symbol; value: unknown}> = [];

constructor() {
this.register = this.register.bind(this);
}

proxy(): typeof globalThis {
return this.globalProxy;
}

/**
* Marks that the environment setup has completed, and properties set on
* the global object from now on should be deleted at teardown.
*/
envSetupCompleted(): void {
this.isEnvSetup = true;
}

/**
* Deletes any property that was set on the global object, except for:
* 1. Properties that were set before {@link #envSetupCompleted} was invoked.
* 2. Properties protected by {@link #protectProperties}.
*/
clear(): void {
for (const {property, value} of [
...[...this.propertyToValue.entries()].map(([property, value]) => ({

Check warning on line 273 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L271-L273

Added lines #L271 - L273 were not covered by tests
property,
value,
})),
...this.leftovers,
]) {
/*
* react-native invoke its custom `performance` property after env teardown.
* its setup file should use `protectProperties` to prevent this.
*/
if (property !== 'performance') {
deleteProperties(value);

Check warning on line 284 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L284

Added line #L284 was not covered by tests
}
}
this.propertyToValue.clear();
this.leftovers = [];
this.global = {} as typeof globalThis;
this.globalProxy = {} as typeof globalThis;

Check warning on line 290 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L287-L290

Added lines #L287 - L290 were not covered by tests
}

defineProperty(
target: typeof globalThis,
property: string | symbol,
attributes: PropertyDescriptor,
): boolean {
const newAttributes = {...attributes};

if ('set' in newAttributes && newAttributes.set !== undefined) {
const originalSet = newAttributes.set;
const register = this.register;
newAttributes.set = value => {
originalSet(value);
const newValue = Reflect.get(target, property);
register(property, newValue);
};
}

const result = Reflect.defineProperty(target, property, newAttributes);

if ('value' in newAttributes) {
this.register(property, newAttributes.value);
}

return result;
}

deleteProperty(
target: typeof globalThis,
property: string | symbol,
): boolean {
const result = Reflect.deleteProperty(target, property);
const value = this.propertyToValue.get(property);

Check warning on line 324 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L322-L324

Added lines #L322 - L324 were not covered by tests
if (value) {
this.leftovers.push({property, value});
this.propertyToValue.delete(property);

Check warning on line 327 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L326-L327

Added lines #L326 - L327 were not covered by tests
}
return result;

Check warning on line 329 in packages/jest-environment-node/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-environment-node/src/index.ts#L329

Added line #L329 was not covered by tests
}

private register(property: string | symbol, value: unknown) {
const currentValue = this.propertyToValue.get(property);
if (value !== currentValue) {
if (!this.isEnvSetup && canDeleteProperties(value)) {
protectProperties(value);
}
if (currentValue) {
this.leftovers.push({property, value: currentValue});
}

this.propertyToValue.set(property, value);
}
}
}
6 changes: 3 additions & 3 deletions packages/jest-repl/src/cli/runtime-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ export async function run(
},
{console: customConsole, docblockPragmas: {}, testPath: filePath},
);
setGlobal(environment.global, 'console', customConsole);
setGlobal(environment.global, 'jestProjectConfig', projectConfig);
setGlobal(environment.global, 'jestGlobalConfig', globalConfig);
setGlobal(environment.global, 'console', customConsole, 'retain');
setGlobal(environment.global, 'jestProjectConfig', projectConfig, 'retain');
setGlobal(environment.global, 'jestGlobalConfig', globalConfig, 'retain');

const runtime = new Runtime(
projectConfig,
Expand Down
2 changes: 1 addition & 1 deletion packages/jest-runner/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@
? new LeakDetector(environment)
: null;

setGlobal(environment.global, 'console', testConsole);
setGlobal(environment.global, 'console', testConsole, 'retain');

Check warning on line 187 in packages/jest-runner/src/runTest.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-runner/src/runTest.ts#L187

Added line #L187 was not covered by tests

const runtime = new Runtime(
projectConfig,
Expand Down
54 changes: 54 additions & 0 deletions packages/jest-util/src/garbage-collection-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

const PROTECT_PROPERTY = Symbol.for('$$jest-protect-from-deletion');

Check warning on line 8 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L8

Added line #L8 was not covered by tests

/**
* Deletes all the properties from the given value (if it's an object),
* unless the value was protected via {@link #protectProperties}.
*
* @param value the given value.
*/
export function deleteProperties(value: unknown): void {

Check warning on line 16 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L16

Added line #L16 was not covered by tests
if (canDeleteProperties(value)) {
const protectedProperties = Reflect.get(value, PROTECT_PROPERTY);

Check warning on line 18 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L18

Added line #L18 was not covered by tests
if (!Array.isArray(protectedProperties) || protectedProperties.length > 0) {
for (const key of Reflect.ownKeys(value)) {

Check warning on line 20 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L20

Added line #L20 was not covered by tests
if (!protectedProperties?.includes(key)) {
Reflect.deleteProperty(value, key);

Check warning on line 22 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L22

Added line #L22 was not covered by tests
}
}
}
}
}

/**
* Protects the given value from being deleted by {@link #deleteProperties}.
*
* @param value The given value.
* @param properties If the array contains any property,
* then only these properties will not be deleted; otherwise if the array is empty,
* all properties will not be deleted.
*/
export function protectProperties<T extends object>(
value: T,
properties: Array<keyof T> = [],
): boolean {

Check warning on line 40 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L40

Added line #L40 was not covered by tests
if (canDeleteProperties(value)) {
return Reflect.set(value, PROTECT_PROPERTY, properties);

Check warning on line 42 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L42

Added line #L42 was not covered by tests
}
return false;

Check warning on line 44 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L44

Added line #L44 was not covered by tests
}

/**
* Whether the given value has properties that can be deleted (regardless of protection).
*
* @param value The given value.
*/
export function canDeleteProperties(value: unknown): value is object {

Check warning on line 52 in packages/jest-util/src/garbage-collection-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/garbage-collection-utils.ts#L52

Added line #L52 was not covered by tests
return value !== null && ['object', 'function'].includes(typeof value);
}
5 changes: 5 additions & 0 deletions packages/jest-util/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ export {default as tryRealpath} from './tryRealpath';
export {default as requireOrImportModule} from './requireOrImportModule';
export {default as invariant} from './invariant';
export {default as isNonNullable} from './isNonNullable';
export {
canDeleteProperties,
protectProperties,
deleteProperties,
} from './garbage-collection-utils';
13 changes: 10 additions & 3 deletions packages/jest-util/src/setGlobal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@
*/

import type {Global} from '@jest/types';
import {
canDeleteProperties,
protectProperties,
} from './garbage-collection-utils';

export default function setGlobal(
globalToMutate: typeof globalThis | Global.Global,
key: string,
key: string | symbol,
value: unknown,
afterTeardown: 'clean' | 'retain' = 'clean',
): void {
// @ts-expect-error: no index
globalToMutate[key] = value;
Reflect.set(globalToMutate, key, value);

Check warning on line 20 in packages/jest-util/src/setGlobal.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/setGlobal.ts#L20

Added line #L20 was not covered by tests
if (afterTeardown === 'retain' && canDeleteProperties(value)) {
protectProperties(value);

Check warning on line 22 in packages/jest-util/src/setGlobal.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-util/src/setGlobal.ts#L22

Added line #L22 was not covered by tests
}
}
Loading