Skip to content

Commit 2ef208d

Browse files
authored
prevent execution queue being blocked by zombie processes (#1134)
1 parent ab4e5c6 commit 2ef208d

15 files changed

+680
-287
lines changed

Diff for: src/JestExt/process-listeners.ts

+11-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as vscode from 'vscode';
22
import { JestTotalResults, RunnerEvent } from 'jest-editor-support';
33
import { cleanAnsi, toErrorString } from '../helpers';
4-
import { JestProcess } from '../JestProcessManagement';
4+
import { JestProcess, ProcessStatus } from '../JestProcessManagement';
55
import { ListenerSession, ListTestFilesCallback } from './process-session';
66
import { Logging } from '../logging';
77
import { JestRunEvent } from './types';
@@ -279,10 +279,7 @@ export class RunTestListener extends AbstractProcessListener {
279279

280280
// watch process should not exit unless we request it to be closed
281281
private handleWatchProcessCrash(process: JestProcess): string | undefined {
282-
if (
283-
(process.request.type === 'watch-tests' || process.request.type === 'watch-all-tests') &&
284-
process.stopReason !== 'on-demand'
285-
) {
282+
if (process.isWatchMode && process.status !== ProcessStatus.Cancelled) {
286283
const msg = `Jest process "${process.request.type}" ended unexpectedly`;
287284
this.logging('warn', msg);
288285

@@ -354,16 +351,19 @@ export class RunTestListener extends AbstractProcessListener {
354351

355352
protected onProcessClose(process: JestProcess, code?: number, signal?: string): void {
356353
this.runEnded();
357-
let error = this.handleWatchProcessCrash(process);
354+
let error: string | undefined;
358355

359-
if (code && code > 1) {
360-
if (this.retryWithLoginShell(process, code, signal)) {
361-
return;
362-
}
363-
if (!error) {
356+
if (process.status !== ProcessStatus.Cancelled) {
357+
if (code && code > 1) {
358+
if (this.retryWithLoginShell(process, code, signal)) {
359+
return;
360+
}
364361
error = `process ${process.id} exited with code= ${code}`;
365362
}
363+
364+
error = this.handleWatchProcessCrash(process) ?? error;
366365
}
366+
367367
this.onRunEvent.fire({ type: 'exit', process, error, code });
368368
}
369369
}

Diff for: src/JestProcessManagement/JestProcess.ts

+50-14
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Runner, RunnerEvent, Options } from 'jest-editor-support';
44
import { JestExtContext, WatchMode } from '../JestExt/types';
55
import { extensionId } from '../appGlobals';
66
import { Logging } from '../logging';
7-
import { JestProcessInfo, JestProcessRequest, UserDataType } from './types';
7+
import { JestProcessInfo, JestProcessRequest, ProcessStatus, UserDataType } from './types';
88
import { requestString } from './helper';
99
import { toFilePath, removeSurroundingQuote, escapeRegExp, shellQuote } from '../helpers';
1010

@@ -23,20 +23,18 @@ interface RunnerTask {
2323
reject: (reason: unknown) => unknown;
2424
runner: Runner;
2525
}
26-
export type StopReason = 'on-demand' | 'process-end';
2726

2827
let SEQ = 0;
2928

3029
export class JestProcess implements JestProcessInfo {
31-
static readonly stopHangTimeout = 500;
32-
3330
private task?: RunnerTask;
3431
private extContext: JestExtContext;
3532
private logging: Logging;
36-
private _stopReason?: StopReason;
3733
public readonly id: string;
3834
private desc: string;
3935
public readonly request: JestProcessRequest;
36+
public _status: ProcessStatus;
37+
private autoStopTimer?: NodeJS.Timeout;
4038

4139
constructor(
4240
extContext: JestExtContext,
@@ -48,10 +46,11 @@ export class JestProcess implements JestProcessInfo {
4846
this.logging = extContext.loggingFactory.create(`JestProcess ${request.type}`);
4947
this.id = `${request.type}-${SEQ++}`;
5048
this.desc = `id: ${this.id}, request: ${requestString(request)}`;
49+
this._status = ProcessStatus.Pending;
5150
}
5251

53-
public get stopReason(): StopReason | undefined {
54-
return this._stopReason;
52+
public get status(): ProcessStatus {
53+
return this._status;
5554
}
5655

5756
private get watchMode(): WatchMode {
@@ -64,15 +63,39 @@ export class JestProcess implements JestProcessInfo {
6463
return WatchMode.None;
6564
}
6665

66+
public get isWatchMode(): boolean {
67+
return this.watchMode !== WatchMode.None;
68+
}
69+
6770
public toString(): string {
68-
return `JestProcess: ${this.desc}; stopReason: ${this.stopReason}`;
71+
return `JestProcess: ${this.desc}; status: "${this.status}"`;
6972
}
70-
public start(): Promise<void> {
71-
this._stopReason = undefined;
72-
return this.startRunner();
73+
74+
/**
75+
* To prevent zombie process, this method will automatically stops the Jest process if it is running for too long. The process will be marked as "Cancelled" and stopped.
76+
* Warning: This should only be called when you are certain the process should end soon, for example a non-watch mode process should end after the test results have been processed.
77+
* @param delay The delay in milliseconds after which the process will be considered hung and stopped. Default is 30000 milliseconds (30 seconds ).
78+
*/
79+
public autoStop(delay = 30000, onStop?: (process: JestProcessInfo) => void): void {
80+
if (this.status === ProcessStatus.Running) {
81+
if (this.autoStopTimer) {
82+
clearTimeout(this.autoStopTimer);
83+
}
84+
this.autoStopTimer = setTimeout(() => {
85+
if (this.status === ProcessStatus.Running) {
86+
console.warn(
87+
`Jest Process "${this.id}": will be force closed due to the autoStop Timer (${delay} msec) `
88+
);
89+
this.stop();
90+
onStop?.(this);
91+
}
92+
}, delay);
93+
}
7394
}
95+
7496
public stop(): Promise<void> {
75-
this._stopReason = 'on-demand';
97+
this._status = ProcessStatus.Cancelled;
98+
7699
if (!this.task) {
77100
this.logging('debug', 'nothing to stop, no pending runner/promise');
78101
this.taskDone();
@@ -99,12 +122,19 @@ export class JestProcess implements JestProcessInfo {
99122
return `"${removeSurroundingQuote(aString)}"`;
100123
}
101124

102-
private startRunner(): Promise<void> {
125+
public start(): Promise<void> {
126+
if (this.status === ProcessStatus.Cancelled) {
127+
this.logging('warn', `the runner task has been cancelled!`);
128+
return Promise.resolve();
129+
}
130+
103131
if (this.task) {
104132
this.logging('warn', `the runner task has already started!`);
105133
return this.task.promise;
106134
}
107135

136+
this._status = ProcessStatus.Running;
137+
108138
const options: Options = {
109139
noColor: false,
110140
reporters: ['default', `"${this.getReporterPath()}"`],
@@ -196,7 +226,13 @@ export class JestProcess implements JestProcessInfo {
196226
if (event === 'processClose' || event === 'processExit') {
197227
this.task?.resolve();
198228
this.task = undefined;
199-
this._stopReason = this._stopReason ?? 'process-end';
229+
230+
clearTimeout(this.autoStopTimer);
231+
this.autoStopTimer = undefined;
232+
233+
if (this._status !== ProcessStatus.Cancelled) {
234+
this._status = ProcessStatus.Done;
235+
}
200236
}
201237
this.request.listener.onEvent(this, event, ...args);
202238
}

Diff for: src/JestProcessManagement/JestProcessManager.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Task,
77
JestProcessInfo,
88
UserDataType,
9+
ProcessStatus,
910
} from './types';
1011
import { Logging } from '../logging';
1112
import { createTaskQueue, TaskQueue } from './task-queue';
@@ -78,11 +79,13 @@ export class JestProcessManager implements TaskArrayFunctions<JestProcess> {
7879
return;
7980
}
8081
const process = task.data;
81-
8282
try {
83-
const promise = process.start();
84-
this.extContext.onRunEvent.fire({ type: 'process-start', process });
85-
await promise;
83+
// process could be cancelled before it starts, so check before starting
84+
if (process.status === ProcessStatus.Pending) {
85+
const promise = process.start();
86+
this.extContext.onRunEvent.fire({ type: 'process-start', process });
87+
await promise;
88+
}
8689
} catch (e) {
8790
this.logging('error', `${queue.name}: process failed to start:`, process, e);
8891
this.extContext.onRunEvent.fire({

Diff for: src/JestProcessManagement/types.ts

+13
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,25 @@ export interface UserDataType {
1515
testError?: boolean;
1616
testItem?: vscode.TestItem;
1717
}
18+
export enum ProcessStatus {
19+
Pending = 'pending',
20+
Running = 'running',
21+
Cancelled = 'cancelled',
22+
// process exited not because of cancellation
23+
Done = 'done',
24+
}
25+
1826
export interface JestProcessInfo {
1927
readonly id: string;
2028
readonly request: JestProcessRequest;
2129
// user data is a way to store data that is outside of the process managed by the processManager.
2230
// subsequent use of this data is up to the user but should be aware that multiple components might contribute to this data.
2331
userData?: UserDataType;
32+
stop: () => Promise<void>;
33+
status: ProcessStatus;
34+
isWatchMode: boolean;
35+
// starting a timer to automatically kill the process after x milliseconds if the process is still running.
36+
autoStop: (delay?: number, onStop?: (process: JestProcessInfo) => void) => void;
2437
}
2538

2639
export type TaskStatus = 'running' | 'pending';

0 commit comments

Comments
 (0)