Search code examples
javascriptnode.jsghostscriptchild-processspawn

Node.js - How can I prevent interrupted child processes from surviving?


I have found that some child processes are failing to terminate if the calling script is interrupted.

Specifically, I have a module that uses Ghostscript to perform various actions: extract page images, create a new pdf from a slice, etc. I use the following to execute the command and return a through stream of the child's stdout:

function spawnStream(command, args, storeStdout, cbSuccess) {
  storeStdout = storeStdout || false;

  const child = spawn(command, args);
  const stream = through(data => stream.emit('data', data));

  let stdout = '';
  child.stdout.on('data', data => {
    if (storeStdout === true) stdout += data;
    stream.write(data);
  });

  let stderr = '';
  child.stderr.on('data', data => stderr += data);

  child.on('close', code => {
    stream.emit('end');
    if (code > 0) return stream.emit('error', stderr);
    if (!!cbSuccess) cbSuccess(stdout);
  });

  return stream;
}

This is invoked by function such as:

function extractPage(pathname, page) {
  const internalRes = 96;
  const downScaleFactor = 1;
  return spawnStream(PATH_TO_GS, [
    '-q',
    '-sstdout=%stderr',
    '-dBATCH',
    '-dNOPAUSE',
    '-sDEVICE=pngalpha',
    `-r${internalRes}`,
    `-dDownScaleFactor=${downScaleFactor}`,
    `-dFirstPage=${page}`,
    `-dLastPage=${page}`,
    '-sOutputFile=%stdout',
    pathname
  ]);
}

which is consumed, for example, like this:

it('given a pdf pathname and page number, returns the image as a stream', () => {
  const document = path.resolve(__dirname, 'samples', 'document.pdf');
  const test = new Promise((resolve, reject) => {
    const imageBlob = extract(document, 1);
    imageBlob.on('data', data => {
      // do nothing in this test
    });

    imageBlob.on('end', () => resolve(true));
    imageBlob.on('error', err => reject(err));
  });

  return Promise.all([expect(test).to.eventually.equal(true)]);
});

When this is interrupted, for example if the test times out or an unhandled error occurs, the child process doesn't seem to receive any signal and survives. It's a bit confusing, as no individual operation is particularly complex and yet the process appears to survive indefinitely, using 100% of CPU.

☁  ~  ps aux | grep gs | head -n 5
rwick            5735 100.0  4.2  3162908 699484 s000  R    12:54AM   6:28.13 gs -q -sstdout=%stderr -dBATCH -dNOPAUSE -sDEVICE=pngalpha -r96 -dDownScaleFactor=1 -dFirstPage=3 -dLastPage=3 -sOutputFile=%stdout /Users/rwick/projects/xan-desk/test/samples/document.pdf
rwick            5734 100.0  4.2  3171100 706260 s000  R    12:54AM   6:28.24 gs -q -sstdout=%stderr -dBATCH -dNOPAUSE -sDEVICE=pngalpha -r96 -dDownScaleFactor=1 -dFirstPage=2 -dLastPage=2 -sOutputFile=%stdout /Users/rwick/projects/xan-desk/test/samples/document.pdf
rwick            5733 100.0  4.1  3154808 689000 s000  R    12:54AM   6:28.36 gs -q -sstdout=%stderr -dBATCH -dNOPAUSE -sDEVICE=pngalpha -r96 -dDownScaleFactor=1 -dFirstPage=1 -dLastPage=1 -sOutputFile=%stdout /Users/rwick/projects/xan-desk/test/samples/document.pdf
rwick            5732 100.0  4.2  3157360 696556 s000  R    12:54AM   6:28.29 gs -q -sstdout=%stderr -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -sOutputFile=%stdout /Users/rwick/projects/xan-desk/test/samples/document.pdf /Users/rwick/projects/xan-desk/test/samples/page.pdf

I thought to use a timer to send a kill signal to the child but selecting an arbitrary interval to kill a process seems like it would effectively be trading an known problem for an unknown one and kicking that can down the road.

I would really appreciate any insight into what I'm missing here. Is there a better option to encapsulate child processes so the termination of the parent is more likely to precipitate the child's interrupt?


Solution

  • listen to error event

    child.on('error', function(err) {
        console.error(err);
        // code
        try {
            // child.kill() or child.disconnect()
        } catch (e) {
            console.error(e);
        }
    });