Skip to content

Commit f9966be

Browse files
committed
feat: handle hanging processes
1 parent ff54b7e commit f9966be

File tree

10 files changed

+207
-10
lines changed

10 files changed

+207
-10
lines changed

cspell.yaml

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ words:
1919
- turborepo
2020
- turbowatch
2121
- vitest
22-
- wholename
22+
- wholename
23+
- pidtree
24+
- pids

package-lock.json

+58-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
"dependencies": {
1111
"chalk": "^4.1.2",
1212
"chokidar": "^3.5.3",
13+
"find-process": "^1.4.7",
1314
"glob": "^9.3.1",
1415
"jiti": "^1.18.2",
1516
"micromatch": "^4.0.5",
17+
"pidtree": "^0.6.0",
1618
"randomcolor": "^0.6.2",
1719
"roarr": "^7.15.0",
1820
"semver": "^7.3.8",
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/* eslint-disable no-console */
2+
3+
const { spawn } = require('node:child_process');
4+
const { resolve } = require('node:path');
5+
6+
spawn('node', [resolve(__dirname, 'b.js')], {
7+
stdio: 'inherit',
8+
});
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* eslint-disable no-console */
2+
3+
setInterval(() => {
4+
console.log('b');
5+
}, 1_000);
6+
7+
process.on('SIGTERM', () => {
8+
console.log('b: SIGTERM');
9+
});
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* eslint-disable no-console */
2+
3+
const { spawn } = require('node:child_process');
4+
const { resolve } = require('node:path');
5+
6+
const b = spawn('node', [resolve(__dirname, 'b.js')]);
7+
8+
b.stdout.on('data', (data) => {
9+
console.log(data.toString());
10+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/* eslint-disable no-console */
2+
3+
setInterval(() => {
4+
console.log('b');
5+
}, 1_000);

src/createSpawn.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { AbortError, UnexpectedError } from './errors';
44
import { findNearestDirectory } from './findNearestDirectory';
5+
import { killPsTree } from './killPsTree';
56
import { Logger } from './Logger';
67
import { type Throttle } from './types';
78
import chalk from 'chalk';
@@ -117,12 +118,21 @@ export const createSpawn = (
117118

118119
if (abortSignal) {
119120
const kill = () => {
121+
const pid = processPromise.child?.pid;
122+
123+
if (!pid) {
124+
log.warn('no process to kill');
125+
126+
return;
127+
}
128+
129+
// TODO make this configurable
120130
// eslint-disable-next-line promise/prefer-await-to-then
121-
processPromise.kill().finally(() => {
131+
killPsTree(pid, 5_000).then(() => {
122132
log.debug('task %s was killed', taskId);
123133

124-
// processPromise.stdout.off('data', onStdout);
125-
// processPromise.stderr.off('data', onStderr);
134+
processPromise.stdout.off('data', onStdout);
135+
processPromise.stderr.off('data', onStderr);
126136
});
127137
};
128138

src/killPsTree.test.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { killPsTree } from './killPsTree';
2+
import { exec } from 'node:child_process';
3+
import { join } from 'node:path';
4+
import { setTimeout } from 'node:timers/promises';
5+
import { test } from 'vitest';
6+
7+
test('kills a good process tree', async () => {
8+
const childProcess = exec(
9+
`node ${join(__dirname, '__fixtures__/killPsTree/goodTree/a.js')}`,
10+
);
11+
12+
if (!childProcess.pid) {
13+
throw new Error('Expected child process to have a pid');
14+
}
15+
16+
await setTimeout(500);
17+
18+
await killPsTree(childProcess.pid);
19+
});
20+
21+
test('kills a bad process tree', async () => {
22+
const childProcess = exec(
23+
`node ${join(__dirname, '__fixtures__/killPsTree/badTree/a.js')}`,
24+
);
25+
26+
if (!childProcess.pid) {
27+
throw new Error('Expected child process to have a pid');
28+
}
29+
30+
await setTimeout(500);
31+
32+
await killPsTree(childProcess.pid, 1_000);
33+
});

src/killPsTree.ts

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Logger } from './Logger';
2+
import findProcess from 'find-process';
3+
import pidTree from 'pidtree';
4+
5+
const log = Logger.child({
6+
namespace: 'killPsTree',
7+
});
8+
9+
export const killPsTree = async (
10+
rootPid: number,
11+
gracefulTimeout: number = 30_000,
12+
) => {
13+
const childPids = await pidTree(rootPid);
14+
15+
const pids = [rootPid, ...childPids];
16+
17+
for (const pid of pids) {
18+
process.kill(pid, 'SIGTERM');
19+
}
20+
21+
let hangingPids = [...pids];
22+
23+
let hitTimeout = false;
24+
25+
const timeoutId = setTimeout(() => {
26+
hitTimeout = true;
27+
28+
log.debug({ hangingPids }, 'sending SIGKILL to processes...');
29+
30+
for (const pid of hangingPids) {
31+
process.kill(pid, 'SIGKILL');
32+
}
33+
}, gracefulTimeout);
34+
35+
await Promise.all(
36+
hangingPids.map((pid) => {
37+
return new Promise((resolve) => {
38+
const interval = setInterval(async () => {
39+
if (hitTimeout) {
40+
clearInterval(interval);
41+
42+
resolve(false);
43+
44+
return;
45+
}
46+
47+
const processes = await findProcess('pid', pid);
48+
49+
if (processes.length === 0) {
50+
hangingPids = hangingPids.filter(
51+
(hangingPid) => hangingPid !== pid,
52+
);
53+
54+
clearInterval(interval);
55+
56+
resolve(true);
57+
}
58+
}, 100);
59+
});
60+
}),
61+
);
62+
63+
clearTimeout(timeoutId);
64+
65+
log.debug('all processes terminated');
66+
};

0 commit comments

Comments
 (0)