Skip to content

Commit 04ac844

Browse files
committed
Implemented non-blocking stdin and event-based stream switching
1 parent 515fe71 commit 04ac844

File tree

2 files changed

+166
-59
lines changed

2 files changed

+166
-59
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@ $cmd = Command::factory('ls')
6464
->run(null, true);
6565
```
6666

67+
### Streaming large command output
68+
The STDOUT and STDERR is collected inside PHP by default. If you have a large amount of data to pass into the command, you should stream it in (see STDIN from a stream below). If you have a large amount of output from the command, you should stream it out using a callback:
69+
70+
```php
71+
use kamermans\Command\Command;
72+
73+
require_once __DIR__.'/../vendor/autoload.php';
74+
75+
$filename = __DIR__.'/../README.md';
76+
$stdin = fopen($filename, 'r');
77+
78+
// This will read README.md and grep for lines containing 'the'
79+
$cmd = Command::factory("grep 'the'")
80+
->setCallback(function($pipe, $data) {
81+
// Change the text to uppercase
82+
$data = strtoupper($data);
83+
84+
if ($pipe === Command::STDERR) {
85+
Command::echoStdErr($data);
86+
} else {
87+
echo $data;
88+
}
89+
})
90+
->run($stdin);
91+
92+
fclose($stdin);
93+
94+
```
95+
6796
### Running a Command without Escaping
6897
By default, the command passed to `Command::factory(string $command, bool $escape)` is escaped, so characters like `|` and `>` will replaced with `\|` and `\>` respectively. To prevent the command factory from escaping your command, you can pass `true` as the second argument:
6998

src/Command.php

Lines changed: 137 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class Command
2525
protected $_readbuffer = 16536;
2626
protected $_separator = ' ';
2727
protected $_cmd;
28-
protected $_args = array();
28+
protected $_args = [];
2929
protected $_exitcode;
3030
protected $_stdout;
3131
protected $_stderr;
@@ -35,7 +35,7 @@ class Command
3535
protected $_timeend;
3636
protected $_cwd;
3737
protected $_env;
38-
protected $_conf = array();
38+
protected $_conf = [];
3939

4040
/**
4141
* Creates a new Command object
@@ -47,7 +47,7 @@ class Command
4747
static public function factory($cmd = null, $noescape = false)
4848
{
4949
$obj = new self();
50-
if ($cmd !== NULL) {
50+
if ($cmd !== null) {
5151
$obj->command($cmd, $noescape);
5252
}
5353
return $obj;
@@ -105,7 +105,7 @@ public function setDirectory($cwd)
105105
* @param array $env
106106
* @return Command - Fluent
107107
*/
108-
public function setEnv($env = array())
108+
public function setEnv($env = [])
109109
{
110110
$this->_env = $env;
111111
return $this;
@@ -158,12 +158,12 @@ public function command($cmd, $noescape = false)
158158
*/
159159
public function option($left, $right = null, $sep = null)
160160
{
161-
if ($right !== NULL) {
161+
if ($right !== null) {
162162
$right = escapeshellarg($right);
163163
if (empty($right)) {
164164
$right = "''";
165165
}
166-
$left .= ($sep === NULL ? $this->_separator : $sep) . $right;
166+
$left .= ($sep === null ? $this->_separator : $sep) . $right;
167167
}
168168

169169
$this->_args[] = $left;
@@ -198,11 +198,11 @@ public function run($stdin = null, $throw_exceptions = true)
198198
$this->_timestart = microtime(true);
199199

200200
// Prepare the buffers structure
201-
$buffers = array(
201+
$buffers = [
202202
0 => $stdin,
203203
1 => &$this->_stdout,
204204
2 => &$this->_stderr,
205-
);
205+
];
206206
$this->_exitcode = self::exec($this->getFullCommand(), $buffers, $this->_callback, $this->_callbacklines, $this->_readbuffer, $this->_cwd, $this->_env, $this->_conf);
207207
$this->_timeend = microtime(true);
208208

@@ -225,7 +225,7 @@ public function __toString()
225225
*/
226226
public function getFullCommand()
227227
{
228-
$parts = array_merge(array($this->_cmd), $this->_args);
228+
$parts = array_merge([$this->_cmd], $this->_args);
229229
return implode($this->_separator, $parts);
230230
}
231231

@@ -295,74 +295,124 @@ public static function echoStdErr($content)
295295
public static function exec($cmd, &$buffers, $callback = null, $callbacklines = false, $readbuffer = 16536, $cwd = null, $env = null, $conf = null)
296296
{
297297
if (!is_array($buffers)) {
298-
$buffers = array();
298+
$buffers = [];
299299
}
300300

301301
// Define the pipes to configure for the process
302-
$descriptors = array(
303-
self::STDIN => array('pipe', 'r'),
304-
self::STDOUT => array('pipe', 'w'),
305-
self::STDERR => array('pipe', 'w'),
306-
);
302+
$descriptors = [
303+
self::STDIN => ['pipe', 'r'],
304+
self::STDOUT => ['pipe', 'w'],
305+
self::STDERR => ['pipe', 'w'],
306+
];
307307

308308
// Start the process
309309
$ph = proc_open($cmd, $descriptors, $pipes, $cwd, $env, $conf);
310310
if (!is_resource($ph)) {
311311
return null;
312312
}
313313

314-
// Feed the process with the stdin if any and close it
314+
// Prepare STDIN
315+
$stdin_position = 0;
315316
$stdin = $buffers[self::STDIN];
316-
if (is_resource($stdin)) {
317-
// It seems this method is less memory-intensive that the stream copying builtin:
318-
// stream_copy_to_stream(resource $source, resource $dest)
319-
while(!feof($stdin)) {
320-
fwrite($pipes[self::STDIN], fread($stdin, $readbuffer));
321-
}
322-
323-
} else if (!empty($stdin)) {
324-
fwrite($pipes[self::STDIN], $stdin);
317+
$stdin_is_stream = is_resource($stdin);
318+
$use_stdin = $stdin_is_stream || !empty($stdin);
319+
$stdin_length = null;
320+
321+
if (!$use_stdin) {
322+
fclose($pipes[self::STDIN]);
323+
} else if (!$stdin_is_stream) {
324+
$stdin_length = strlen($stdin);
325325
}
326326

327-
fclose($pipes[self::STDIN]);
327+
// Setup all streams to non-blocking mode
328+
stream_set_blocking($pipes[self::STDIN], false);
329+
stream_set_blocking($pipes[self::STDOUT], false);
330+
stream_set_blocking($pipes[self::STDERR], false);
328331

329-
// Setup non-blocking behaviour for stdout and stderr
330-
stream_set_blocking($pipes[self::STDOUT], 0);
331-
stream_set_blocking($pipes[self::STDERR], 0);
332+
$stream_select_timeout_sec = null;
333+
$stream_select_timeout_usec = null;
332334

333335
$delay = 0;
334336
$code = null;
335-
$open = array(self::STDOUT, self::STDERR);
337+
338+
$buffers[self::STDIN] = '';
336339
$buffers[self::STDOUT] = empty($buffers[self::STDOUT]) ? '' : $buffers[self::STDOUT];
337340
$buffers[self::STDERR] = empty($buffers[self::STDERR]) ? '' : $buffers[self::STDERR];
338341

339-
while (!empty($open)) {
342+
// Read from the process' STDOUT and STDERR
343+
$reads = [
344+
$pipes[self::STDOUT],
345+
$pipes[self::STDERR],
346+
];
347+
348+
// Write to the process' STDIN
349+
$writes = [
350+
$pipes[self::STDIN],
351+
];
352+
353+
$stream_id_map = [
354+
self::STDIN => $pipes[self::STDIN],
355+
self::STDOUT => $pipes[self::STDOUT],
356+
self::STDERR => $pipes[self::STDERR],
357+
];
358+
359+
// Read/write loop
360+
while (true) {
361+
362+
// Setup streams before each iteration since they are changed by stream_select()
363+
$streams = [
364+
'read' => array_filter($reads, 'is_resource'),
365+
'write' => array_filter($writes, 'is_resource'),
366+
'except' => [],
367+
];
368+
369+
// This line will block until a stream is ready for input or output
370+
$ready_streams = stream_select(
371+
$streams['read'],
372+
$streams['write'],
373+
$streams['except'],
374+
$stream_select_timeout_sec,
375+
$stream_select_timeout_usec
376+
);
377+
340378
// Try to find the exit code of the command before buggy proc_close()
341-
if ($code === NULL) {
379+
if ($code === null) {
342380
$status = proc_get_status($ph);
343381
if (!$status['running']) {
344382
$code = $status['exitcode'];
383+
break;
345384
}
346385
}
347386

348-
// Go thru all open pipes and check for data
349-
foreach ($open as $i=>$pipe) {
350-
// Try to get some data
351-
$str = fread($pipes[$pipe], $readbuffer);
352-
if (strlen($str)) {
353-
$buffers[$pipe] .= $str;
387+
if ($ready_streams === 0) {
388+
// Stream timeout; no streams ready, retry stream_select
389+
continue;
390+
}
391+
392+
if ($ready_streams === false) {
393+
throw new \Exception("stream_select() failed while waiting for I/O on command");
394+
}
395+
396+
// Read from all ready streams
397+
foreach ($streams['read'] as $stream) {
398+
399+
$stream_id = array_search($stream, $stream_id_map, true);
400+
401+
$str = stream_get_contents($stream, $readbuffer);
402+
if (strlen($str) !== 0) {
403+
$buffers[$stream_id] .= $str;
354404

355405
if ($callback) {
356406
if ($callbacklines) {
357407
// Note: \r will be left in the line in case of CRLF,
358408
// and we will need to add \n to the end of each line
359-
$lines = explode("\n", $buffers[$pipe]);
409+
$lines = explode("\n", $buffers[$stream_id]);
360410

361411
// This is left over and does not end with the delimiter
362-
$buffers[$pipe] = array_pop($lines);
412+
$buffers[$stream_id] = array_pop($lines);
363413

364414
foreach ($lines as $line) {
365-
$callback_return = call_user_func($callback, $pipe, "$line\n");
415+
$callback_return = call_user_func($callback, $stream_id, "$line\n");
366416
if ($callback_return === false) {
367417
// We killed the proc early, set code to 0
368418
$code = 0;
@@ -371,8 +421,8 @@ public static function exec($cmd, &$buffers, $callback = null, $callbacklines =
371421
}
372422

373423
} else {
374-
$callback_return = call_user_func($callback, $pipe, $buffers[$pipe]);
375-
$buffers[$pipe] = '';
424+
$callback_return = call_user_func($callback, $stream_id, $buffers[$stream_id]);
425+
$buffers[$stream_id] = '';
376426
if ($callback_return === false) {
377427
// We killed the proc early, set code to 0
378428
$code = 0;
@@ -384,28 +434,56 @@ public static function exec($cmd, &$buffers, $callback = null, $callbacklines =
384434
// Since we've got some data we don't need to sleep :)
385435
$delay = 0;
386436
// Check if we have consumed all the data in the current pipe
387-
} else if (feof($pipes[$pipe])) {
388-
if ($callback) {
389-
if (call_user_func($callback, $pipe, null) === false) {
390-
break 2;
437+
}
438+
}
439+
440+
// Write to all write ready streams (STDIN of the process)
441+
foreach ($streams['write'] as $stream) {
442+
443+
$stream_id = array_search($stream, $stream_id_map, true);
444+
445+
if ($stdin_is_stream) {
446+
// It seems this method is less memory-intensive that the stream copying builtin:
447+
// stream_copy_to_stream(resource $source, resource $dest)
448+
if (feof($stdin)) {
449+
fclose($stream);
450+
} else {
451+
if (strlen($buffers[$stream_id]) < $readbuffer) {
452+
// The STDIN buffer is running low
453+
$buffers[$stream_id] .= stream_get_contents($stdin, $readbuffer);
454+
}
455+
456+
$bytes_written = fwrite($stream, $buffers[$stream_id]);
457+
458+
if ($bytes_written === false) {
459+
continue;
460+
}
461+
462+
$buffer_length = strlen($buffers[$stream_id]);
463+
464+
if ($bytes_written === $buffer_length) {
465+
$buffers[$stream_id] = '';
466+
} else {
467+
// Only part of the buffer was written so we remove that part
468+
$buffers[$stream_id] = substr($buffers[$stream_id], $bytes_written);
391469
}
392470
}
393-
unset($open[$i]);
394-
continue 2;
471+
472+
} else {
473+
if ($stdin_position >= $stdin_length) {
474+
fclose($stream);
475+
} else {
476+
$chunk = substr($stdin, $stdin_position, $readbuffer);
477+
$bytes_written = fwrite($stream, $chunk);
478+
$stdin_position += $bytes_written;
479+
}
395480
}
396481
}
397482

398-
// Check if we have to sleep for a bit to be nice on the CPU
399-
if ($delay) {
400-
usleep($delay * 1000);
401-
$delay = ceil(min(self::SLEEP_MAX, $delay*self::SLEEP_FACTOR));
402-
} else {
403-
$delay = self::SLEEP_START;
404-
}
405-
}
483+
} // End read/write loop
406484

407485
// Make sure all pipes are closed
408-
foreach ($pipes as $pipe=>$desc) {
486+
foreach ($pipes as $pipe => $desc) {
409487
if (is_resource($desc)) {
410488
if ($callback) {
411489
call_user_func($callback, $pipe, null);
@@ -421,7 +499,7 @@ public static function exec($cmd, &$buffers, $callback = null, $callbacklines =
421499
}
422500

423501
// Find out the exit code
424-
if ($code === NULL) {
502+
if ($code === null) {
425503
$code = proc_close($ph);
426504
} else {
427505
proc_close($ph);

0 commit comments

Comments
 (0)