Skip to content
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

feat: add workerIdleMemoryLimit to support worker recycling in the event of node >16.11.0 memory leaks #13056

Merged
merged 51 commits into from Aug 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3e4c26b
feat: process recycling
phawxby Jul 22, 2022
833e429
chore: changelog
phawxby Jul 22, 2022
7da8127
chore: remove temp files
phawxby Jul 22, 2022
40a23cf
test: more complete functional testing
phawxby Jul 25, 2022
1961d65
docs: update docs for PR review
phawxby Jul 25, 2022
d7f292f
chore: try this test config
phawxby Jul 25, 2022
8bfcd74
chore: linting
phawxby Jul 25, 2022
fd1be6f
feat: add improved error checking and promise handling
phawxby Jul 25, 2022
233477e
chore: add facebook header
phawxby Jul 25, 2022
19fb71c
fix: use os spy rather than mock to retain platform functions
phawxby Jul 25, 2022
e2a853d
fix: spying of totalmem
phawxby Jul 25, 2022
ab6554d
chore: add some logging to help debugging
phawxby Jul 25, 2022
207ae05
chore: more debugging
phawxby Jul 25, 2022
c4d2389
chore: more debugging
phawxby Jul 25, 2022
0f5c7bb
chore: check files exist
phawxby Jul 25, 2022
d35e4b9
chore: try as a specific js import
phawxby Jul 25, 2022
daee499
chore: try this
phawxby Jul 26, 2022
fd96e78
chore: remove debugging and cleanup
phawxby Jul 26, 2022
576ca63
chore: debug failing test
phawxby Jul 26, 2022
491b16c
fix: windows tests
phawxby Jul 26, 2022
479c123
chore: use verbose output to help
phawxby Jul 26, 2022
ad238f2
Merge branch 'process-recycling' of https://github.com/phawxby/jest i…
phawxby Jul 26, 2022
e3e63e4
chore: disable silent reporter
phawxby Jul 26, 2022
74ce892
chore: temporary change to allow me to see where the tests are stalling
phawxby Jul 27, 2022
8467b8b
chore: set sensible timeout
phawxby Jul 27, 2022
6338419
chore: there's an argument for what i want to do
phawxby Jul 27, 2022
d2acdc3
chore: try this
phawxby Jul 27, 2022
dea2ef2
chore: remove now I have the test order
phawxby Jul 27, 2022
44ff11d
chore: single thread to track down the failing test suite
phawxby Jul 27, 2022
6b3ba00
chore: does skipping this test make the timeouts go away?
phawxby Jul 27, 2022
f208aa9
fix: fatal but not out of memory errors should retry
phawxby Jul 27, 2022
1ca06bc
fix: out of memory test
phawxby Jul 27, 2022
c98dd32
chore: increase timeout
phawxby Jul 27, 2022
c21b48e
chore: debugging output
phawxby Jul 27, 2022
33405a2
Merge branch 'process-recycling' of https://github.com/phawxby/jest i…
phawxby Jul 27, 2022
98f5e82
chore: limit to just failing test and add logging
phawxby Jul 27, 2022
a1b647f
chore: more debugging
phawxby Jul 27, 2022
54dccb3
feat: add stderr concat
phawxby Jul 27, 2022
0f5da7d
chore: back to full tests
phawxby Jul 27, 2022
debf034
test: fix use simple count in case same pid comes up twice
phawxby Jul 27, 2022
4619055
test: add retry and increase timeout
phawxby Jul 27, 2022
da47b50
test: more retries
phawxby Jul 27, 2022
ba00b26
chore: revert changes to jest config
phawxby Jul 27, 2022
019c5d4
feat: port idle usage to thread workers
phawxby Jul 28, 2022
7239650
chore: docs
phawxby Jul 28, 2022
fdb4a66
chore: linting
phawxby Jul 28, 2022
3e3be99
feat: add shorthand idle limit parsing
phawxby Aug 1, 2022
9f9dff1
docs: copyright header
phawxby Aug 1, 2022
43f1054
chore: improve test flake
phawxby Aug 1, 2022
791b7a5
chore: fix typing
phawxby Aug 2, 2022
f3db1cc
chore: pr feedback
phawxby Aug 4, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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))
- `[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;
```
phawxby marked this conversation as resolved.
Show resolved Hide resolved

```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 @@
/**
phawxby marked this conversation as resolved.
Show resolved Hide resolved
* 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