Skip to content

Commit

Permalink
Do not launch command line in a shell (cmd.exe) by default on Windows
Browse files Browse the repository at this point in the history
  • Loading branch information
clue committed Jan 10, 2019
1 parent 09390e2 commit 015717b
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 18 deletions.
50 changes: 42 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,30 @@ For more details, see the
The `Process` class allows you to pass any kind of command line string:

```php
$process = new Process('echo test');
$process = new Process('echo test', …);
$process->start($loop);
```

The command line string usually consists of a whitespace-separated list with
your main executable bin and any number of arguments. Special care should be
taken to quote any arguments via `escapeshellarg()` if you pass any user input
along. Likewise, keep in mind that especially on Windows, it is rather common to
have path names containing spaces and other special characters. If you want to
run a binary like this, you will have to ensure this is quoted as a single
argument like this:

```php
$bin = 'C:\\Program files (x86)\\PHP\\php.exe';
$file = 'C:\\Users\\me\\Desktop\\Application\\main.php';

$process = new Process(escapeshellarg($bin) . ' ' . escapeshellarg($file), …);
$process->start($loop);
```

By default, PHP will launch processes by wrapping the given command line string
in a `sh` command on Unix, so that the above example will actually execute
`sh -c echo test` under the hood on Unix. On Windows, it will launch processes
by wrapping it in a `cmd` shell like `cmd /C echo test`.
in a `sh` command on Unix, so that the first example will actually execute
`sh -c echo test` under the hood on Unix. On Windows, it will not launch
processes by wrapping them in a shell.

This is a very useful feature because it does not only allow you to pass single
commands, but actually allows you to pass any kind of shell command line and
Expand All @@ -133,6 +149,12 @@ $process = new Process('echo run && demo || echo failed');
$process->start($loop);
```

> Note that [Windows support](#windows-compatibility) is limited in that it
doesn't support STDIO streams at all and also that processes will not be run
in a wrapping shell by default. If you want to run a shell built-in function
such as `echo hello` or `sleep 10`, you may have to prefix your command line
with an explicit shell like `cmd /c echo hello`.

In other words, the underlying shell is responsible for managing this command
line and launching the individual sub-commands and connecting their STDIO
streams as appropriate.
Expand All @@ -145,7 +167,7 @@ implement some higher-level protocol logic, such as printing an explicit
boundary between each sub-command like this:

```php
$process = new Process('cat first && echo --- && cat second');
$process = new Process('cat first && echo --- && cat second', …);
$process->start($loop);
```

Expand All @@ -154,7 +176,7 @@ its `exit` event to conditionally start the next process in the chain.
This will give you an opportunity to configure the subsequent process I/O streams:

```php
$first = new Process('cat first');
$first = new Process('cat first', …);
$first->start($loop);

