Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Features

- `[jest-config, jest-runner, jest-worker]` Add `workerGracefulExitTimeout` config option to control how long workers are given to exit before being force-killed ([#XXXX](https://github.com/jestjs/jest/pull/XXXX))
- `[jest-config]` Add `defineConfig` and `mergeConfig` helpers for type-safe Jest config ([#15844](https://github.com/jestjs/jest/pull/15844))
- `[jest-fake-timers]` Add `setTimerTickMode` to configure how timers advance

Expand Down
1 change: 1 addition & 0 deletions e2e/__tests__/__snapshots__/showConfig.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ exports[`--showConfig outputs config info and exits 1`] = `
"watch": false,
"watchAll": false,
"watchman": true,
"workerGracefulExitTimeout": 500,
"workerThreads": false
},
"version": "[version]"
Expand Down
8 changes: 8 additions & 0 deletions packages/jest-cli/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,14 @@ export const options: {[key: string]: Options} = {
'--no-watchman.',
type: 'boolean',
},
workerGracefulExitTimeout: {
description:
'Timeout in milliseconds for worker processes to exit gracefully ' +
'after tests complete. Workers that do not exit in time are ' +
'force-killed. Default: 500.',
requiresArg: true,
type: 'number',
},
workerThreads: {
description:
'Whether to use worker threads for parallelization. Child processes ' +
Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/src/Defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const defaultOptions: Config.DefaultOptions = {
watch: false,
watchPathIgnorePatterns: [],
watchman: true,
workerGracefulExitTimeout: 500,
workerThreads: false,
};

Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/src/ValidConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export const initialOptions: Config.InitialOptions = {
],
],
watchman: true,
workerGracefulExitTimeout: 500,
workerIdleMemoryLimit: multipleValidOptions(0.2, '50%'),
workerThreads: true,
};
Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ const groupOptions = (
watchAll: options.watchAll,
watchPlugins: options.watchPlugins,
watchman: options.watchman,
workerGracefulExitTimeout: options.workerGracefulExitTimeout,
workerIdleMemoryLimit: options.workerIdleMemoryLimit,
workerThreads: options.workerThreads,
}),
Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ export default async function normalize(
case 'watch':
case 'watchAll':
case 'watchman':
case 'workerGracefulExitTimeout':
case 'workerThreads':
value = oldOptions[key];
break;
Expand Down
1 change: 1 addition & 0 deletions packages/jest-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export default class TestRunner extends EmittingTestRunner {
maxRetries: 3,
numWorkers: this._globalConfig.maxWorkers,
setupArgs: [{serializableResolvers: [...resolvers.values()]}],
workerGracefulExitTimeout: this._globalConfig.workerGracefulExitTimeout,
}) as JestWorkerFarm<TestWorker>;

if (worker.getStdout()) worker.getStdout().pipe(process.stdout);
Expand Down
4 changes: 4 additions & 0 deletions packages/jest-schemas/src/raw-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,5 +350,9 @@ export const InitialOptions = Type.Partial(
),
workerIdleMemoryLimit: Type.Union([Type.Number(), Type.String()]),
workerThreads: Type.Boolean(),
workerGracefulExitTimeout: Type.Number({
description:
'Timeout in milliseconds for worker processes to exit gracefully after tests complete. Workers that do not exit in time are force-killed. Default: 500.',
}),
}),
);
3 changes: 3 additions & 0 deletions packages/jest-types/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export type DefaultOptions = {
watch: boolean;
watchPathIgnorePatterns: Array<string>;
watchman: boolean;
workerGracefulExitTimeout: number;
workerThreads: boolean;
};

Expand Down Expand Up @@ -321,6 +322,7 @@ export type GlobalConfig = {
path: string;
config: Record<string, unknown>;
}> | null;
workerGracefulExitTimeout?: number;
workerIdleMemoryLimit?: number;
// TODO: make non-optional in Jest 30
workerThreads?: boolean;
Expand Down Expand Up @@ -490,6 +492,7 @@ export type Argv = Arguments<
watchAll: boolean;
watchman: boolean;
watchPathIgnorePatterns: Array<string>;
workerGracefulExitTimeout: number;
workerIdleMemoryLimit: number | string;
workerThreads: boolean;
}>
Expand Down
6 changes: 1 addition & 5 deletions packages/jest-worker/src/base/BaseWorkerPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ import {
WorkerStates,
} from '../types';

// How long to wait for the child process to terminate
// after CHILD_MESSAGE_END before sending force exiting.
const FORCE_EXIT_DELAY = 500;

/* istanbul ignore next */
// eslint-disable-next-line @typescript-eslint/no-empty-function
const emptyMethod = () => {};
Expand Down Expand Up @@ -149,7 +145,7 @@ export default class BaseWorkerPool {
const forceExitTimeout = setTimeout(() => {
worker.forceExit();
forceExited = true;
}, FORCE_EXIT_DELAY);
}, this._options.workerGracefulExitTimeout ?? 500);

await worker.waitForExit();
// Worker ideally exited gracefully, don't send force exit then
Expand Down
83 changes: 83 additions & 0 deletions packages/jest-worker/src/base/__tests__/BaseWorkerPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,89 @@ describe('BaseWorkerPool', () => {
expect(await pool.end()).toEqual({forceExited: false});
});

it('uses workerGracefulExitTimeout option for force exit delay', async () => {
jest.useFakeTimers();

try {
let worker0Exited: (a?: unknown) => void;
const mockForceExit = jest.fn(() => {
worker0Exited();
});
Worker.mockImplementation(
() =>
({
forceExit: mockForceExit,
getStderr: () => null,
getStdout: () => null,
send: jest.fn(),
waitForExit: () =>
new Promise(resolve => (worker0Exited = resolve)),
}) as unknown as WorkerInterface,
);

const pool = new MockWorkerPool('/tmp/baz.js', {
forkOptions: {execArgv: []},
maxRetries: 6,
numWorkers: 1,
setupArgs: [],
workerGracefulExitTimeout: 2000,
} as unknown as WorkerPoolOptions);

const endPromise = pool.end();

// At 500ms (default), forceExit should NOT have been called
jest.advanceTimersByTime(500);
expect(mockForceExit).not.toHaveBeenCalled();

// At 2000ms (custom timeout), forceExit SHOULD be called
jest.advanceTimersByTime(1500);
expect(await endPromise).toEqual({forceExited: true});
} finally {
jest.useRealTimers();
}
});

it('defaults to 500ms when workerGracefulExitTimeout is not set', async () => {
jest.useFakeTimers();

try {
let worker0Exited: (a?: unknown) => void;
const mockForceExit = jest.fn(() => {
worker0Exited();
});
Worker.mockImplementation(
() =>
({
forceExit: mockForceExit,
getStderr: () => null,
getStdout: () => null,
send: jest.fn(),
waitForExit: () =>
new Promise(resolve => (worker0Exited = resolve)),
}) as unknown as WorkerInterface,
);

const pool = new MockWorkerPool('/tmp/baz.js', {
forkOptions: {execArgv: []},
maxRetries: 6,
numWorkers: 1,
setupArgs: [],
} as unknown as WorkerPoolOptions);

const endPromise = pool.end();

// At 499ms, forceExit should NOT have been called
jest.advanceTimersByTime(499);
expect(mockForceExit).not.toHaveBeenCalled();

// At 500ms (default), forceExit SHOULD be called
jest.advanceTimersByTime(1);
expect(await endPromise).toEqual({forceExited: true});
} finally {
jest.useRealTimers();
}
});

it('force exits workers that do not exit gracefully and resolves with forceExited=true', async () => {
// Set it up so that the first worker does not resolve waitForExit immediately,
// but only when forceExit() is called
Expand Down
1 change: 1 addition & 0 deletions packages/jest-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class Worker {
this._options.numWorkers ?? Math.max(availableParallelism() - 1, 1),
resourceLimits: this._options.resourceLimits ?? {},
setupArgs: this._options.setupArgs ?? [],
workerGracefulExitTimeout: this._options.workerGracefulExitTimeout,
};

if (this._options.WorkerPool) {
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export type WorkerFarmOptions = {
) => WorkerPoolInterface;
workerSchedulingPolicy?: WorkerSchedulingPolicy;
idleMemoryLimit?: number;
workerGracefulExitTimeout?: number;
};

export type WorkerPoolOptions = {
Expand All @@ -157,6 +158,7 @@ export type WorkerPoolOptions = {
numWorkers: number;
enableWorkerThreads: boolean;
idleMemoryLimit?: number;
workerGracefulExitTimeout?: number;
};

export type WorkerOptions = {
Expand Down
Loading