Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

child_process: add shell option to spawn() #4598

Merged
merged 1 commit into from
Jan 27, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions doc/api/child_process.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,12 @@ The importance of the distinction between `child_process.exec()` and
`child_process.execFile()` can vary based on platform. On Unix-type operating
systems (Unix, Linux, OSX) `child_process.execFile()` can be more efficient
because it does not spawn a shell. On Windows, however, `.bat` and `.cmd`
files are not executable on their own without a terminal and therefore cannot
be launched using `child_process.execFile()` (or even `child_process.spawn()`).
When running on Windows, `.bat` and `.cmd` files can only be invoked using
either `child_process.exec()` or by spawning `cmd.exe` and passing the `.bat`
or `.cmd` file as an argument (which is what `child_process.exec()` does).
files are not executable on their own without a terminal, and therefore cannot
be launched using `child_process.execFile()`. When running on Windows, `.bat`
and `.cmd` files can be invoked using `child_process.spawn()` with the `shell`
option set, with `child_process.exec()`, or by spawning `cmd.exe` and passing
the `.bat` or `.cmd` file as an argument (which is what the `shell` option and
`child_process.exec()` do).

```js
// On Windows Only ...
Expand Down Expand Up @@ -277,6 +278,10 @@ not clone the current process.*
[`options.detached`][])
* `uid` {Number} Sets the user identity of the process. (See setuid(2).)
* `gid` {Number} Sets the group identity of the process. (See setgid(2).)
* `shell` {Boolean|String} If `true`, runs `command` inside of a shell. Uses
'/bin/sh' on UNIX, and 'cmd.exe' on Windows. A different shell can be
specified as a string. The shell should understand the `-c` switch on UNIX,
or `/s /c` on Windows. Defaults to `false` (no shell).
* return: {ChildProcess object}

The `child_process.spawn()` method spawns a new process using the given
Expand Down Expand Up @@ -581,6 +586,10 @@ throw. The [`Error`][] object will contain the entire result from
* `maxBuffer` {Number} largest amount of data (in bytes) allowed on stdout or
stderr - if exceeded child process is killed
* `encoding` {String} The encoding used for all stdio inputs and outputs. (Default: 'buffer')
* `shell` {Boolean|String} If `true`, runs `command` inside of a shell. Uses
'/bin/sh' on UNIX, and 'cmd.exe' on Windows. A different shell can be
specified as a string. The shell should understand the `-c` switch on UNIX,
or `/s /c` on Windows. Defaults to `false` (no shell).
* return: {Object}
* `pid` {Number} Pid of the child process
* `output` {Array} Array of results from stdio output
Expand Down
53 changes: 29 additions & 24 deletions lib/child_process.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ exports._forkChild = function(fd) {


function normalizeExecArgs(command /*, options, callback*/) {
var file, args, options, callback;
let options;
let callback;

if (typeof arguments[1] === 'function') {
options = undefined;
Expand All @@ -81,25 +82,12 @@ function normalizeExecArgs(command /*, options, callback*/) {
callback = arguments[2];
}

if (process.platform === 'win32') {
file = process.env.comspec || 'cmd.exe';
args = ['/s', '/c', '"' + command + '"'];
// Make a shallow copy before patching so we don't clobber the user's
// options object.
options = util._extend({}, options);
options.windowsVerbatimArguments = true;
} else {
file = '/bin/sh';
args = ['-c', command];
}

if (options && options.shell)
file = options.shell;
// Make a shallow copy so we don't clobber the user's options object.
options = Object.assign({}, options);
options.shell = typeof options.shell === 'string' ? options.shell : true;

return {
cmd: command,
file: file,
args: args,
file: command,
options: options,
callback: callback
};
Expand All @@ -109,7 +97,6 @@ function normalizeExecArgs(command /*, options, callback*/) {
exports.exec = function(command /*, options, callback*/) {
var opts = normalizeExecArgs.apply(null, arguments);
return exports.execFile(opts.file,
opts.args,
opts.options,
opts.callback);
};
Expand All @@ -123,7 +110,8 @@ exports.execFile = function(file /*, args, options, callback*/) {
maxBuffer: 200 * 1024,
killSignal: 'SIGTERM',
cwd: null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work? execFile() ends up calling spawn() but I can't figure out where opts.args is used as the arguments array.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand your question. The args to execFile() is optional. Before this change, args would contain the arguments to the shell and the command. With this change, that happens in normalizeSpawnArguments().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is what confuses me:

  1. exec() calls execFile() without an args argument.
  2. execFile() calls spawn() with an empty array as its args argument.
  3. spawn() calls normalizeSpawnArguments() with that empty array as its args argument.
  4. normalizeSpawnArguments() returns that empty array in an options object.
  5. The opts.args from that object is passed to child.spawn().

IOW, I get the impression that the opts.args from normalizeExecArgs() is lost. I assume that's not the case but I don't understand why not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior to this change, args would have been something like ['-c', command]. That would have been sent through execFile(), spawn(), etc.

With this change, normalizeExecArgs() sets shell in options instead. This is passed through until normalizeSpawnArguments(), where the same ['-c', command] is created and passed to child.spawn().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, that makes sense. Objection withdrawn.

env: null
env: null,
shell: false
};

// Parse the optional positional parameters.
Expand Down Expand Up @@ -153,6 +141,7 @@ exports.execFile = function(file /*, args, options, callback*/) {
env: options.env,
gid: options.gid,
uid: options.uid,
shell: options.shell,
windowsVerbatimArguments: !!options.windowsVerbatimArguments
});

Expand Down Expand Up @@ -331,7 +320,23 @@ function normalizeSpawnArguments(file /*, args, options*/) {
else if (options === null || typeof options !== 'object')
throw new TypeError('"options" argument must be an object');

options = util._extend({}, options);
// Make a shallow copy so we don't clobber the user's options object.
options = Object.assign({}, options);

if (options.shell) {
const command = [file].concat(args).join(' ');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't double quotes be escaped on Windows?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably. I'll add a test for it too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, maybe not:

C:\Users\Colin>cmd.exe /s /c "echo "foo""
"foo"

C:\Users\Colin>cmd.exe /s /c "echo \"foo\""
\"foo\"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, I wonder what cmd.exe's algorithm for that is. Counting quotes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, looks like the /s enables that behavior - http://stackoverflow.com/questions/9866962/what-is-cmd-s-for

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Learned something new today. Thanks.


if (process.platform === 'win32') {
file = typeof options.shell === 'string' ? options.shell :
process.env.comspec || 'cmd.exe';
args = ['/s', '/c', '"' + command + '"'];
options.windowsVerbatimArguments = true;
} else {
file = typeof options.shell === 'string' ? options.shell : '/bin/sh';
args = ['-c', command];
}
}

args.unshift(file);

var env = options.env || process.env;
Expand Down Expand Up @@ -491,12 +496,12 @@ function execFileSync(/*command, args, options*/) {
exports.execFileSync = execFileSync;


function execSync(/*command, options*/) {
function execSync(command /*, options*/) {
var opts = normalizeExecArgs.apply(null, arguments);
var inheritStderr = opts.options ? !opts.options.stdio : true;

var ret = spawnSync(opts.file, opts.args, opts.options);
ret.cmd = opts.cmd;
var ret = spawnSync(opts.file, opts.options);
ret.cmd = command;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the change on this line is harmless but I don't quite see why you made it. Because the format of opts.cmd may change in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it because opts.cmd came from normalizeExecArgs(), but is removed in this PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed that. Carry on.


if (inheritStderr)
process.stderr.write(ret.stderr);
Expand Down
64 changes: 64 additions & 0 deletions test/parallel/test-child-process-spawn-shell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const cp = require('child_process');

// Verify that a shell is, in fact, executed
const doesNotExist = cp.spawn('does-not-exist', {shell: true});

assert.notEqual(doesNotExist.spawnfile, 'does-not-exist');
doesNotExist.on('error', common.fail);
doesNotExist.on('exit', common.mustCall((code, signal) => {
assert.strictEqual(signal, null);

if (common.isWindows)
assert.strictEqual(code, 1); // Exit code of cmd.exe
else
assert.strictEqual(code, 127); // Exit code of /bin/sh
}));

// Verify that passing arguments works
const echo = cp.spawn('echo', ['foo'], {
encoding: 'utf8',
shell: true
});
let echoOutput = '';

assert.strictEqual(echo.spawnargs[echo.spawnargs.length - 1].replace(/"/g, ''),
'echo foo');
echo.stdout.on('data', data => {
echoOutput += data;
});
echo.on('close', common.mustCall((code, signal) => {
assert.strictEqual(echoOutput.trim(), 'foo');
}));

// Verify that shell features can be used
const cmd = common.isWindows ? 'echo bar | more' : 'echo bar | cat';
const command = cp.spawn(cmd, {
encoding: 'utf8',
shell: true
});
let commandOutput = '';

command.stdout.on('data', data => {
commandOutput += data;
});
command.on('close', common.mustCall((code, signal) => {
assert.strictEqual(commandOutput.trim(), 'bar');
}));

// Verify that the environment is properly inherited
const env = cp.spawn(`"${process.execPath}" -pe process.env.BAZ`, {
env: Object.assign({}, process.env, {BAZ: 'buzz'}),
encoding: 'utf8',
shell: true
});
let envOutput = '';

env.stdout.on('data', data => {
envOutput += data;
});
env.on('close', common.mustCall((code, signal) => {
assert.strictEqual(envOutput.trim(), 'buzz');
}));
37 changes: 37 additions & 0 deletions test/parallel/test-child-process-spawnsync-shell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const cp = require('child_process');

// Verify that a shell is, in fact, executed
const doesNotExist = cp.spawnSync('does-not-exist', {shell: true});

assert.notEqual(doesNotExist.file, 'does-not-exist');
assert.strictEqual(doesNotExist.error, undefined);
assert.strictEqual(doesNotExist.signal, null);

if (common.isWindows)
assert.strictEqual(doesNotExist.status, 1); // Exit code of cmd.exe
else
assert.strictEqual(doesNotExist.status, 127); // Exit code of /bin/sh

// Verify that passing arguments works
const echo = cp.spawnSync('echo', ['foo'], {shell: true});

assert.strictEqual(echo.args[echo.args.length - 1].replace(/"/g, ''),
'echo foo');
assert.strictEqual(echo.stdout.toString().trim(), 'foo');

// Verify that shell features can be used
const cmd = common.isWindows ? 'echo bar | more' : 'echo bar | cat';
const command = cp.spawnSync(cmd, {shell: true});

assert.strictEqual(command.stdout.toString().trim(), 'bar');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add one or two more tests to verify that the environment is inherited correctly?


// Verify that the environment is properly inherited
const env = cp.spawnSync(`"${process.execPath}" -pe process.env.BAZ`, {
env: Object.assign({}, process.env, {BAZ: 'buzz'}),
shell: true
});

assert.strictEqual(env.stdout.toString().trim(), 'buzz');