Skip to content

Commit 05592c7

Browse files
committed
#15215 Reduce memory leak in node env by shredding global properties in teardown
1 parent 213c3b2 commit 05592c7

File tree

7 files changed

+210
-16
lines changed

7 files changed

+210
-16
lines changed

packages/jest-circus/src/state.ts

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

88
import type {Circus, Global} from '@jest/types';
9+
import {setGlobal, setNotShreddable} from 'jest-util';
910
import eventHandler from './eventHandler';
1011
import formatNodeAssertErrors from './formatNodeAssertErrors';
1112
import {STATE_SYM} from './types';
@@ -39,16 +40,28 @@ const createState = (): Circus.State => {
3940
};
4041

4142
/* eslint-disable no-restricted-globals */
43+
export const getState = (): Circus.State =>
44+
(global as Global.Global)[STATE_SYM] as Circus.State;
45+
export const setState = (state: Circus.State): Circus.State => {
46+
setGlobal(global, STATE_SYM, state);
47+
setNotShreddable(state, [
48+
'hasFocusedTests',
49+
'hasStarted',
50+
'includeTestLocationInResult',
51+
'maxConcurrency',
52+
'seed',
53+
'testNamePattern',
54+
'testTimeout',
55+
'unhandledErrors',
56+
'unhandledRejectionErrorByPromise',
57+
]);
58+
return state;
59+
};
4260
export const resetState = (): void => {
43-
(global as Global.Global)[STATE_SYM] = createState();
61+
setState(createState());
4462
};
4563

4664
resetState();
47-
48-
export const getState = (): Circus.State =>
49-
(global as Global.Global)[STATE_SYM] as Circus.State;
50-
export const setState = (state: Circus.State): Circus.State =>
51-
((global as Global.Global)[STATE_SYM] = state);
5265
/* eslint-enable */
5366

5467
export const dispatch = async (event: Circus.AsyncEvent): Promise<void> => {

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+
installCommonGlobals,
19+
isShreddable,
20+
setNotShreddable,
21+
shred,
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 #shred} them at environment teardown, to clean up memory and
239+
* 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 shredded at teardown.
261+
*/
262+
envSetupCompleted(): void {
263+
this.isEnvSetup = true;
264+
}
265+
266+
/**
267+
* Shreds 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 #setNotShreddable}.
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 `setNotShreddable` to prevent this.
282+
*/
283+
if (property !== 'performance') {
284+
shred(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 && isShreddable(value)) {
336+
setNotShreddable(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, false);
103+
setGlobal(environment.global, 'jestProjectConfig', projectConfig, false);
104+
setGlobal(environment.global, 'jestGlobalConfig', globalConfig, false);
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
@@ -183,7 +183,7 @@ async function runTestInternal(
183183
? new LeakDetector(environment)
184184
: null;
185185

186-
setGlobal(environment.global, 'console', testConsole);
186+
setGlobal(environment.global, 'console', testConsole, false);
187187

188188
const runtime = new Runtime(
189189
projectConfig,

packages/jest-util/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ 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 {isShreddable, setNotShreddable, shred} from './shredder';

packages/jest-util/src/setGlobal.ts

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

88
import type {Global} from '@jest/types';
9+
import {isShreddable, setNotShreddable} from './shredder';
910

1011
export default function setGlobal(
1112
globalToMutate: typeof globalThis | Global.Global,
12-
key: string,
13+
key: string | symbol,
1314
value: unknown,
15+
shredAfterTeardown = true,
1416
): void {
15-
// @ts-expect-error: no index
16-
globalToMutate[key] = value;
17+
Reflect.set(globalToMutate, key, value);
18+
if (!shredAfterTeardown && isShreddable(value)) {
19+
setNotShreddable(value);
20+
}
1721
}

packages/jest-util/src/shredder.ts

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 NO_SHRED_AFTER_TEARDOWN = Symbol.for('$$jest-no-shred');
9+
10+
/**
11+
* Deletes all the properties from the given value (if it's an object),
12+
* unless the value was protected via {@link #setNotShreddable}.
13+
*
14+
* @param value the given value.
15+
*/
16+
export function shred(value: unknown): void {
17+
if (isShreddable(value)) {
18+
const protectedProperties = Reflect.get(value, NO_SHRED_AFTER_TEARDOWN);
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 shredded by {@link #shred}.
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 setNotShreddable<T extends object>(
38+
value: T,
39+
properties: Array<keyof T> = [],
40+
): boolean {
41+
if (isShreddable(value)) {
42+
return Reflect.set(value, NO_SHRED_AFTER_TEARDOWN, properties);
43+
}
44+
return false;
45+
}
46+
47+
/**
48+
* Whether the given value is possible to be shredded.
49+
*
50+
* @param value The given value.
51+
*/
52+
export function isShreddable(value: unknown): value is object {
53+
return value !== null && ['object', 'function'].includes(typeof value);
54+
}

0 commit comments

Comments
 (0)