Skip to content

Commit

Permalink
child_process: fix spawn and fork abort behavior
Browse files Browse the repository at this point in the history
Fix AbortSignal in Spawn which doesn't actually abort
the process, and fork can emit an AbortError even if
the process was already exited. Add documentation
For killSignal.

Fixes: #37273
  • Loading branch information
Nitzan Uziely committed Feb 11, 2021
1 parent cf5f6af commit 3111e78
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 25 deletions.
10 changes: 10 additions & 0 deletions doc/api/child_process.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ controller.abort();
<!-- YAML
added: v0.5.0
changes:
- version: REPLACEME
pr-url: xxx
description: killSignal for AbortSignal was added.
- version: v15.6.0
pr-url: https://github.com/nodejs/node/pull/36603
description: AbortSignal support was added.
Expand Down Expand Up @@ -383,6 +386,8 @@ changes:
messages between processes. Possible values are `'json'` and `'advanced'`.
See [Advanced serialization][] for more details. **Default:** `'json'`.
* `signal` {AbortSignal} Allows closing the subprocess using an AbortSignal.
* `killSignal` {string} The signal value to be used when the spawned
process will be killed by the abort signal. **Default:** `'SIGTERM'`.
* `silent` {boolean} If `true`, stdin, stdout, and stderr of the child will be
piped to the parent, otherwise they will be inherited from the parent, see
the `'pipe'` and `'inherit'` options for [`child_process.spawn()`][]'s
Expand Down Expand Up @@ -431,6 +436,9 @@ The `signal` option works exactly the same way it does in
<!-- YAML
added: v0.1.90
changes:
- version: REPLACEME
pr-url: xxx
description: killSignal for AbortSignal was added.
- version: v15.5.0
pr-url: https://github.com/nodejs/node/pull/36432
description: AbortSignal support was added.
Expand Down Expand Up @@ -477,6 +485,8 @@ changes:
* `windowsHide` {boolean} Hide the subprocess console window that would
normally be created on Windows systems. **Default:** `false`.
* `signal` {AbortSignal} allows aborting the execFile using an AbortSignal.
* `killSignal` {string} The signal value to be used when the spawned
process will be killed by the abort signal. **Default:** `'SIGTERM'`.

* Returns: {ChildProcess}

Expand Down
30 changes: 19 additions & 11 deletions lib/child_process.js
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,18 @@ function normalizeSpawnArguments(file, args, options) {
};
}

function abortChildProcess(child, killSignal) {
if (!child)
return;
try {
if (child.kill(killSignal)) {
child.emit('error', new AbortError());
}
} catch (err) {
child.emit('error', err);
}
}


function spawn(file, args, options) {
const child = new ChildProcess();
Expand All @@ -595,21 +607,19 @@ function spawn(file, args, options) {
const signal = options.signal;
// Validate signal, if present
validateAbortSignal(signal, 'options.signal');

const killSignal = sanitizeKillSignal(options.killSignal);
// Do nothing and throw if already aborted
if (signal.aborted) {
onAbortListener();
} else {
signal.addEventListener('abort', onAbortListener, { once: true });
child.once('close',
child.once('exit',
() => signal.removeEventListener('abort', onAbortListener));
}

function onAbortListener() {
process.nextTick(() => {
child?.kill?.(options.killSignal);

child.emit('error', new AbortError());
abortChildProcess(child, killSignal);
});
}
}
Expand Down Expand Up @@ -761,20 +771,18 @@ function spawnWithSignal(file, args, options) {
const child = spawn(file, args, opts);

if (options && options.signal) {
const killSignal = sanitizeKillSignal(options.killSignal);
// Validate signal, if present
validateAbortSignal(options.signal, 'options.signal');
function kill() {
if (child._handle) {
child._handle.kill(options.killSignal || 'SIGTERM');
child.emit('error', new AbortError());
}
abortChildProcess(child, killSignal);
}
if (options.signal.aborted) {
process.nextTick(kill);
} else {
options.signal.addEventListener('abort', kill);
options.signal.addEventListener('abort', kill, { once: true });
const remove = () => options.signal.removeEventListener('abort', kill);
child.once('close', remove);
child.once('exit', remove);
}
}
return child;
Expand Down
45 changes: 42 additions & 3 deletions test/parallel/test-child-process-fork-abort-signal.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

const { mustCall } = require('../common');
const { mustCall, mustNotCall } = require('../common');
const { strictEqual } = require('assert');
const fixtures = require('../common/fixtures');
const { fork } = require('child_process');
Expand All @@ -12,7 +12,10 @@ const { fork } = require('child_process');
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
signal
});
cp.on('exit', mustCall());
cp.on('exit', mustCall((code, killSignal) => {
strictEqual(code, null);
strictEqual(killSignal, 'SIGTERM');
}));
cp.on('error', mustCall((err) => {
strictEqual(err.name, 'AbortError');
}));
Expand All @@ -26,8 +29,44 @@ const { fork } = require('child_process');
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
signal
});
cp.on('exit', mustCall());
cp.on('exit', mustCall((code, killSignal) => {
strictEqual(code, null);
strictEqual(killSignal, 'SIGTERM');
}));
cp.on('error', mustCall((err) => {
strictEqual(err.name, 'AbortError');
}));
}