$first->on('exit', function () use ($loop) {
Expand All @@ -163,7 +185,7 @@ $first->on('exit', function () use ($loop) {
});
```

Keep in mind that PHP uses the shell wrapper for ALL command lines.
Keep in mind that PHP uses the shell wrapper for ALL command lines on Unix.
While this may seem reasonable for more complex command lines, this actually
also applies to running the most simple single command:

Expand All @@ -172,7 +194,7 @@ $process = new Process('yes');
$process->start($loop);
```

This will actually spawn a command hierarchy similar to this:
This will actually spawn a command hierarchy similar to this on Unix:

```
5480 … \_ php example.php
Expand Down Expand Up @@ -525,6 +547,18 @@ want to run a child process on Windows, each with its own set of pros and cons:
In this case, we suggest looking at the excellent
[createprocess-windows](https://github.com/cubiclesoft/createprocess-windows).

Additionally, note that the [command](#command) given to the `Process` will be
passed to the underlying Windows-API
([`CreateProcess`](https://docs.microsoft.com/en-us/windows/desktop/api/processthreadsapi/nf-processthreadsapi-createprocessa))
as-is and the process will not be launched in a wrapping shell by default. In
particular, this means that shell built-in functions such as `echo hello` or
`sleep 10` may have to be prefixed with an explicit shell command like this:

```php
$process = new Process('cmd /c echo hello', null, null, $pipes);
$process->start($loop);
```

## Install

The recommended way to install this library is [through Composer](https://getcomposer.org).
Expand Down
10 changes: 9 additions & 1 deletion src/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,15 @@ public function start(LoopInterface $loop, $interval = 0.1)
$cmd = sprintf('(%s) ' . $sigchild . '>/dev/null; code=$?; echo $code >&' . $sigchild . '; exit $code', $cmd);
}

$this->process = proc_open($cmd, $fdSpec, $pipes, $this->cwd, $this->env);
// on Windows, we do not launch the given command line in a shell (cmd.exe) by default and omit any error dialogs
// the cmd.exe shell can explicitly be given as part of the command as detailed in both documentation and tests
$options = array();
if (DIRECTORY_SEPARATOR === '\\') {
$options['bypass_shell'] = true;
$options['suppress_errors'] = true;
}

$this->process = proc_open($cmd, $fdSpec, $pipes, $this->cwd, $this->env, $options);

if (!is_resource($this->process)) {
throw new \RuntimeException('Unable to launch a new process.');
Expand Down
64 changes: 55 additions & 9 deletions tests/AbstractProcessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ public function testStartWillAssignPipes()

public function testStartWithoutAnyPipesWillNotAssignPipes()
{
$process = new Process('exit 0', null, null, array());
if (DIRECTORY_SEPARATOR === '\\') {
$process = new Process('cmd /c exit 0', null, null, array());
} else {
$process = new Process('exit 0', null, null, array());
}
$process->start($this->createLoop());

$this->assertNull($process->stdin);
Expand Down Expand Up @@ -84,7 +88,7 @@ public function testIsRunning()
{
if (DIRECTORY_SEPARATOR === '\\') {
// Windows doesn't have a sleep command and also does not support process pipes
$process = new Process('php -r ' . escapeshellarg('sleep(1);'), null, null, array());
$process = new Process($this->getPhpBinary() . ' -r ' . escapeshellarg('sleep(1);'), null, null, array());
} else {
$process = new Process('sleep 1');
}
Expand Down Expand Up @@ -156,7 +160,11 @@ public function testReceivesProcessOutputFromStdoutRedirectedToFile()
{
$tmp = tmpfile();

$cmd = 'echo test';
if (DIRECTORY_SEPARATOR === '\\') {
$cmd = 'cmd /c echo test';
} else {
$cmd = 'echo test';
}

$loop = $this->createLoop();
$process = new Process($cmd, null, null, array(1 => $tmp));
Expand All @@ -168,6 +176,27 @@ public function testReceivesProcessOutputFromStdoutRedirectedToFile()
$this->assertEquals('test', rtrim(stream_get_contents($tmp)));
}

public function testReceivesProcessOutputFromTwoCommandsChainedStdoutRedirectedToFile()
{
$tmp = tmpfile();

if (DIRECTORY_SEPARATOR === '\\') {
// omit whitespace before "&&" and quotation marks as Windows will actually echo this otherwise
$cmd = 'cmd /c echo hello&& cmd /c echo world';
} else {
$cmd = 'echo "hello" && echo "world"';
}

$loop = $this->createLoop();
$process = new Process($cmd, null, null, array(1 => $tmp));
$process->start($loop);

$loop->run();

rewind($tmp);
$this->assertEquals("hello\nworld", str_replace("\r\n", "\n", rtrim(stream_get_contents($tmp))));
}

public function testReceivesProcessOutputFromStdoutAttachedToSocket()
{
if (DIRECTORY_SEPARATOR === '\\') {
Expand Down Expand Up @@ -199,9 +228,14 @@ public function testReceivesProcessOutputFromStdoutRedirectedToSocketProcess()
// create TCP/IP server on random port and wait for client connection
$server = stream_socket_server('tcp://127.0.0.1:0');

$cmd = 'echo test';
if (DIRECTORY_SEPARATOR === '\\') {
$cmd = 'cmd /c echo test';
} else {
$cmd = 'exec echo test';
}

$code = '$s=stream_socket_client($argv[1]);do{$d=fread(STDIN,8192);fwrite($s,$d);}while(!feof(STDIN));fclose($s);';
$cmd .= ' | php -r ' . escapeshellarg($code) . ' ' . escapeshellarg(stream_socket_get_name($server, false));
$cmd .= ' | ' . $this->getPhpBinary() . ' -r ' . escapeshellarg($code) . ' ' . escapeshellarg(stream_socket_get_name($server, false));

$loop = $this->createLoop();

Expand Down Expand Up @@ -505,7 +539,13 @@ public function testDetectsClosingProcessEvenWhenAllStdioPipesHaveBeenClosed()
public function testDetectsClosingProcessEvenWhenStartedWithoutPipes()
{
$loop = $this->createLoop();
$process = new Process('exit 0', null, null, array());

if (DIRECTORY_SEPARATOR === '\\') {
$process = new Process('cmd /c exit 0', null, null, array());
} else {
$process = new Process('exit 0', null, null, array());
}

$process->start($loop, 0.001);

$time = microtime(true);
Expand Down Expand Up @@ -548,10 +588,11 @@ public function testStartAlreadyRunningProcess()
{
if (DIRECTORY_SEPARATOR === '\\') {
// Windows doesn't have a sleep command and also does not support process pipes
$process = new Process('php -r ' . escapeshellarg('sleep(1);'), null, null, array());
$process = new Process($this->getPhpBinary() . ' -r ' . escapeshellarg('sleep(1);'), null, null, array());
} else {
$process = new Process('sleep 1');
}
//var_dump($process);

$process->start($this->createLoop());
$process->start($this->createLoop());
Expand All @@ -561,7 +602,7 @@ public function testTerminateProcesWithoutStartingReturnsFalse()
{
if (DIRECTORY_SEPARATOR === '\\') {
// Windows doesn't have a sleep command and also does not support process pipes
$process = new Process('php -r ' . escapeshellarg('sleep(1);'), null, null, array());
$process = new Process($this->getPhpBinary() . ' -r ' . escapeshellarg('sleep(1);'), null, null, array());
} else {
$process = new Process('sleep 1');
}
Expand All @@ -573,7 +614,7 @@ public function testTerminateWillExit()
{
if (DIRECTORY_SEPARATOR === '\\') {
// Windows doesn't have a sleep command and also does not support process pipes
$process = new Process('php -r ' . escapeshellarg('sleep(10);'), null, null, array());
$process = new Process($this->getPhpBinary() . ' -r ' . escapeshellarg('sleep(10);'), null, null, array());
} else {
$process = new Process('sleep 10');
}
Expand Down Expand Up @@ -771,6 +812,11 @@ public function assertSoon(\Closure $callback, $timeout = 20000, $interval = 200
}
}

/**
* Returns the path to the PHP binary. This is already escapescaped via `escapeshellarg()`.
*
* @return string
*/
private function getPhpBinary()
{
$runtime = new Runtime();
Expand Down

0 comments on commit 015717b

Please sign in to comment.