Skip to content

Commit

Permalink
Issue #20 Implement read/write loop in non-blocking mode
Browse files Browse the repository at this point in the history
  • Loading branch information
mikehaertl committed Aug 15, 2019
1 parent 57ba6f1 commit d66f8a1
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 22 deletions.
90 changes: 71 additions & 19 deletions src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,40 +343,92 @@ public function execute()
return false;
}
} else {
$isInputStream = $this->_stdIn !== null &&
is_resource($this->_stdIn) &&
in_array(get_resource_type($this->_stdIn), array('file', 'stream'));
$isInputString = is_string($this->_stdIn);
$hasInput = $isInputStream || $isInputString;

$descriptors = array(
1 => array('pipe','w'),
2 => array('pipe', $this->getIsWindows() ? 'a' : 'w'),
);
if ($this->_stdIn!==null) {
if ($hasInput) {
$descriptors[0] = array('pipe', 'r');
}

// Issue #20 Set non-blocking mode to fix hanging processes
$nonBlocking = $this->nonBlockingMode === null ?
!$this->getIsWindows() : $this->nonBlockingMode;

$process = proc_open($command, $descriptors, $pipes, $this->procCwd, $this->procEnv, $this->procOptions);

if (is_resource($process)) {
// Issue #20 Set non-blocking mode to fix hanging processes
$nonBlocking = $this->nonBlockingMode === null ?
!$this->getIsWindows() : $this->nonBlockingMode;

if ($nonBlocking) {
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
}

if ($this->_stdIn!==null) {
if (is_resource($this->_stdIn) &&
in_array(get_resource_type($this->_stdIn), array('file', 'stream'), true)) {
stream_copy_to_stream($this->_stdIn, $pipes[0]);
} else {
fwrite($pipes[0], $this->_stdIn);
if ($hasInput) {
$writtenBytes = 0;
stream_set_blocking($pipes[0], false);
if ($isInputStream) {
stream_set_blocking($this->_stdIn, false);
}
}
fclose($pipes[0]);
$running = true;
// Due to the non-blocking streams we now have to check if
// the process is still running. We also need to ensure
// that all the pipes are written/read alternately until
// there's nothing left to write/read.
while ($running) {
$status = proc_get_status($process);
$running = $status['running'];
if ($hasInput && $running) {
if ($isInputStream) {
$written = stream_copy_to_stream($this->_stdIn, $pipes[0], 16 * 1024, $writtenBytes);
if ($written === false || $written === 0) {
fclose($pipes[0]);
} else {
$writtenBytes += $written;
}
} else {
if ($writtenBytes < strlen($this->_stdIn)) {
$writtenBytes += fwrite($pipes[0], substr($this->_stdIn, $writtenBytes));
} else {
fclose($pipes[0]);
}
}
}
while (($out = fgets($pipes[1])) !== false) {
$this->_stdOut .= $out;
}
while (($err = fgets($pipes[2])) !== false) {
$this->_stdErr .= $err;
}
if (!$running) {
$this->_exitCode = $status['exitcode'];
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
} else {
usleep(10000); // wait 10 ms
}
}
} else {
if ($hasInput) {
if ($isInputStream) {
stream_copy_to_stream($this->_stdIn, $pipes[0]);
} elseif ($isInputString) {
fwrite($pipes[0], $this->_stdIn);
}
fclose($pipes[0]);
}
$this->_stdOut = stream_get_contents($pipes[1]);
$this->_stdErr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$this->_exitCode = proc_close($process);
}
$this->_stdOut = stream_get_contents($pipes[1]);
$this->_stdErr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);

$this->_exitCode = proc_close($process);

if ($this->_exitCode !== 0) {
$this->_error = $this->_stdErr ?
Expand Down
150 changes: 150 additions & 0 deletions tests/BlockingCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php
use mikehaertl\shellcommand\Command;

class BlockingCommandTest extends \PHPUnit\Framework\TestCase
{
public function setUp()
{
// Default in some installations
setlocale(LC_CTYPE, 'C');
}

// Create command from command string
public function testCanPassCommandStringToConstructor()
{
$command = new Command('/bin/ls -l');

$this->assertEquals('/bin/ls -l', $command->getExecCommand());
$this->assertNull($command->nonBlockingMode);
}

// Options
public function testCanSetOptions()
{
$command = new Command;
$command->setOptions(array(
'command' => 'echo',
'escapeArgs' => false,
'procEnv' => array('TESTVAR' => 'test'),
'args' => '-n $TESTVAR',
'nonBlockingMode' => false,
));
$this->assertFalse($command->nonBlockingMode);
$this->assertEquals('echo -n $TESTVAR', $command->getExecCommand());
$this->assertFalse($command->escapeArgs);
$this->assertFalse($command->getExecuted());
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals('test', $command->getOutput());
}
public function testCanPassOptionsToConstructor()
{
$command = new Command(array(
'command' => 'echo',
'args' => '-n $TESTVAR',
'escapeArgs' => false,
'procEnv' => array('TESTVAR' => 'test'),
'nonBlockingMode' => false,
));
$this->assertFalse($command->nonBlockingMode);
$this->assertEquals('echo -n $TESTVAR', $command->getExecCommand());
$this->assertFalse($command->escapeArgs);
$this->assertFalse($command->getExecuted());
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals('test', $command->getOutput());
}


public function testCanRunCommandWithArguments()
{
$command = new Command('ls');
$command->nonBlockingMode = false;
$command->addArg('-l');
$command->addArg('-n');
$this->assertEquals("ls -l -n", $command->getExecCommand());
$this->assertFalse($command->getExecuted());
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
}

// Output / error / exit code
public function testCanRunValidCommand()
{
$dir = __DIR__;
$command = new Command("/bin/ls $dir/Command*");
$command->nonBlockingMode = false;

$this->assertFalse($command->getExecuted());
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals("$dir/CommandTest.php", $command->getOutput());
$this->assertEquals("$dir/CommandTest.php\n", $command->getOutput(false));
$this->assertEmpty($command->getError());
$this->assertEmpty($command->getStdErr());
$this->assertEquals(0, $command->getExitCode());
}
public function testCanNotRunEmptyCommand()
{
$command = new Command('');
$command->nonBlockingMode = false;
$this->assertFalse($command->execute());
$this->assertEquals('Could not locate any executable command', $command->getError());
}
public function testCanNotRunNotExistantCommand()
{
$command = new Command('/does/not/exist');
$command->nonBlockingMode = false;
$this->assertFalse($command->getExecuted());
$this->assertFalse($command->execute());
$this->assertFalse($command->getExecuted());
$this->assertNotEmpty($command->getError());
$this->assertNotEmpty($command->getStdErr());
$this->assertEmpty($command->getOutput());
$this->assertEquals(127, $command->getExitCode());
}
public function testCanNotRunInvalidCommand()
{
$command = new Command('ls --this-does-not-exist');
$command->nonBlockingMode = false;
$this->assertFalse($command->getExecuted());
$this->assertFalse($command->execute());
$this->assertFalse($command->getExecuted());
$this->assertNotEmpty($command->getError());
$this->assertNotEmpty($command->getStdErr());
$this->assertEmpty($command->getOutput());
$this->assertEquals(2, $command->getExitCode());
}


// Proc
public function testCanProvideProcEnvVars()
{
$command = new Command('echo $TESTVAR');
$command->nonBlockingMode = false;
$command->procEnv = array('TESTVAR' => 'testvalue');
$this->assertTrue($command->execute());
$this->assertEquals("testvalue", $command->getOutput());
}
public function testCanProvideProcDir()
{
$tmpDir = sys_get_temp_dir();
$command = new Command('pwd');
$command->procCwd = $tmpDir;
$command->nonBlockingMode = false;
$this->assertFalse($command->getExecuted());
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals($tmpDir, $command->getOutput());
}
public function testCanRunCommandWithStandardInput()
{
$command = new Command('/bin/cat');
$command->nonBlockingMode = false;
$command->addArg('-T');
$command->setStdIn("\t");
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals("^I", $command->getOutput());
}
}
15 changes: 12 additions & 3 deletions tests/CommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,25 @@ public function testCanRunCommandWithStandardInput()
}
public function testCanRunCommandWithStandardInputStream()
{
$string = str_repeat('01234567890abcdef', 16 * 1024); // 16 * 16 * 1024 = 256KB
$tmpfile = tmpfile();
fwrite($tmpfile, "\t");
fwrite($tmpfile, $string);
fseek($tmpfile, 0);
$command = new Command('/bin/cat');
$command->addArg('-T');
$command->setStdIn($tmpfile);
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals("^I", $command->getOutput());
$this->assertEquals(strlen($string), strlen($command->getOutput()));
fclose($tmpfile);
}

public function testCanRunCommandWithBigInputAndOutput()
{
$string = str_repeat('01234567890abcdef', 16 * 1024); // 16 * 16 * 1024 = 256KB
$command = new Command('/bin/cat');
$command->setStdIn($string);
$this->assertTrue($command->execute());
$this->assertTrue($command->getExecuted());
$this->assertEquals(strlen($string), strlen($command->getOutput()));
}
}

0 comments on commit d66f8a1

Please sign in to comment.