node.jsjestjstimeoutreact-scriptsconcurrently

How to Programmatically Kill Jest and All Its Child Processes After a Timeout?


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

jest test running


Solution

  • 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'