From d66f8a1465d41c8aeccf28b67d7381d03a163a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20H=C3=A4rtl?= Date: Thu, 15 Aug 2019 23:08:37 +0200 Subject: [PATCH] Issue #20 Implement read/write loop in non-blocking mode --- src/Command.php | 90 +++++++++++++++----- tests/BlockingCommandTest.php | 150 ++++++++++++++++++++++++++++++++++ tests/CommandTest.php | 15 +++- 3 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 tests/BlockingCommandTest.php diff --git a/src/Command.php b/src/Command.php index 49f8ea0..f0a187f 100644 --- a/src/Command.php +++ b/src/Command.php @@ -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 ? diff --git a/tests/BlockingCommandTest.php b/tests/BlockingCommandTest.php new file mode 100644 index 0000000..25ac7a2 --- /dev/null +++ b/tests/BlockingCommandTest.php @@ -0,0 +1,150 @@ +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()); + } +} diff --git a/tests/CommandTest.php b/tests/CommandTest.php index ff2cf70..3356d33 100644 --- a/tests/CommandTest.php +++ b/tests/CommandTest.php @@ -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())); + } }