Skip to content

Commit 2c95e2d

Browse files
committed
Fix memory leak (all solutions combined)
1 parent b113b44 commit 2c95e2d

File tree

7 files changed

+236
-19
lines changed

7 files changed

+236
-19
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: 145 additions & 4 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
@@ -206,9 +214,29 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
206214
if (this.fakeTimersModern) {
207215
this.fakeTimersModern.dispose();
208216
}
209-
this.context = null;
217+
218+
if (this.context) {
219+
// source-map-support keeps memory leftovers in `Error.prepareStackTrace`
220+
runInContext("Error.prepareStackTrace = () => '';", this.context);
221+
222+
// remove any leftover listeners that may hold references to sizable memory
223+
this.context.process.removeAllListeners();
224+
const cluster = runInContext(
225+
"require('node:cluster')",
226+
Object.assign(this.context, {
227+
require:
228+
// get native require instead of webpack's
229+
// @ts-expect-error https://webpack.js.org/api/module-variables/#__non_webpack_require__-webpack-specific
230+
__non_webpack_require__,
231+
}),
232+
);
233+
cluster.removeAllListeners();
234+
235+
this.context = null;
236+
}
210237
this.fakeTimers = null;
211238
this.fakeTimersModern = null;
239+
this._globalProxy.clear();
212240
}
213241

214242
exportConditions(): Array<string> {
@@ -221,3 +249,116 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
221249
}
222250

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

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: 7 additions & 3 deletions
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,
@@ -312,8 +312,12 @@ async function runTestInternal(
312312
sendMessageToJest,
313313
);
314314
} catch (error: any) {
315-
// Access stack before uninstalling sourcemaps
316-
error.stack;
315+
// Access all stacks before uninstalling sourcemaps
316+
let e = error;
317+
while (typeof e === 'object' && 'stack' in e) {
318+
e.stack;
319+
e = e?.cause ?? {};
320+
}
317321

318322
throw error;
319323
} finally {

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)