Skip to content

Commit a0703c9

Browse files
committed
fix(jest-worker): fix hanging when child process workers are killed
1 parent 7c48c4c commit a0703c9

File tree

4 files changed

+131
-1
lines changed

4 files changed

+131
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- `[jest-mock]` Treat cjs modules as objects so they can be mocked ([#13513](https://github.com/facebook/jest/pull/13513))
8+
- `[jest-worker]` Throw an error instead of hanging when jest workers are killed ([#13566](https://github.com/facebook/jest/pull/13566))
89

910
### Chore & Maintenance
1011

packages/jest-worker/src/workers/ChildProcessWorker.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ export default class ChildProcessWorker
343343
}
344344
}
345345

346-
private _onExit(exitCode: number | null) {
346+
private _onExit(exitCode: number | null, signal: NodeJS.Signals | null) {
347347
this._workerReadyPromise = undefined;
348348
this._resolveWorkerReady = undefined;
349349

@@ -372,6 +372,46 @@ export default class ChildProcessWorker
372372
this._child.send(this._request);
373373
}
374374
} else {
375+
// At this point, it's not clear why the child process exited. There could
376+
// be several reasons:
377+
//
378+
// 1. The child process exited successfully after finishing its work.
379+
// This is the most likely case.
380+
// 2. The child process crashed in a manner that wasn't caught through
381+
// any of the heuristic-based checks above.
382+
// 3. The child process was killed by another process or daemon unrelated
383+
// to Jest. For example, oom-killer on Linux may have picked the child
384+
// process to kill because overall system memory is constrained.
385+
//
386+
// If there's a pending request to the child process in any of those
387+
// situations, the request still needs to be handled in some manner before
388+
// entering the shutdown phase. Otherwise the caller expecting a response
389+
// from the worker will never receive indication that something unexpected
390+
// happened and hang forever.
391+
//
392+
// In normal operation, the request is handled and cleared before the
393+
// child process exits. If it's still present, it's not clear what
394+
// happened and probably best to throw an error. In practice, this usually
395+
// happens when the child process is killed externally.
396+
//
397+
// There's a reasonable argument that the child process should be retried
398+
// with request re-sent in this scenario. However, if the problem was due
399+
// to situations such as oom-killer attempting to free up system
400+
// resources, retrying would exacerbate the problem.
401+
const isRequestStillPending = !!this._request;
402+
if (isRequestStillPending) {
403+
// If a signal is present, we can be reasonably confident the process
404+
// was killed externally. Log this fact so it's more clear to users that
405+
// something went wrong externally, rather than a bug in Jest itself.
406+
const error = new Error(
407+
signal != null
408+
? `A jest worker process (pid=${this._child.pid}) was terminated by another process: signal=${signal}, exitCode=${exitCode}. Operating system logs may contain more information on why this occurred.`
409+
: `A jest worker process (pid=${this._child.pid}) crashed for an unknown reason: exitCode=${exitCode}`,
410+
);
411+
412+
this._onProcessEnd(error, null);
413+
}
414+
375415
this._shutdown();
376416
}
377417
}

packages/jest-worker/src/workers/__tests__/WorkerEdgeCases.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,68 @@ describe.each([
386386
});
387387
});
388388
});
389+
390+
// This describe block only applies to the child process worker since it's
391+
// generally not possible for external processes to abruptly kill a thread of
392+
// another process.
393+
describe('should not hang on external process kill', () => {
394+
let worker: ChildProcessWorker;
395+
396+
beforeEach(() => {
397+
const options = {
398+
childWorkerPath: processChildWorkerPath,
399+
maxRetries: 0,
400+
silent: true,
401+
workerPath: join(__dirname, '__fixtures__', 'SelfKillWorker'),
402+
} as unknown as WorkerOptions;
403+
404+
worker = new ChildProcessWorker(options);
405+
});
406+
407+
afterEach(async () => {
408+
await new Promise<void>(resolve => {
409+
setTimeout(async () => {
410+
if (worker) {
411+
worker.forceExit();
412+
await worker.waitForExit();
413+
}
414+
415+
resolve();
416+
}, 500);
417+
});
418+
});
419+
420+
// Regression test for https://github.com/facebook/jest/issues/13183
421+
test('onEnd callback is called', async () => {
422+
let onEndPromiseResolve: () => void;
423+
let onEndPromiseReject: (err: Error) => void;
424+
const onEndPromise = new Promise<void>((resolve, reject) => {
425+
onEndPromiseResolve = resolve;
426+
onEndPromiseReject = reject;
427+
});
428+
429+
const onStart = jest.fn();
430+
const onEnd = jest.fn((err: Error | null) => {
431+
if (err) {
432+
return onEndPromiseReject(err);
433+
}
434+
onEndPromiseResolve();
435+
});
436+
const onCustom = jest.fn();
437+
438+
await worker.waitForWorkerReady();
439+
440+
// The SelfKillWorker simulates an external process calling SIGTERM on it,
441+
// but just SIGTERMs itself underneath the hood to make this test easier.
442+
worker.send(
443+
[CHILD_MESSAGE_CALL, true, 'selfKill', []],
444+
onStart,
445+
onEnd,
446+
onCustom,
447+
);
448+
449+
// The onEnd callback should be called when the child process exits.
450+
await expect(onEndPromise).rejects.toBeInstanceOf(Error);
451+
expect(onEnd).toHaveBeenCalled();
452+
});
453+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
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+
const {isMainThread} = require('worker_threads');
8+
9+
async function selfKill() {
10+
// This test is intended for the child process worker. If the Node.js worker
11+
// thread mode is accidentally tested instead, let's prevent a confusing
12+
// situation where process.kill stops the Jest test harness itself.
13+
if (!isMainThread) {
14+
// process.exit is documented to only stop the current thread rather than
15+
// the process in a worker_threads environment.
16+
process.exit();
17+
}
18+
19+
process.kill(process.pid);
20+
}
21+
22+
module.exports = {
23+
selfKill,
24+
};

0 commit comments

Comments
 (0)