I’m facing an issue with Jest where I’m trying to enforce a timeout on its execution and kill it along with all its spawned child processes if it exceeds this timeout.
When the javascript heap space runs out in the environment where the tests are running, jest will not stop running.
Here’s what I’ve tried so far:
process-gaurd.js
import args from "command-line-args";
import { execa } from "execa";
import chalk from "chalk";
import process from "node:process";
import treeKill from "tree-kill";
import terminate from "terminate";
const optionDefinitions = [
{ name: "delay", alias: "d", type: Number },
{ name: "command", alias: "c", type: String }
];
const options = args(optionDefinitions);
const delay = options.delay;
const command = options.command;
console.log(
chalk.yellow(
`setting ${delay}s auto kill timeout for running command ${chalk.green(`'${command}'`)}`
)
);
const controller = new AbortController();
setTimeout(() => {
console.log(
chalk.red(
`timeout exceeded running command ${chalk.bold(`'${command}'`)}. exiting...`
)
);
controller.abort();
}, delay * 1000);
try {
execa({
shell: true,
stdio: "inherit",
verbose: "full",
timeout: delay * 1000,
detached: false,
cancelSignal: controller.signal,
forceKillAfterDelay: true,
cleanup: true
})`${command}`;
} catch (err) {
execa({
shell: true,
stdio: "inherit",
verbose: "full",
timeout: 2000,
detached: false,
forceKillAfterDelay: true,
cleanup: true
})`ps aux | grep jest | awk '{print $2}' | xargs kill -9`;
}
package.json
...
"test": "react-app-rewired test --verbose --silent --watchAll=false",
"test:prebuild": " node process-gaurd.js --delay 2 --command 'npm run test'",
...
running
❯ npm run test:prebuild
> ui@0.1.0 test:prebuild
> node ./scripts/utils/timeout.js -d 2 -c 'npm run test -- --workerThreads=1'
setting 2s auto kill timeout for running command 'npm run test -- --workerThreads=1'
t::0> react-app-rewired test --verbose --silent --watchAll=false --workerThreads=1
t::0> PASS Test1
t::0> PASS Test2
t::1> PASS Test3
t::2> PASS Test3
t::3> timeout exceeded running command 'npm run test'. exiting...
████████████████████████████████████████timeout exceeded running command 'npm run test'. exiting...
[23:55:24.469] [0] ✘ Command was canceled: 'npm run test'
[23:55:24.469] [0] ✘ This operation was aborted
[23:55:24.469] [0] ✘ (done in 5s)
file:ui/node_modules/execa/lib/return/final-error.js:6
return new ErrorClass(message, options);
^
ExecaError: Command was canceled: 'npm run test'
This operation was aborted
at getFinalError (file:ui/node_modules/execa/lib/return/final-error.js:6:9)
at makeError (file:ui/node_modules/execa/lib/return/result.js:108:16)
at getAsyncResult (file:ui/node_modules/execa/lib/methods/main-async.js:174:4)
at handlePromise (file:ui/node_modules/execa/lib/methods/main-async.js:157:17)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
shortMessage: "Command was canceled: 'npm run test'\nThis operation was aborted",
originalMessage: 'This operation was aborted',
command: 'npm run test',
escapedCommand: "'npm run test'",
cwd: 'ui',
durationMs: 5009.989291,
failed: true,
timedOut: false,
isCanceled: true,
isGracefullyCanceled: false,
isTerminated: true,
isMaxBuffer: false,
isForcefullyTerminated: false,
>
Even after grep and kill, the main process with node is still in action
It turned out the process from react scripts test.js was still running separately. even after jest processes were killed.
~❯ ps aux | grep test
user 89603 59,1 0,5 411986080 161776 s001 S+ 9:27am 0:00.43 node ui/node_modules/react-app-rewired/scripts/test.js --verbose --silent --watchAll=false
user 89565 2,8 0,2 411917056 62128 s001 S+ 9:27am 0:00.16 npm run test
user 89590 1,2 0,1 411730480 40304 s001 S+ 9:27am 0:00.04 node ui/node_modules/.bin/react-app-rewired test --verbose --silent --watchAll=false
user 47439 0,0 0,0 412055920 4656 s001 T 10:45am 0:00.18 npm test -u
user 47414 0,0 0,0 411662704 4656 s001 T 10:45am 0:00.21 npm run test:update-snapshot
user 89684 0,0 0,0 410059824 240 s002 R 9:27am 0:00.00 grep test
To avoid that, I had to modify the script to accept an optional grep command to find and kill.
process-gaurd.js
import args from "command-line-args";
import { execa } from "execa";
import chalk from "chalk";
const optionDefinitions = [
{ name: "timeout", alias: "t", type: Number },
{ name: "process", alias: "p", type: String },
{ name: "killgrep", alias: "k", type: String }
];
const options = args(optionDefinitions);
const timeout = options.timeout;
const process = options.process;
const killGrep = options.killgrep;
const killCommand = killGrep
? `ps aux | grep -E '${killGrep}' | awk '{print $2}' | xargs kill -9`
: "";
const forceKillProcesses = killCommand !== "";
console.log(
chalk.yellow(
`setting ${timeout}s auto kill timeout for running command ${chalk.green(`'${process}'`)}`
)
);
const abortController = new AbortController();
setTimeout(() => {
console.log(
chalk.red(
`timeout exceeded running command ${chalk.bold(`'${process}'`)}. exiting...`
)
);
if (forceKillProcesses) {
console.log(
chalk.red(
`killing all the process associated with it by runniing ${killCommand}`
)
);
execa({
shell: true,
stdio: "inherit",
verbose: "full",
timeout: 2000,
detached: false,
forceKillAfterDelay: true,
cleanup: true
})`${killCommand}`;
}
abortController.abort();
}, timeout * 1000);
try {
execa({
shell: true,
stdio: "inherit",
verbose: "full",
timeout: timeout * 1000,
detached: false,
killSignal: "SIGTERM",
forceKillAfterDelay: true,
cancelSignal: forceKillProcesses ? undefined : abortController,
cleanup: true
})`${process}`;
} catch (err) {
console.log(chalk.red(err));
}
and run the script like
node process-gaurd.js -t 600 -p 'npm run test' -k 'react-app-rewired/scripts/test.js|jest'