Skip to content

Commit 999bb65

Browse files
authored
Mitigate memory leaks in jest-environment-node (#15215)
1 parent 1853785 commit 999bb65

File tree

7 files changed

+219
-19
lines changed

7 files changed

+219
-19
lines changed

packages/jest-circus/src/state.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
*/
77

88
import type {Circus, Global} from '@jest/types';
9+
import {protectProperties, setGlobal} from 'jest-util';
910
import eventHandler from './eventHandler';
1011
import formatNodeAssertErrors from './formatNodeAssertErrors';
1112
import {EVENT_HANDLERS, STATE_SYM} from './types';
1213
import {makeDescribe} from './utils';
1314

1415
const handlers: Array<Circus.EventHandler> = ((globalThis as Global.Global)[
1516
EVENT_HANDLERS
16-
] = ((globalThis as Global.Global)[
17-
EVENT_HANDLERS
18-
] as Array<Circus.EventHandler>) || [eventHandler, formatNodeAssertErrors]);
17+
] as Array<Circus.EventHandler>) || [eventHandler, formatNodeAssertErrors];
18+
setGlobal(globalThis, EVENT_HANDLERS, handlers, 'retain');
1919

2020
export const ROOT_DESCRIBE_BLOCK_NAME = 'ROOT_DESCRIBE_BLOCK';
2121

@@ -39,17 +39,29 @@ const createState = (): Circus.State => {
3939
};
4040
};
4141

42+
export const getState = (): Circus.State =>
43+
(globalThis as Global.Global)[STATE_SYM] as Circus.State;
44+
export const setState = (state: Circus.State): Circus.State => {
45+
setGlobal(globalThis, STATE_SYM, state);
46+
protectProperties(state, [
47+
'hasFocusedTests',
48+
'hasStarted',
49+
'includeTestLocationInResult',
50+
'maxConcurrency',
51+
'seed',
52+
'testNamePattern',
53+
'testTimeout',
54+
'unhandledErrors',
55+
'unhandledRejectionErrorByPromise',
56+
]);
57+
return state;
58+
};
4259
export const resetState = (): void => {
43-
(globalThis as Global.Global)[STATE_SYM] = createState();
60+
setState(createState());
4461
};
4562

4663
resetState();
4764