{
// Test passing a different kill signal
const ac = new AbortController();
const { signal } = ac;
ac.abort();
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
signal,
killSignal: 'SIGKILL',
});
cp.on('exit', mustCall((code, killSignal) => {
strictEqual(code, null);
strictEqual(killSignal, 'SIGKILL');
}));
cp.on('error', mustCall((err) => {
strictEqual(err.name, 'AbortError');
}));
}

{
// Test aborting a cp before close but after exit
const ac = new AbortController();
const { signal } = ac;
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
signal
});
cp.on('exit', mustCall(() => {
ac.abort();
}));
cp.on('error', mustNotCall());

setTimeout(() => cp.kill(), 1);
}
90 changes: 79 additions & 11 deletions test/parallel/test-child-process-spawn-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

const common = require('../common');
const assert = require('assert');
const cp = require('child_process');
const { spawn } = require('child_process');
const fixtures = require('../common/fixtures');

const aliveScript = fixtures.path('child-process-stay-alive-forever.js');
{
// Verify that passing an AbortSignal works
const controller = new AbortController();
const { signal } = controller;

const echo = cp.spawn('echo', ['fun'], {
encoding: 'utf8',
shell: true,
signal
const cp = spawn(process.execPath, [aliveScript], {
signal,
});

echo.on('error', common.mustCall((e) => {
cp.on('exit', common.mustCall((code, killSignal) => {
assert.strictEqual(code, null);
assert.strictEqual(killSignal, 'SIGTERM');
}));

cp.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
}));

Expand All @@ -29,13 +34,76 @@ const cp = require('child_process');

controller.abort();

const echo = cp.spawn('echo', ['fun'], {
encoding: 'utf8',
shell: true,
signal
const cp = spawn(process.execPath, [aliveScript], {
signal,
});
cp.on('exit', common.mustCall((code, killSignal) => {
assert.strictEqual(code, null);
assert.strictEqual(killSignal, 'SIGTERM');
}));

cp.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
}));
}

{
// Verify that waiting a bit and closing works
const controller = new AbortController();
const { signal } = controller;

const cp = spawn(process.execPath, [aliveScript], {
signal,
});

cp.on('exit', common.mustCall((code, killSignal) => {
assert.strictEqual(code, null);
assert.strictEqual(killSignal, 'SIGTERM');
}));

cp.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
}));

setTimeout(() => controller.abort(), 1);
}

{
// Test passing a different killSignal
const controller = new AbortController();
const { signal } = controller;

const cp = spawn(process.execPath, [aliveScript], {
signal,
killSignal: 'SIGKILL',
});

echo.on('error', common.mustCall((e) => {
cp.on('exit', common.mustCall((code, killSignal) => {
assert.strictEqual(code, null);
assert.strictEqual(killSignal, 'SIGKILL');
}));

cp.on('error', common.mustCall((e) => {
assert.strictEqual(e.name, 'AbortError');
}));

setTimeout(() => controller.abort(), 1);
}

{
// Test aborting a cp before close but after exit
const controller = new AbortController();
const { signal } = controller;

const cp = spawn(process.execPath, [aliveScript], {
signal,
});

cp.on('exit', common.mustCall(() => {
controller.abort();
}));

cp.on('error', common.mustNotCall());

setTimeout(() => cp.kill(), 1);
}

0 comments on commit 3111e78

Please sign in to comment.