Skip to content

Commit

Permalink
feat: add workerIdleMemoryLimit to support worker recycling in the ev…
Browse files Browse the repository at this point in the history
…ent of node >16.11.0 memory leaks (#13056)
  • Loading branch information
phawxby committed Aug 5, 2022
1 parent 5761f0f commit 37200eb
Show file tree
Hide file tree
Showing 25 changed files with 1,358 additions and 38 deletions.
6 changes: 5 additions & 1 deletion .circleci/config.yml
Expand Up @@ -42,7 +42,11 @@ jobs:
node-version: lts/*
- node/install-packages: *install
- run:
command: JEST_JASMINE=1 yarn test-ci-partial --shard=$(expr $CIRCLE_NODE_INDEX + 1)/$CIRCLE_NODE_TOTAL && JEST_JASMINE=1 yarn test-leak
name: Test
command: JEST_JASMINE=1 yarn test-ci-partial --shard=$(expr $CIRCLE_NODE_INDEX + 1)/$CIRCLE_NODE_TOTAL
- run:
name: Leak test
command: JEST_JASMINE=1 yarn test-leak
- store_test_results:
path: reports/junit

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Expand Up @@ -53,3 +53,6 @@ api-extractor.json
**/.pnp.*

crowdin-cli.jar

# We don't want these temp files
packages/jest-worker/src/workers/__tests__/__temp__
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@
- `[jest-config]` [**BREAKING**] Make `snapshotFormat` default to `escapeString: false` and `printBasicPrototype: false` ([#13036](https://github.com/facebook/jest/pull/13036))
- `[jest-environment-jsdom]` [**BREAKING**] Upgrade to `jsdom@20` ([#13037](https://github.com/facebook/jest/pull/13037), [#13058](https://github.com/facebook/jest/pull/13058))
- `[pretty-format]` [**BREAKING**] Remove `ConvertAnsi` plugin in favour of `jest-serializer-ansi-escapes` ([#13040](https://github.com/facebook/jest/pull/13040))
- `[jest-worker]` Adds `workerIdleMemoryLimit` option which is used as a check for worker memory leaks >= Node 16.11.0 and recycles child workers as required. ([#13056](https://github.com/facebook/jest/pull/13056))

### Fixes

Expand Down
39 changes: 39 additions & 0 deletions docs/Configuration.md
Expand Up @@ -2254,6 +2254,45 @@ Default: `true`

Whether to use [`watchman`](https://facebook.github.io/watchman/) for file crawling.

### `workerIdleMemoryLimit` \[number|string]

Default: `undefined`

Specifies the memory limit for workers before they are recycled and is primarily a work-around for [this issue](https://github.com/facebook/jest/issues/11956);

After the worker has executed a test the memory usage of it is checked. If it exceeds the value specified the worker is killed and restarted. The limit can be specified in a number of different ways and whatever the result is `Math.floor` is used to turn it into an integer value:

- `<= 1` - The value is assumed to be a percentage of system memory. So 0.5 sets the memory limit of the worker to half of the total system memory
- `\> 1` - Assumed to be a fixed byte value. Because of the previous rule if you wanted a value of 1 byte (I don't know why) you could use `1.1`.
- With units
- `50%` - As above, a percentage of total system memory
- `100KB`, `65MB`, etc - With units to denote a fixed memory limit.
- `K` / `KB` - Kilobytes (x1000)
- `KiB` - Kibibytes (x1024)
- `M` / `MB` - Megabytes
- `MiB` - Mebibytes
- `G` / `GB` - Gigabytes
- `GiB` - Gibibytes

```js tab
/** @type {import('jest').Config} */
const config = {
workerIdleMemoryLimit: 0.2,
};

module.exports = config;
```

```ts tab
import type {Config} from 'jest';

const config: Config = {
workerIdleMemoryLimit: 0.2,
};

export default config;
```

### `//` \[string]

This option allows comments in `package.json`. Include the comment text as the value of this key:
Expand Down
2 changes: 1 addition & 1 deletion e2e/__tests__/workerForceExit.test.ts
Expand Up @@ -74,4 +74,4 @@ test('force exits a worker that fails to exit gracefully', async () => {
expect(pidNumber).not.toBeNaN();

expect(await findProcess('pid', pidNumber)).toHaveLength(0);
});
}, 15000);
6 changes: 5 additions & 1 deletion jest.config.mjs
Expand Up @@ -66,14 +66,18 @@ export default {
'/packages/jest-snapshot/src/__tests__/plugins',
'/packages/jest-snapshot/src/__tests__/fixtures/',
'/packages/jest-validate/src/__tests__/fixtures/',
'/packages/jest-worker/src/workers/__tests__/__fixtures__/',
'/e2e/__tests__/iterator-to-null-test.ts',
'/e2e/__tests__/tsIntegration.test.ts', // this test needs types to be build, it runs in a separate CI job through `jest.config.ts.mjs`
],
testTimeout: 70000,
transform: {
'\\.[jt]sx?$': require.resolve('babel-jest'),
},
watchPathIgnorePatterns: ['coverage'],
watchPathIgnorePatterns: [
'coverage',
'<rootDir>/packages/jest-worker/src/workers/__tests__/__temp__',
],
watchPlugins: [
require.resolve('jest-watch-typeahead/filename'),
require.resolve('jest-watch-typeahead/testname'),
Expand Down
12 changes: 12 additions & 0 deletions packages/jest-config/src/__tests__/normalize.test.ts
Expand Up @@ -2112,3 +2112,15 @@ describe('logs a deprecation warning', () => {
expect(console.warn).toMatchSnapshot();
});
});

it('parses workerIdleMemoryLimit', async () => {
const {options} = await normalize(
{
rootDir: '/root/',
workerIdleMemoryLimit: '45MiB',
},
{} as Config.Argv,
);

expect(options.workerIdleMemoryLimit).toEqual(47185920);
});
127 changes: 127 additions & 0 deletions packages/jest-config/src/__tests__/stringToBytes.test.ts
@@ -0,0 +1,127 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import stringToBytes from '../stringToBytes';

describe('numeric input', () => {
test('> 1 represents bytes', () => {
expect(stringToBytes(50.8)).toEqual(50);
});

test('1.1 should be a 1', () => {
expect(stringToBytes(1.1, 54)).toEqual(1);
});

test('< 1 represents a %', () => {
expect(stringToBytes(0.3, 51)).toEqual(15);
});

test('should throw when no reference supplied', () => {
expect(() => stringToBytes(0.3)).toThrowError();
});

test('should throw on a bad input', () => {
expect(() => stringToBytes(-0.3, 51)).toThrowError();
});
});

describe('string input', () => {
describe('numeric passthrough', () => {
test('> 1 represents bytes', () => {
expect(stringToBytes('50.8')).toEqual(50);
});

test('< 1 represents a %', () => {
expect(stringToBytes('0.3', 51)).toEqual(15);
});

test('should throw when no reference supplied', () => {
expect(() => stringToBytes('0.3')).toThrowError();
});

test('should throw on a bad input', () => {
expect(() => stringToBytes('-0.3', 51)).toThrowError();
});
});

describe('parsing', () => {
test('0% should throw an error', () => {
expect(() => stringToBytes('0%', 51)).toThrowError();
});

test('30%', () => {
expect(stringToBytes('30%', 51)).toEqual(15);
});

test('80%', () => {
expect(stringToBytes('80%', 51)).toEqual(40);
});

test('100%', () => {
expect(stringToBytes('100%', 51)).toEqual(51);
});

// The units caps is intentionally janky to test for forgiving string parsing.
describe('k', () => {
test('30k', () => {
expect(stringToBytes('30K')).toEqual(30000);
});

test('30KB', () => {
expect(stringToBytes('30kB')).toEqual(30000);
});

test('30KiB', () => {
expect(stringToBytes('30kIb')).toEqual(30720);
});
});

describe('m', () => {
test('30M', () => {
expect(stringToBytes('30M')).toEqual(30000000);
});

test('30MB', () => {
expect(stringToBytes('30MB')).toEqual(30000000);
});

test('30MiB', () => {
expect(stringToBytes('30MiB')).toEqual(31457280);
});
});

describe('g', () => {
test('30G', () => {
expect(stringToBytes('30G')).toEqual(30000000000);
});

test('30GB', () => {
expect(stringToBytes('30gB')).toEqual(30000000000);
});

test('30GiB', () => {
expect(stringToBytes('30GIB')).toEqual(32212254720);
});
});

test('unknown unit', () => {
expect(() => stringToBytes('50XX')).toThrowError();
});
});
});

test('nesting', () => {
expect(stringToBytes(stringToBytes(stringToBytes('30%', 51)))).toEqual(15);
});

test('null', () => {
expect(stringToBytes(null)).toEqual(null);
});

test('undefined', () => {
expect(stringToBytes(undefined)).toEqual(undefined);
});
5 changes: 5 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -6,6 +6,7 @@
*/

import {createHash} from 'crypto';
import {totalmem} from 'os';
import * as path from 'path';
import chalk = require('chalk');
import merge = require('deepmerge');
Expand Down Expand Up @@ -36,6 +37,7 @@ import {DEFAULT_JS_PATTERN} from './constants';
import getMaxWorkers from './getMaxWorkers';
import {parseShardPair} from './parseShardPair';
import setFromArgv from './setFromArgv';
import stringToBytes from './stringToBytes';
import {
BULLET,
DOCUMENTATION_NOTE,
Expand Down Expand Up @@ -969,6 +971,9 @@ export default async function normalize(
case 'watchman':
value = oldOptions[key];
break;
case 'workerIdleMemoryLimit':
value = stringToBytes(oldOptions[key], totalmem());
break;
case 'watchPlugins':
value = (oldOptions[key] || []).map(watchPlugin => {
if (typeof watchPlugin === 'string') {
Expand Down
90 changes: 90 additions & 0 deletions packages/jest-config/src/stringToBytes.ts
@@ -0,0 +1,90 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

function stringToBytes(
input: undefined,
percentageReference?: number,
): undefined;
function stringToBytes(input: null, percentageReference?: number): null;
function stringToBytes(
input: string | number,
percentageReference?: number,
): number;

/**
* Converts a string representing an amount of memory to bytes.
*
* @param input The value to convert to bytes.
* @param percentageReference The reference value to use when a '%' value is supplied.
*/
function stringToBytes(
input: string | number | null | undefined,
percentageReference?: number,
): number | null | undefined {
if (input === null || input === undefined) {
return input;
}

if (typeof input === 'string') {
if (isNaN(Number.parseFloat(input.slice(-1)))) {
// eslint-disable-next-line prefer-const
let [, numericString, trailingChars] =
input.match(/(.*?)([^0-9.-]+)$/i) || [];

if (trailingChars && numericString) {
const numericValue = Number.parseFloat(numericString);
trailingChars = trailingChars.toLowerCase();

switch (trailingChars) {
case '%':
input = numericValue / 100;
break;
case 'kb':
case 'k':
return numericValue * 1000;
case 'kib':
return numericValue * 1024;
case 'mb':
case 'm':
return numericValue * 1000 * 1000;
case 'mib':
return numericValue * 1024 * 1024;
case 'gb':
case 'g':
return numericValue * 1000 * 1000 * 1000;
case 'gib':
return numericValue * 1024 * 1024 * 1024;
}
}

// It ends in some kind of char so we need to do some parsing
} else {
input = Number.parseFloat(input);
}
}

if (typeof input === 'number') {
if (input <= 1 && input > 0) {
if (percentageReference) {
return Math.floor(input * percentageReference);
} else {
throw new Error(
'For a percentage based memory limit a percentageReference must be supplied',
);
}
} else if (input > 1) {
return Math.floor(input);
} else {
throw new Error('Unexpected numerical input');
}
}

throw new Error('Unexpected input');
}

// https://github.com/import-js/eslint-plugin-import/issues/1590
export default stringToBytes;
6 changes: 6 additions & 0 deletions packages/jest-runner/src/index.ts
Expand Up @@ -107,6 +107,12 @@ export default class TestRunner extends EmittingTestRunner {
const worker = new Worker(require.resolve('./testWorker'), {
exposedMethods: ['worker'],
forkOptions: {serialization: 'json', stdio: 'pipe'},
// The workerIdleMemoryLimit should've been converted to a number during
// the normalization phase.
idleMemoryLimit:
typeof this._globalConfig.workerIdleMemoryLimit === 'number'
? this._globalConfig.workerIdleMemoryLimit
: undefined,
maxRetries: 3,
numWorkers: this._globalConfig.maxWorkers,
setupArgs: [{serializableResolvers: Array.from(resolvers.values())}],
Expand Down

0 comments on commit 37200eb

Please sign in to comment.