48-
export const getState = (): Circus.State =>
49-
(globalThis as Global.Global)[STATE_SYM] as Circus.State;
50-
export const setState = (state: Circus.State): Circus.State =>
51-
((globalThis as Global.Global)[STATE_SYM] = state);
52-
5365
export const dispatch = async (event: Circus.AsyncEvent): Promise<void> => {
5466
for (const handler of handlers) {
5567
await handler(event, getState());

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

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import type {
1414
import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers';
1515
import type {Global} from '@jest/types';
1616
import {ModuleMocker} from 'jest-mock';
17-
import {installCommonGlobals} from 'jest-util';
17+
import {
18+
canDeleteProperties,
19+
deleteProperties,
20+
installCommonGlobals,
21+
protectProperties,
22+
} from 'jest-util';
1823

1924
type Timer = {
2025
id: number;
@@ -80,12 +85,13 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
8085
moduleMocker: ModuleMocker | null;
8186
customExportConditions = ['node', 'node-addons'];
8287
private readonly _configuredExportConditions?: Array<string>;
88+
private _globalProxy: GlobalProxy;
8389

8490
// while `context` is unused, it should always be passed
8591
constructor(config: JestEnvironmentConfig, _context: EnvironmentContext) {
8692
const {projectConfig} = config;
87-
this.context = createContext();
88-
93+
this._globalProxy = new GlobalProxy();
94+
this.context = createContext(this._globalProxy.proxy());
8995
const global = runInContext(
9096
'this',
9197
Object.assign(this.context, projectConfig.testEnvironmentOptions),
@@ -194,6 +200,8 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
194200
config: projectConfig,
195201
global,
196202
});
203+
204+
this._globalProxy.envSetupCompleted();
197205
}
198206

199207
// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -209,6 +217,7 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
209217
this.context = null;
210218
this.fakeTimers = null;
211219
this.fakeTimersModern = null;
220+
this._globalProxy.clear();
212221
}
213222

214223
exportConditions(): Array<string> {
@@ -221,3 +230,116 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
221230
}
222231

223232
export const TestEnvironment = NodeEnvironment;
233+
234+
/**
235+
* Creates a new empty global object and wraps it with a {@link Proxy}.
236+
*
237+
* The purpose is to register any property set on the global object,
238+
* and {@link #deleteProperties} on them at environment teardown,
239+
* to clean up memory and prevent leaks.
240+
*/
241+
class GlobalProxy implements ProxyHandler<typeof globalThis> {
242+
private global: typeof globalThis = Object.create(
243+
Object.getPrototypeOf(globalThis),
244+
);
245+
private globalProxy: typeof globalThis = new Proxy(this.global, this);
246+
private isEnvSetup = false;
247+
private propertyToValue = new Map<string | symbol, unknown>();
248+
private leftovers: Array<{property: string | symbol; value: unknown}> = [];
249+
250+
constructor() {
251+
this.register = this.register.bind(this);
252+
}
253+
254+
proxy(): typeof globalThis {
255+
return this.globalProxy;
256+
}
257+
258+
/**
259+
* Marks that the environment setup has completed, and properties set on
260+
* the global object from now on should be deleted at teardown.
261+
*/
262+
envSetupCompleted(): void {
263+
this.isEnvSetup = true;
264+
}
265+
266+
/**
267+
* Deletes any property that was set on the global object, except for:
268+
* 1. Properties that were set before {@link #envSetupCompleted} was invoked.
269+
* 2. Properties protected by {@link #protectProperties}.
270+
*/
271+
clear(): void {
272+
for (const {property, value} of [
273+
...[...this.propertyToValue.entries()].map(([property, value]) => ({
274+
property,
275+
value,
276+
})),
277+
...this.leftovers,
278+
]) {
279+
/*
280+
* react-native invoke its custom `performance` property after env teardown.
281+
* its setup file should use `protectProperties` to prevent this.
282+
*/
283+
if (property !== 'performance') {
284+
deleteProperties(value);
285+
}
286+
}
287+
this.propertyToValue.clear();
288+
this.leftovers = [];
289+
this.global = {} as typeof globalThis;
290+
this.globalProxy = {} as typeof globalThis;
291+
}
292+
293+
defineProperty(
294+
target: typeof globalThis,
295+
property: string | symbol,
296+
attributes: PropertyDescriptor,
297+
): boolean {
298+
const newAttributes = {...attributes};
299+
300+
if ('set' in newAttributes && newAttributes.set !== undefined) {
301+
const originalSet = newAttributes.set;
302+
const register = this.register;
303+
newAttributes.set = value => {
304+
originalSet(value);
305+
const newValue = Reflect.get(target, property);
306+
register(property, newValue);
307+
};
308+
}
309+
310+
const result = Reflect.defineProperty(target, property, newAttributes);
311+
312+
if ('value' in newAttributes) {
313+
this.register(property, newAttributes.value);
314+
}
315+
316+
return result;
317+
}
318+
319+
deleteProperty(
320+
target: typeof globalThis,
321+
property: string | symbol,
322+
): boolean {
323+
const result = Reflect.deleteProperty(target, property);
324+
const value = this.propertyToValue.get(property);
325+
if (value) {
326+
this.leftovers.push({property, value});
327+
this.propertyToValue.delete(property);
328+
}
329+
return result;
330+
}
331+
332+
private register(property: string | symbol, value: unknown) {
333+
const currentValue = this.propertyToValue.get(property);
334+
if (value !== currentValue) {
335+
if (!this.isEnvSetup && canDeleteProperties(value)) {
336+
protectProperties(value);
337+
}
338+
if (currentValue) {
339+
this.leftovers.push({property, value: currentValue});
340+
}
341+
342+
this.propertyToValue.set(property, value);
343+
}
344+
}
345+
}

packages/jest-repl/src/cli/runtime-cli.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ export async function run(
9999
},
100100
{console: customConsole, docblockPragmas: {}, testPath: filePath},
101101
);
102-
setGlobal(environment.global, 'console', customConsole);
103-
setGlobal(environment.global, 'jestProjectConfig', projectConfig);
104-
setGlobal(environment.global, 'jestGlobalConfig', globalConfig);
102+
setGlobal(environment.global, 'console', customConsole, 'retain');
103+
setGlobal(environment.global, 'jestProjectConfig', projectConfig, 'retain');
104+
setGlobal(environment.global, 'jestGlobalConfig', globalConfig, 'retain');
105105

106106
const runtime = new Runtime(
107107
projectConfig,

packages/jest-runner/src/runTest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ async function runTestInternal(
184184
? new LeakDetector(environment)
185185
: null;
186186

187-
setGlobal(environment.global, 'console', testConsole);
187+
setGlobal(environment.global, 'console', testConsole, 'retain');
188188

189189
const runtime = new Runtime(
190190
projectConfig,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
const PROTECT_PROPERTY = Symbol.for('$$jest-protect-from-deletion');
9+
10+
/**
11+
* Deletes all the properties from the given value (if it's an object),
12+
* unless the value was protected via {@link #protectProperties}.
13+
*
14+
* @param value the given value.
15+
*/
16+
export function deleteProperties(value: unknown): void {
17+
if (canDeleteProperties(value)) {
18+
const protectedProperties = Reflect.get(value, PROTECT_PROPERTY);
19+
if (!Array.isArray(protectedProperties) || protectedProperties.length > 0) {
20+
for (const key of Reflect.ownKeys(value)) {
21+
if (!protectedProperties?.includes(key)) {
22+
Reflect.deleteProperty(value, key);
23+
}
24+
}
25+
}
26+
}
27+
}
28+
29+
/**
30+
* Protects the given value from being deleted by {@link #deleteProperties}.
31+
*
32+
* @param value The given value.
33+
* @param properties If the array contains any property,
34+
* then only these properties will not be deleted; otherwise if the array is empty,
35+
* all properties will not be deleted.
36+
*/
37+
export function protectProperties<T extends object>(
38+
value: T,
39+
properties: Array<keyof T> = [],
40+
): boolean {
41+
if (canDeleteProperties(value)) {
42+
return Reflect.set(value, PROTECT_PROPERTY, properties);
43+
}
44+
return false;
45+
}
46+
47+
/**
48+
* Whether the given value has properties that can be deleted (regardless of protection).
49+
*
50+
* @param value The given value.
51+
*/
52+
export function canDeleteProperties(value: unknown): value is object {
53+
return value !== null && ['object', 'function'].includes(typeof value);
54+
}

packages/jest-util/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@ export {default as tryRealpath} from './tryRealpath';
2929
export {default as requireOrImportModule} from './requireOrImportModule';
3030
export {default as invariant} from './invariant';
3131
export {default as isNonNullable} from './isNonNullable';
32+
export {
33+
canDeleteProperties,
34+
protectProperties,
35+
deleteProperties,
36+
} from './garbage-collection-utils';

packages/jest-util/src/setGlobal.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@
66
*/
77

88
import type {Global} from '@jest/types';
9+
import {
10+
canDeleteProperties,
11+
protectProperties,
12+
} from './garbage-collection-utils';
913

1014
export default function setGlobal(
1115
globalToMutate: typeof globalThis | Global.Global,
12-
key: string,
16+
key: string | symbol,
1317
value: unknown,
18+
afterTeardown: 'clean' | 'retain' = 'clean',
1419
): void {
15-
// @ts-expect-error: no index
16-
globalToMutate[key] = value;
20+
Reflect.set(globalToMutate, key, value);
21+
if (afterTeardown === 'retain' && canDeleteProperties(value)) {
22+
protectProperties(value);
23+
}
1724
}

0 commit comments

Comments
 (0)