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 3 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
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
18 changes: 18 additions & 0 deletions docs/Configuration.md
Expand Up @@ -2267,3 +2267,21 @@ This option allows comments in `package.json`. Include the comment text as the v
}
}
```

### `workerIdleMemoryLimit` \[number]
phawxby marked this conversation as resolved.
Show resolved Hide resolved

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 2 ways

* < 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

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

module.exports = config;
```
phawxby marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions packages/jest-runner/src/index.ts
Expand Up @@ -107,6 +107,7 @@ export default class TestRunner extends EmittingTestRunner {
const worker = new Worker(require.resolve('./testWorker'), {
exposedMethods: ['worker'],
forkOptions: {serialization: 'json', stdio: 'pipe'},
idleMemoryLimit: this._globalConfig.workerIdleMemoryLimit,
maxRetries: 3,
numWorkers: this._globalConfig.maxWorkers,
setupArgs: [{serializableResolvers: Array.from(resolvers.values())}],
Expand Down
4 changes: 4 additions & 0 deletions packages/jest-types/src/Config.ts
Expand Up @@ -327,6 +327,7 @@ export type InitialOptions = Partial<{
watchAll: boolean;
watchman: boolean;
watchPlugins: Array<string | [string, Record<string, unknown>]>;
workerIdleMemoryLimit: number;
}>;

export type SnapshotUpdateState = 'all' | 'new' | 'none';
Expand Down Expand Up @@ -420,6 +421,7 @@ export type GlobalConfig = {
path: string;
config: Record<string, unknown>;
}> | null;
workerIdleMemoryLimit?: number;
};

export type ProjectConfig = {
Expand Down Expand Up @@ -478,6 +480,7 @@ export type ProjectConfig = {
transformIgnorePatterns: Array<string>;
watchPathIgnorePatterns: Array<string>;
unmockedModulePathPatterns?: Array<string>;
workerIdleMemoryLimit?: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be part of global config, not project

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the case for both. You may want to set a sensible global default perhaps running on a machine with known memory usage limitations but then configure specifically for projects where you may use less threads and allow more memory. In all honesty I could go either way, if you want it removing I can remove it.

};

export type Argv = Arguments<
Expand Down Expand Up @@ -571,5 +574,6 @@ export type Argv = Arguments<
watchAll: boolean;
watchman: boolean;
watchPathIgnorePatterns: Array<string>;
workerIdleMemoryLimit: number;
}>
>;
39 changes: 38 additions & 1 deletion packages/jest-worker/src/__tests__/process-integration.test.js
Expand Up @@ -6,7 +6,12 @@
*/

import EventEmitter from 'events';
import {CHILD_MESSAGE_CALL, PARENT_MESSAGE_OK} from '../types';
import {
CHILD_MESSAGE_CALL,
CHILD_MESSAGE_MEM_USAGE,
PARENT_MESSAGE_OK,
WorkerFarmOptions,
} from '../types';

let Farm;
let mockForkedProcesses;
Expand Down Expand Up @@ -175,4 +180,36 @@ describe('Jest Worker Integration', () => {
expect(await promise1).toBe('worker-1');
expect(await promise2).toBe('worker-2');
});

it('should check for memory limits', async () => {
/** @type WorkerFarmOptions */
const options = {
computeWorkerKey: () => '1234567890abcdef',
exposedMethods: ['foo', 'bar'],
idleMemoryLimit: 0.4,
numWorkers: 2,
};

const farm = new Farm('/tmp/baz.js', options);

// Send a call to the farm
const promise0 = farm.foo('param-0');

// Send different responses for each call (from the same child).
replySuccess(0, 'worker-0');

// Check that all the calls have been received by the same child).
// We're not using the assertCallsToChild helper because we need to check
// for other send types.
expect(mockForkedProcesses[0].send).toHaveBeenCalledTimes(3);
expect(mockForkedProcesses[0].send.mock.calls[1][0]).toEqual([
CHILD_MESSAGE_CALL,
true,
'foo',
['param-0'],
]);
expect(mockForkedProcesses[0].send.mock.calls[2][0]).toEqual([
CHILD_MESSAGE_MEM_USAGE,
]);
});
});
1 change: 1 addition & 0 deletions packages/jest-worker/src/base/BaseWorkerPool.ts
Expand Up @@ -40,6 +40,7 @@ export default class BaseWorkerPool {
for (let i = 0; i < options.numWorkers; i++) {
const workerOptions: WorkerOptions = {
forkOptions,
idleMemoryLimit: this._options.idleMemoryLimit,
maxRetries,
resourceLimits,
setupArgs,
Expand Down
1 change: 1 addition & 0 deletions packages/jest-worker/src/index.ts
Expand Up @@ -96,6 +96,7 @@ export class Worker {
const workerPoolOptions: WorkerPoolOptions = {
enableWorkerThreads: this._options.enableWorkerThreads ?? false,
forkOptions: this._options.forkOptions ?? {},
idleMemoryLimit: this._options.idleMemoryLimit,
maxRetries: this._options.maxRetries ?? 3,
numWorkers: this._options.numWorkers ?? Math.max(cpus().length - 1, 1),
resourceLimits: this._options.resourceLimits ?? {},
Expand Down
20 changes: 18 additions & 2 deletions packages/jest-worker/src/types.ts
Expand Up @@ -36,11 +36,13 @@ export type WorkerModule<T> = {
export const CHILD_MESSAGE_INITIALIZE = 0;
export const CHILD_MESSAGE_CALL = 1;
export const CHILD_MESSAGE_END = 2;
export const CHILD_MESSAGE_MEM_USAGE = 3;

export const PARENT_MESSAGE_OK = 0;
export const PARENT_MESSAGE_CLIENT_ERROR = 1;
export const PARENT_MESSAGE_SETUP_ERROR = 2;
export const PARENT_MESSAGE_CUSTOM = 3;
export const PARENT_MESSAGE_MEM_USAGE = 4;

export type PARENT_MESSAGE_ERROR =
| typeof PARENT_MESSAGE_CLIENT_ERROR
Expand Down Expand Up @@ -122,6 +124,7 @@ export type WorkerFarmOptions = {
options?: WorkerPoolOptions,
) => WorkerPoolInterface;
workerSchedulingPolicy?: WorkerSchedulingPolicy;
idleMemoryLimit?: number;
};

export type WorkerPoolOptions = {
Expand All @@ -131,6 +134,7 @@ export type WorkerPoolOptions = {
maxRetries: number;
numWorkers: number;
enableWorkerThreads: boolean;
idleMemoryLimit?: number;
};

export type WorkerOptions = {
Expand All @@ -141,6 +145,7 @@ export type WorkerOptions = {
workerId: number;
workerData?: unknown;
workerPath: string;
idleMemoryLimit?: number;
};

// Messages passed from the parent to the children.
Expand Down Expand Up @@ -174,10 +179,15 @@ export type ChildMessageEnd = [
boolean, // processed
];

export type ChildMessageMemUsage = [
typeof CHILD_MESSAGE_MEM_USAGE, // type
];

export type ChildMessage =
| ChildMessageInitialize
| ChildMessageCall
| ChildMessageEnd;
| ChildMessageEnd
| ChildMessageMemUsage;

// Messages passed from the children to the parent.

Expand All @@ -191,6 +201,11 @@ export type ParentMessageOk = [
unknown, // result
];

export type ParentMessageMemUsage = [
typeof PARENT_MESSAGE_MEM_USAGE, // type
number, // used memory in bytes
];

export type ParentMessageError = [
PARENT_MESSAGE_ERROR, // type
string, // constructor
Expand All @@ -202,7 +217,8 @@ export type ParentMessageError = [
export type ParentMessage =
| ParentMessageOk
| ParentMessageError
| ParentMessageCustom;
| ParentMessageCustom
| ParentMessageMemUsage;

// Queue types.

Expand Down
61 changes: 52 additions & 9 deletions packages/jest-worker/src/workers/ChildProcessWorker.ts
Expand Up @@ -6,17 +6,20 @@
*/

import {ChildProcess, fork} from 'child_process';
import {totalmem} from 'os';
import {PassThrough} from 'stream';
import mergeStream = require('merge-stream');
import {stdout as stdoutSupportsColor} from 'supports-color';
import {
CHILD_MESSAGE_INITIALIZE,
CHILD_MESSAGE_MEM_USAGE,
ChildMessage,
OnCustomMessage,
OnEnd,
OnStart,
PARENT_MESSAGE_CLIENT_ERROR,
PARENT_MESSAGE_CUSTOM,
PARENT_MESSAGE_MEM_USAGE,
PARENT_MESSAGE_OK,
PARENT_MESSAGE_SETUP_ERROR,
ParentMessage,
Expand Down Expand Up @@ -64,6 +67,9 @@ export default class ChildProcessWorker implements WorkerInterface {

private _exitPromise: Promise<void>;
private _resolveExitPromise!: () => void;
private _childIdleMemoryUsage: number | null;
private _childIdleMemoryUsageLimit: number | null;
private _restarting = false;

constructor(options: WorkerOptions) {
this._options = options;
Expand All @@ -73,6 +79,8 @@ export default class ChildProcessWorker implements WorkerInterface {
this._fakeStream = null;
this._stdout = null;
this._stderr = null;
this._childIdleMemoryUsage = null;
this._childIdleMemoryUsageLimit = options.idleMemoryLimit || null;

this._exitPromise = new Promise(resolve => {
this._resolveExitPromise = resolve;
Expand All @@ -82,6 +90,8 @@ export default class ChildProcessWorker implements WorkerInterface {
}

initialize(): void {
this._restarting = false;

const forceColor = stdoutSupportsColor ? {FORCE_COLOR: '1'} : {};
const child = fork(require.resolve('./processChild'), [], {
cwd: process.cwd(),
Expand Down Expand Up @@ -201,17 +211,44 @@ export default class ChildProcessWorker implements WorkerInterface {
case PARENT_MESSAGE_CUSTOM:
this._onCustomMessage(response[1]);
break;

case PARENT_MESSAGE_MEM_USAGE:
this._childIdleMemoryUsage = response[1];

this._performRestartIfRequired();

break;

default:
throw new TypeError(`Unexpected response from worker: ${response[0]}`);
}
}

private _performRestartIfRequired(): void {
let limit = this._childIdleMemoryUsageLimit;

if (limit && limit > 0 && limit <= 1) {
limit = Math.floor(totalmem() * limit);
}

if (
limit &&
this._childIdleMemoryUsage &&
this._childIdleMemoryUsage > limit
) {
this._restarting = true;

this.killChild();
}
}

private _onExit(exitCode: number | null, signal: NodeJS.Signals | null) {
if (
exitCode !== 0 &&
exitCode !== null &&
exitCode !== SIGTERM_EXIT_CODE &&
exitCode !== SIGKILL_EXIT_CODE
(exitCode !== 0 &&
exitCode !== null &&
exitCode !== SIGTERM_EXIT_CODE &&
exitCode !== SIGKILL_EXIT_CODE) ||
this._restarting
) {
this.initialize();

Expand Down Expand Up @@ -241,7 +278,12 @@ export default class ChildProcessWorker implements WorkerInterface {
onCustomMessage: OnCustomMessage,
): void {
onProcessStart(this);

this._onProcessEnd = (...args) => {
if (this._childIdleMemoryUsageLimit) {
this._child.send([CHILD_MESSAGE_MEM_USAGE]);
}

// Clean the request to avoid sending past requests to workers that fail
// while waiting for a new request (timers, unhandled rejections...)
this._request = null;
Expand All @@ -260,12 +302,13 @@ export default class ChildProcessWorker implements WorkerInterface {
return this._exitPromise;
}

forceExit(): void {
killChild(): NodeJS.Timeout {
this._child.kill('SIGTERM');
const sigkillTimeout = setTimeout(
() => this._child.kill('SIGKILL'),
SIGKILL_DELAY,
);
return setTimeout(() => this._child.kill('SIGKILL'), SIGKILL_DELAY);
}

forceExit(): void {
const sigkillTimeout = this.killChild();
this._exitPromise.then(() => clearTimeout(sigkillTimeout));
}

Expand Down