diff --git a/src/Symfony/Component/Process/Process.php b/src/Symfony/Component/Process/Process.php index f8a3293b12..56ff6d1ecc 100644 --- a/src/Symfony/Component/Process/Process.php +++ b/src/Symfony/Component/Process/Process.php @@ -34,10 +34,14 @@ class Process const STDOUT = 1; const STDERR = 2; + // Timeout Precision in seconds. + CONST TIMEOUT_PRECISION = 0.2; + private $commandline; private $cwd; private $env; private $stdin; + private $starttime; private $timeout; private $options; private $exitcode; @@ -211,6 +215,7 @@ class Process throw new \RuntimeException('Process is already running'); } + $this->starttime = microtime(true); $this->stdout = ''; $this->stderr = ''; $callback = $this->buildCallback($callback); @@ -285,7 +290,7 @@ class Process $w = $writePipes; $e = null; - $n = @stream_select($r, $w, $e, $this->timeout); + $n = @stream_select($r, $w, $e, 0, ceil(static::TIMEOUT_PRECISION * 1E6)); if (false === $n) { break; @@ -318,6 +323,8 @@ class Process unset($this->pipes[$type]); } } + + $this->checkTimeout(); } $this->updateStatus(); @@ -345,13 +352,15 @@ class Process if (defined('PHP_WINDOWS_VERSION_BUILD') && $this->fileHandles) { $this->processFileHandles($callback, !$this->pipes); } + $this->checkTimeout(); if ($this->pipes) { $r = $this->pipes; $w = null; $e = null; - if (false === $n = @stream_select($r, $w, $e, $this->timeout)) { + // let's have a look if something changed in streams + if (false === $n = @stream_select($r, $w, $e, 0, ceil(static::TIMEOUT_PRECISION * 1E6))) { $lastError = error_get_last(); // stream_select returns false when the `select` system call is interrupted by an incoming signal @@ -361,10 +370,11 @@ class Process continue; } - if (0 === $n) { - proc_terminate($this->process); - throw new \RuntimeException('The process timed out.'); + + // nothing has changed + if (0 === $n) { + continue; } foreach ($r as $pipe) { @@ -675,7 +685,7 @@ class Process * * To disable the timeout, set this value to null. * - * @param integer|null $timeout The timeout in seconds + * @param float|null $timeout The timeout in seconds * * @throws \InvalidArgumentException if the timeout is negative */ @@ -687,10 +697,10 @@ class Process return; } - $timeout = (integer) $timeout; + $timeout = (float) $timeout; if ($timeout < 0) { - throw new \InvalidArgumentException('The timeout value must be a valid positive integer.'); + throw new \InvalidArgumentException('The timeout value must be a valid positive integer or float number.'); } $this->timeout = $timeout; @@ -829,6 +839,23 @@ class Process $this->enhanceSigchildCompatibility = (Boolean) $enhance; } + /** + * Performs a check between the timeout definition and the time the process + * started + * + * In case you run a background process (with the start method), you should + * trigger this method regularly to ensure the process timeout + * + * @throws RuntimeException In case the timeout was reached + */ + public function checkTimeout() + { + if (0 < $this->timeout && $this->timeout < (microtime(true) - $this->starttime)) { + $this->stop(0); + throw new RuntimeException('Process timed-out.'); + } + } + /** * Builds up the callback used by wait(). * diff --git a/src/Symfony/Component/Process/ProcessBuilder.php b/src/Symfony/Component/Process/ProcessBuilder.php index 2ffb3af5ff..3d8b687fec 100644 --- a/src/Symfony/Component/Process/ProcessBuilder.php +++ b/src/Symfony/Component/Process/ProcessBuilder.php @@ -86,7 +86,7 @@ class ProcessBuilder * * To disable the timeout, set this value to null. * - * @param integer|null + * @param float|null */ public function setTimeout($timeout) { @@ -96,10 +96,10 @@ class ProcessBuilder return $this; } - $timeout = (integer) $timeout; + $timeout = (float) $timeout; if ($timeout < 0) { - throw new \InvalidArgumentException('The timeout value must be a valid positive integer.'); + throw new \InvalidArgumentException('The timeout value must be a valid positive integer or float number.'); } $this->timeout = $timeout; diff --git a/src/Symfony/Component/Process/Tests/AbstractProcessTest.php b/src/Symfony/Component/Process/Tests/AbstractProcessTest.php index 51d51f40b8..10dea51098 100644 --- a/src/Symfony/Component/Process/Tests/AbstractProcessTest.php +++ b/src/Symfony/Component/Process/Tests/AbstractProcessTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Process\Tests; use Symfony\Component\Process\Process; +use Symfony\Component\Process\Exception\RuntimeException; /** * @author Robert Schönthal @@ -255,6 +256,47 @@ abstract class AbstractProcessTest extends \PHPUnit_Framework_TestCase // PHP will deadlock when it tries to cleanup $process } + public function testRunProcessWithTimeout() + { + $timeout = 0.5; + $process = $this->getProcess('sleep 3'); + $process->setTimeout($timeout); + $start = microtime(true); + try { + $process->run(); + $this->fail('A RuntimeException should have been raised'); + } catch (RuntimeException $e) { + + } + $duration = microtime(true) - $start; + + $this->assertLessThan($timeout + Process::TIMEOUT_PRECISION, $duration); + } + + public function testCheckTimeoutOnStartedProcess() + { + $timeout = 0.5; + $precision = 100000; + $process = $this->getProcess('sleep 3'); + $process->setTimeout($timeout); + $start = microtime(true); + + $process->start(); + + try { + while ($process->isRunning()) { + $process->checkTimeout(); + usleep($precision); + } + $this->fail('A RuntimeException should have been raised'); + } catch (RuntimeException $e) { + + } + $duration = microtime(true) - $start; + + $this->assertLessThan($timeout + $precision, $duration); + } + public function responsesCodeProvider() { return array(