diff --git a/cspell.yaml b/cspell.yaml index abe5024..919ae53 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -19,4 +19,6 @@ words: - turborepo - turbowatch - vitest - - wholename \ No newline at end of file + - wholename + - pidtree + - pids \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 40ad96a..16e1c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "dependencies": { "chalk": "^4.1.2", "chokidar": "^3.5.3", + "find-process": "^1.4.7", "glob": "^9.3.1", "jiti": "^1.18.2", "micromatch": "^4.0.5", + "pidtree": "^0.6.0", "randomcolor": "^0.6.2", "roarr": "^7.15.0", "semver": "^7.3.8", @@ -4471,7 +4473,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -6365,6 +6366,27 @@ "node": ">=8" } }, + "node_modules/find-process": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.7.tgz", + "integrity": "sha512-/U4CYp1214Xrp3u3Fqr9yNynUrr5Le4y0SsJh2lMDDSbpwYSz3M2SMWQC+wqcx79cN8PQtHQIL8KnuY9M66fdg==", + "dependencies": { + "chalk": "^4.0.0", + "commander": "^5.1.0", + "debug": "^4.1.1" + }, + "bin": { + "find-process": "bin/find-process.js" + } + }, + "node_modules/find-process/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8424,8 +8446,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.6", @@ -11586,6 +11607,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -17798,7 +17830,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -19188,6 +19219,23 @@ "to-regex-range": "^5.0.1" } }, + "find-process": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.7.tgz", + "integrity": "sha512-/U4CYp1214Xrp3u3Fqr9yNynUrr5Le4y0SsJh2lMDDSbpwYSz3M2SMWQC+wqcx79cN8PQtHQIL8KnuY9M66fdg==", + "requires": { + "chalk": "^4.0.0", + "commander": "^5.1.0", + "debug": "^4.1.1" + }, + "dependencies": { + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + } + } + }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -20669,8 +20717,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nanoid": { "version": "3.3.6", @@ -22874,6 +22921,11 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, + "pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==" + }, "pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", diff --git a/package.json b/package.json index 6016ad2..1e1d27c 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "dependencies": { "chalk": "^4.1.2", "chokidar": "^3.5.3", + "find-process": "^1.4.7", "glob": "^9.3.1", "jiti": "^1.18.2", "micromatch": "^4.0.5", + "pidtree": "^0.6.0", "randomcolor": "^0.6.2", "roarr": "^7.15.0", "semver": "^7.3.8", diff --git a/src/__fixtures__/killPsTree/badTree/a.js b/src/__fixtures__/killPsTree/badTree/a.js new file mode 100644 index 0000000..3b5eadb --- /dev/null +++ b/src/__fixtures__/killPsTree/badTree/a.js @@ -0,0 +1,8 @@ +/* eslint-disable no-console */ + +const { spawn } = require('node:child_process'); +const { resolve } = require('node:path'); + +spawn('node', [resolve(__dirname, 'b.js')], { + stdio: 'inherit', +}); diff --git a/src/__fixtures__/killPsTree/badTree/b.js b/src/__fixtures__/killPsTree/badTree/b.js new file mode 100644 index 0000000..709a545 --- /dev/null +++ b/src/__fixtures__/killPsTree/badTree/b.js @@ -0,0 +1,9 @@ +/* eslint-disable no-console */ + +setInterval(() => { + console.log('b'); +}, 1_000); + +process.on('SIGTERM', () => { + console.log('b: SIGTERM'); +}); diff --git a/src/__fixtures__/killPsTree/goodTree/a.js b/src/__fixtures__/killPsTree/goodTree/a.js new file mode 100644 index 0000000..79f9984 --- /dev/null +++ b/src/__fixtures__/killPsTree/goodTree/a.js @@ -0,0 +1,10 @@ +/* eslint-disable no-console */ + +const { spawn } = require('node:child_process'); +const { resolve } = require('node:path'); + +const b = spawn('node', [resolve(__dirname, 'b.js')]); + +b.stdout.on('data', (data) => { + console.log(data.toString()); +}); diff --git a/src/__fixtures__/killPsTree/goodTree/b.js b/src/__fixtures__/killPsTree/goodTree/b.js new file mode 100644 index 0000000..18131ca --- /dev/null +++ b/src/__fixtures__/killPsTree/goodTree/b.js @@ -0,0 +1,5 @@ +/* eslint-disable no-console */ + +setInterval(() => { + console.log('b'); +}, 1_000); diff --git a/src/createSpawn.ts b/src/createSpawn.ts index b0fa8b2..778e1d6 100644 --- a/src/createSpawn.ts +++ b/src/createSpawn.ts @@ -2,6 +2,7 @@ import { AbortError, UnexpectedError } from './errors'; import { findNearestDirectory } from './findNearestDirectory'; +import { killPsTree } from './killPsTree'; import { Logger } from './Logger'; import { type Throttle } from './types'; import chalk from 'chalk'; @@ -117,12 +118,21 @@ export const createSpawn = ( if (abortSignal) { const kill = () => { + const pid = processPromise.child?.pid; + + if (!pid) { + log.warn('no process to kill'); + + return; + } + + // TODO make this configurable // eslint-disable-next-line promise/prefer-await-to-then - processPromise.kill().finally(() => { + killPsTree(pid, 5_000).then(() => { log.debug('task %s was killed', taskId); - // processPromise.stdout.off('data', onStdout); - // processPromise.stderr.off('data', onStderr); + processPromise.stdout.off('data', onStdout); + processPromise.stderr.off('data', onStderr); }); }; diff --git a/src/killPsTree.test.ts b/src/killPsTree.test.ts new file mode 100644 index 0000000..502d6e1 --- /dev/null +++ b/src/killPsTree.test.ts @@ -0,0 +1,33 @@ +import { killPsTree } from './killPsTree'; +import { exec } from 'node:child_process'; +import { join } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { test } from 'vitest'; + +test('kills a good process tree', async () => { + const childProcess = exec( + `node ${join(__dirname, '__fixtures__/killPsTree/goodTree/a.js')}`, + ); + + if (!childProcess.pid) { + throw new Error('Expected child process to have a pid'); + } + + await setTimeout(500); + + await killPsTree(childProcess.pid); +}); + +test('kills a bad process tree', async () => { + const childProcess = exec( + `node ${join(__dirname, '__fixtures__/killPsTree/badTree/a.js')}`, + ); + + if (!childProcess.pid) { + throw new Error('Expected child process to have a pid'); + } + + await setTimeout(500); + + await killPsTree(childProcess.pid, 1_000); +}); diff --git a/src/killPsTree.ts b/src/killPsTree.ts new file mode 100644 index 0000000..7229330 --- /dev/null +++ b/src/killPsTree.ts @@ -0,0 +1,66 @@ +import { Logger } from './Logger'; +import findProcess from 'find-process'; +import pidTree from 'pidtree'; + +const log = Logger.child({ + namespace: 'killPsTree', +}); + +export const killPsTree = async ( + rootPid: number, + gracefulTimeout: number = 30_000, +) => { + const childPids = await pidTree(rootPid); + + const pids = [rootPid, ...childPids]; + + for (const pid of pids) { + process.kill(pid, 'SIGTERM'); + } + + let hangingPids = [...pids]; + + let hitTimeout = false; + + const timeoutId = setTimeout(() => { + hitTimeout = true; + + log.debug({ hangingPids }, 'sending SIGKILL to processes...'); + + for (const pid of hangingPids) { + process.kill(pid, 'SIGKILL'); + } + }, gracefulTimeout); + + await Promise.all( + hangingPids.map((pid) => { + return new Promise((resolve) => { + const interval = setInterval(async () => { + if (hitTimeout) { + clearInterval(interval); + + resolve(false); + + return; + } + + const processes = await findProcess('pid', pid); + + if (processes.length === 0) { + hangingPids = hangingPids.filter( + (hangingPid) => hangingPid !== pid, + ); + + clearInterval(interval); + + resolve(true); + } + }, 100); + }); + }), + ); + + clearTimeout(timeoutId); + + log.debug('all processes terminated'); +};