From 7098c9ebc86522921e7479965c5103c4efd068f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20H=C3=A4rtl?= Date: Sat, 17 Aug 2019 09:27:36 +0200 Subject: [PATCH] Issue #24 Implement timeout feature --- README.md | 3 +++ src/Command.php | 27 +++++++++++++++++++++++++++ tests/CommandTest.php | 15 +++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/README.md b/README.md index 6700da0..a12b703 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ $command->setStdIn('string'); without making the process hang. The default is `null` which will enable the feature on Non-Windows systems. Set it to `true` or `false` to manually enable/disable it. Note that it doesn't work on Windows. + * `$timeout`: The time in seconds after which the command should be + terminated. This only works in non-blocking mode. Default is `null` which + means the process is never terminated. * `$locale`: The locale to (temporarily) set with `setlocale()` before running the command. This can be set to e.g. `en_US.UTF-8` if you have issues with UTF-8 encoded arguments. diff --git a/src/Command.php b/src/Command.php index 81ef30b..7b78fbb 100644 --- a/src/Command.php +++ b/src/Command.php @@ -69,6 +69,13 @@ class Command */ public $nonBlockingMode; + /** + * @var int the time in seconds after which a command should be terminated. + * This only works in non-blocking mode. Default is `null` which means the + * process is never terminated. + */ + public $timeout; + /** * @var null|string the locale to temporarily set before calling * `escapeshellargs()`. Default is `null` for none. @@ -376,6 +383,7 @@ public function execute() in_array(get_resource_type($this->_stdIn), array('file', 'stream')); $isInputString = is_string($this->_stdIn); $hasInput = $isInputStream || $isInputString; + $hasTimeout = $this->timeout !== null && $this->timeout > 0; $descriptors = array( 1 => array('pipe','w'), @@ -385,10 +393,12 @@ public function execute() $descriptors[0] = array('pipe', 'r'); } + // Issue #20 Set non-blocking mode to fix hanging processes $nonBlocking = $this->nonBlockingMode === null ? !$this->getIsWindows() : $this->nonBlockingMode; + $startTime = $hasTimeout ? time() : 0; $process = proc_open($command, $descriptors, $pipes, $this->procCwd, $this->procEnv, $this->procOptions); if (is_resource($process)) { @@ -457,8 +467,25 @@ public function execute() $this->_stdErr .= $err; } + $runTime = $hasTimeout ? time() - $startTime : 0; + if ($isRunning && $hasTimeout && $runTime >= $this->timeout) { + // Only send a SIGTERM and handle status in the next cycle + proc_terminate($process); + } + if (!$isRunning) { $this->_exitCode = $status['exitcode']; + if ($this->_exitCode !== 0 && empty($this->_stdErr)) { + if ($status['stopped']) { + $signal = $status['stopsig']; + $this->_stdErr = "Command stopped by signal $signal"; + } elseif ($status['signaled']) { + $signal = $status['termsig']; + $this->_stdErr = "Command terminated by signal $signal"; + } else { + $this->_stdErr = 'Command unexpectedly terminated without error message'; + } + } fclose($pipes[1]); fclose($pipes[2]); proc_close($process); diff --git a/tests/CommandTest.php b/tests/CommandTest.php index 983f06c..2797fcb 100644 --- a/tests/CommandTest.php +++ b/tests/CommandTest.php @@ -280,4 +280,19 @@ public function testCanRunLongRunningCommandWithStandardInputStream() $this->assertEquals(strlen($expected), strlen($command->getOutput())); fclose($tmpfile); } + + public function testCanTerminateLongRunningCommandWithTimeout() + { + $command = new Command('sleep 5'); + $command->timeout = 2; + $startTime = time(); + $this->assertFalse($command->execute()); + $stopTime = time(); + $this->assertFalse($command->getExecuted()); + $this->assertNotEquals(0, $command->getExitCode()); + $this->assertStringStartsWith('Command terminated by signal', $command->getError()); + $this->assertStringStartsWith('Command terminated by signal', $command->getStdErr()); + $this->assertEmpty($command->getOutput()); + $this->assertEquals(2, $stopTime - $startTime); + } }