diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b9ee6a..3e33cd0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ CHANGELOG ========= +7.1 +--- + + * Add `Process::setIgnoredSignals()` to disable signal propagation to the child process + +6.4 +--- + + * Add `PhpSubprocess` to handle PHP subprocesses that take over the + configuration from their parent + * Add `RunProcessMessage` and `RunProcessMessageHandler` + 5.2.0 ----- diff --git a/Exception/ProcessFailedException.php b/Exception/ProcessFailedException.php index 328acfde..de8a9e98 100644 --- a/Exception/ProcessFailedException.php +++ b/Exception/ProcessFailedException.php @@ -20,15 +20,14 @@ */ class ProcessFailedException extends RuntimeException { - private $process; - - public function __construct(Process $process) - { + public function __construct( + private Process $process, + ) { if ($process->isSuccessful()) { throw new InvalidArgumentException('Expected a failed process, but the given process was successful.'); } - $error = sprintf('The command "%s" failed.'."\n\nExit Code: %s(%s)\n\nWorking directory: %s", + $error = \sprintf('The command "%s" failed.'."\n\nExit Code: %s(%s)\n\nWorking directory: %s", $process->getCommandLine(), $process->getExitCode(), $process->getExitCodeText(), @@ -36,7 +35,7 @@ public function __construct(Process $process) ); if (!$process->isOutputDisabled()) { - $error .= sprintf("\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s", + $error .= \sprintf("\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s", $process->getOutput(), $process->getErrorOutput() ); @@ -47,7 +46,7 @@ public function __construct(Process $process) $this->process = $process; } - public function getProcess() + public function getProcess(): Process { return $this->process; } diff --git a/Exception/ProcessSignaledException.php b/Exception/ProcessSignaledException.php index d4d32275..3fd13e5d 100644 --- a/Exception/ProcessSignaledException.php +++ b/Exception/ProcessSignaledException.php @@ -20,13 +20,10 @@ */ final class ProcessSignaledException extends RuntimeException { - private $process; - - public function __construct(Process $process) - { - $this->process = $process; - - parent::__construct(sprintf('The process has been signaled with signal "%s".', $process->getTermSignal())); + public function __construct( + private Process $process, + ) { + parent::__construct(\sprintf('The process has been signaled with signal "%s".', $process->getTermSignal())); } public function getProcess(): Process diff --git a/Exception/ProcessStartFailedException.php b/Exception/ProcessStartFailedException.php new file mode 100644 index 00000000..37254725 --- /dev/null +++ b/Exception/ProcessStartFailedException.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Process; + +/** + * Exception for processes failed during startup. + */ +class ProcessStartFailedException extends ProcessFailedException +{ + public function __construct( + private Process $process, + ?string $message, + ) { + if ($process->isStarted()) { + throw new InvalidArgumentException('Expected a process that failed during startup, but the given process was started successfully.'); + } + + $error = \sprintf('The command "%s" failed.'."\n\nWorking directory: %s\n\nError: %s", + $process->getCommandLine(), + $process->getWorkingDirectory(), + $message ?? 'unknown' + ); + + // Skip parent constructor + RuntimeException::__construct($error); + } + + public function getProcess(): Process + { + return $this->process; + } +} diff --git a/Exception/ProcessTimedOutException.php b/Exception/ProcessTimedOutException.php index 94391a45..d3fe4934 100644 --- a/Exception/ProcessTimedOutException.php +++ b/Exception/ProcessTimedOutException.php @@ -23,47 +23,38 @@ class ProcessTimedOutException extends RuntimeException public const TYPE_GENERAL = 1; public const TYPE_IDLE = 2; - private $process; - private $timeoutType; - - public function __construct(Process $process, int $timeoutType) - { - $this->process = $process; - $this->timeoutType = $timeoutType; - - parent::__construct(sprintf( + public function __construct( + private Process $process, + private int $timeoutType, + ) { + parent::__construct(\sprintf( 'The process "%s" exceeded the timeout of %s seconds.', $process->getCommandLine(), $this->getExceededTimeout() )); } - public function getProcess() + public function getProcess(): Process { return $this->process; } - public function isGeneralTimeout() + public function isGeneralTimeout(): bool { return self::TYPE_GENERAL === $this->timeoutType; } - public function isIdleTimeout() + public function isIdleTimeout(): bool { return self::TYPE_IDLE === $this->timeoutType; } - public function getExceededTimeout() + public function getExceededTimeout(): ?float { - switch ($this->timeoutType) { - case self::TYPE_GENERAL: - return $this->process->getTimeout(); - - case self::TYPE_IDLE: - return $this->process->getIdleTimeout(); - - default: - throw new \LogicException(sprintf('Unknown timeout type "%d".', $this->timeoutType)); - } + return match ($this->timeoutType) { + self::TYPE_GENERAL => $this->process->getTimeout(), + self::TYPE_IDLE => $this->process->getIdleTimeout(), + default => throw new \LogicException(\sprintf('Unknown timeout type "%d".', $this->timeoutType)), + }; } } diff --git a/Exception/RunProcessFailedException.php b/Exception/RunProcessFailedException.php new file mode 100644 index 00000000..e7219d35 --- /dev/null +++ b/Exception/RunProcessFailedException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Messenger\RunProcessContext; + +/** + * @author Kevin Bond + */ +final class RunProcessFailedException extends RuntimeException +{ + public function __construct(ProcessFailedException $exception, public readonly RunProcessContext $context) + { + parent::__construct($exception->getMessage(), $exception->getCode()); + } +} diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 89edd22f..5cc65251 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -27,20 +27,23 @@ class ExecutableFinder 'setlocal', 'shift', 'start', 'time', 'title', 'type', 'ver', 'vol', ]; - private $suffixes = []; + private array $suffixes = []; /** * Replaces default suffixes of executable. */ - public function setSuffixes(array $suffixes) + public function setSuffixes(array $suffixes): void { $this->suffixes = $suffixes; } /** - * Adds new possible suffix to check for executable. + * Adds new possible suffix to check for executable, including the dot (.). + * + * $finder = new ExecutableFinder(); + * $finder->addSuffix('.foo'); */ - public function addSuffix(string $suffix) + public function addSuffix(string $suffix): void { $this->suffixes[] = $suffix; } @@ -51,10 +54,8 @@ public function addSuffix(string $suffix) * @param string $name The executable name (without the extension) * @param string|null $default The default to return if no executable is found * @param array $extraDirs Additional dirs to check into - * - * @return string|null */ - public function find(string $name, ?string $default = null, array $extraDirs = []) + public function find(string $name, ?string $default = null, array $extraDirs = []): ?string { // windows built-in commands that are present in cmd.exe should not be resolved using PATH as they do not exist as exes if ('\\' === \DIRECTORY_SEPARATOR && \in_array(strtolower($name), self::CMD_BUILTINS, true)) { @@ -66,10 +67,9 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ $extraDirs ); - $suffixes = []; + $suffixes = $this->suffixes; if ('\\' === \DIRECTORY_SEPARATOR) { $pathExt = getenv('PATHEXT'); - $suffixes = $this->suffixes; $suffixes = array_merge($suffixes, $pathExt ? explode(\PATH_SEPARATOR, $pathExt) : ['.exe', '.bat', '.cmd', '.com']); } $suffixes = '' !== pathinfo($name, PATHINFO_EXTENSION) ? array_merge([''], $suffixes) : array_merge($suffixes, ['']); diff --git a/InputStream.php b/InputStream.php index 0c45b524..586e7429 100644 --- a/InputStream.php +++ b/InputStream.php @@ -22,17 +22,16 @@ */ class InputStream implements \IteratorAggregate { - /** @var callable|null */ - private $onEmpty = null; - private $input = []; - private $open = true; + private ?\Closure $onEmpty = null; + private array $input = []; + private bool $open = true; /** * Sets a callback that is called when the write buffer becomes empty. */ - public function onEmpty(?callable $onEmpty = null) + public function onEmpty(?callable $onEmpty = null): void { - $this->onEmpty = $onEmpty; + $this->onEmpty = null !== $onEmpty ? $onEmpty(...) : null; } /** @@ -41,13 +40,13 @@ public function onEmpty(?callable $onEmpty = null) * @param resource|string|int|float|bool|\Traversable|null $input The input to append as scalar, * stream resource or \Traversable */ - public function write($input) + public function write(mixed $input): void { if (null === $input) { return; } if ($this->isClosed()) { - throw new RuntimeException(sprintf('"%s" is closed.', static::class)); + throw new RuntimeException(\sprintf('"%s" is closed.', static::class)); } $this->input[] = ProcessUtils::validateInput(__METHOD__, $input); } @@ -55,7 +54,7 @@ public function write($input) /** * Closes the write buffer. */ - public function close() + public function close(): void { $this->open = false; } @@ -63,16 +62,12 @@ public function close() /** * Tells whether the write buffer is closed or not. */ - public function isClosed() + public function isClosed(): bool { return !$this->open; } - /** - * @return \Traversable - */ - #[\ReturnTypeWillChange] - public function getIterator() + public function getIterator(): \Traversable { $this->open = true; diff --git a/Messenger/RunProcessContext.php b/Messenger/RunProcessContext.php new file mode 100644 index 00000000..5e223040 --- /dev/null +++ b/Messenger/RunProcessContext.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Messenger; + +use Symfony\Component\Process\Process; + +/** + * @author Kevin Bond + */ +final class RunProcessContext +{ + public readonly ?int $exitCode; + public readonly ?string $output; + public readonly ?string $errorOutput; + + public function __construct( + public readonly RunProcessMessage $message, + Process $process, + ) { + $this->exitCode = $process->getExitCode(); + $this->output = !$process->isStarted() || $process->isOutputDisabled() ? null : $process->getOutput(); + $this->errorOutput = !$process->isStarted() || $process->isOutputDisabled() ? null : $process->getErrorOutput(); + } +} diff --git a/Messenger/RunProcessMessage.php b/Messenger/RunProcessMessage.php new file mode 100644 index 00000000..b2c33fe3 --- /dev/null +++ b/Messenger/RunProcessMessage.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Messenger; + +/** + * @author Kevin Bond + */ +class RunProcessMessage implements \Stringable +{ + public function __construct( + public readonly array $command, + public readonly ?string $cwd = null, + public readonly ?array $env = null, + public readonly mixed $input = null, + public readonly ?float $timeout = 60.0, + ) { + } + + public function __toString(): string + { + return implode(' ', $this->command); + } +} diff --git a/Messenger/RunProcessMessageHandler.php b/Messenger/RunProcessMessageHandler.php new file mode 100644 index 00000000..41c1934c --- /dev/null +++ b/Messenger/RunProcessMessageHandler.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Messenger; + +use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Exception\RunProcessFailedException; +use Symfony\Component\Process\Process; + +/** + * @author Kevin Bond + */ +final class RunProcessMessageHandler +{ + public function __invoke(RunProcessMessage $message): RunProcessContext + { + $process = new Process($message->command, $message->cwd, $message->env, $message->input, $message->timeout); + + try { + return new RunProcessContext($message, $process->mustRun()); + } catch (ProcessFailedException $e) { + throw new RunProcessFailedException($e, new RunProcessContext($message, $e->getProcess())); + } + } +} diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index c3a9680d..9f9218f9 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -19,7 +19,7 @@ */ class PhpExecutableFinder { - private $executableFinder; + private ExecutableFinder $executableFinder; public function __construct() { @@ -28,10 +28,8 @@ public function __construct() /** * Finds The PHP executable. - * - * @return string|false */ - public function find(bool $includeArgs = true) + public function find(bool $includeArgs = true): string|false { if ($php = getenv('PHP_BINARY')) { if (!is_executable($php) && !$php = $this->executableFinder->find($php)) { @@ -76,15 +74,17 @@ public function find(bool $includeArgs = true) $dirs[] = 'C:\xampp\php\\'; } + if ($herdPath = getenv('HERD_HOME')) { + $dirs[] = $herdPath.\DIRECTORY_SEPARATOR.'bin'; + } + return $this->executableFinder->find('php', false, $dirs); } /** * Finds the PHP executable arguments. - * - * @return array */ - public function findArguments() + public function findArguments(): array { $arguments = []; if ('phpdbg' === \PHP_SAPI) { diff --git a/PhpProcess.php b/PhpProcess.php index 3a1d147c..0e7ff846 100644 --- a/PhpProcess.php +++ b/PhpProcess.php @@ -50,18 +50,12 @@ public function __construct(string $script, ?string $cwd = null, ?array $env = n parent::__construct($php, $cwd, $env, $script, $timeout); } - /** - * {@inheritdoc} - */ - public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60) + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static { - throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); + throw new LogicException(\sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); } - /** - * {@inheritdoc} - */ - public function start(?callable $callback = null, array $env = []) + public function start(?callable $callback = null, array $env = []): void { if (null === $this->getCommandLine()) { throw new RuntimeException('Unable to find the PHP executable.'); diff --git a/PhpSubprocess.php b/PhpSubprocess.php new file mode 100644 index 00000000..bdd4173c --- /dev/null +++ b/PhpSubprocess.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process; + +use Symfony\Component\Process\Exception\LogicException; +use Symfony\Component\Process\Exception\RuntimeException; + +/** + * PhpSubprocess runs a PHP command as a subprocess while keeping the original php.ini settings. + * + * For this, it generates a temporary php.ini file taking over all the current settings and disables + * loading additional .ini files. Basically, your command gets prefixed using "php -n -c /tmp/temp.ini". + * + * Given your php.ini contains "memory_limit=-1" and you have a "MemoryTest.php" with the following content: + * + * run(); + * print $p->getOutput()."\n"; + * + * This will output "string(2) "-1", because the process is started with the default php.ini settings. + * + * $p = new PhpSubprocess(['MemoryTest.php'], null, null, 60, ['php', '-d', 'memory_limit=256M']); + * $p->run(); + * print $p->getOutput()."\n"; + * + * This will output "string(4) "256M"", because the process is started with the temporarily created php.ini settings. + * + * @author Yanick Witschi + * @author Partially copied and heavily inspired from composer/xdebug-handler by John Stevenson + */ +class PhpSubprocess extends Process +{ + /** + * @param array $command The command to run and its arguments listed as separate entries. They will automatically + * get prefixed with the PHP binary + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param int $timeout The timeout in seconds + * @param array|null $php Path to the PHP binary to use with any additional arguments + */ + public function __construct(array $command, ?string $cwd = null, ?array $env = null, int $timeout = 60, ?array $php = null) + { + if (null === $php) { + $executableFinder = new PhpExecutableFinder(); + $php = $executableFinder->find(false); + $php = false === $php ? null : array_merge([$php], $executableFinder->findArguments()); + } + + if (null === $php) { + throw new RuntimeException('Unable to find PHP binary.'); + } + + $tmpIni = $this->writeTmpIni($this->getAllIniFiles(), sys_get_temp_dir()); + + $php = array_merge($php, ['-n', '-c', $tmpIni]); + register_shutdown_function('unlink', $tmpIni); + + $command = array_merge($php, $command); + + parent::__construct($command, $cwd, $env, null, $timeout); + } + + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static + { + throw new LogicException(\sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); + } + + public function start(?callable $callback = null, array $env = []): void + { + if (null === $this->getCommandLine()) { + throw new RuntimeException('Unable to find the PHP executable.'); + } + + parent::start($callback, $env); + } + + private function writeTmpIni(array $iniFiles, string $tmpDir): string + { + if (false === $tmpfile = @tempnam($tmpDir, '')) { + throw new RuntimeException('Unable to create temporary ini file.'); + } + + // $iniFiles has at least one item and it may be empty + if ('' === $iniFiles[0]) { + array_shift($iniFiles); + } + + $content = ''; + + foreach ($iniFiles as $file) { + // Check for inaccessible ini files + if (($data = @file_get_contents($file)) === false) { + throw new RuntimeException('Unable to read ini: '.$file); + } + // Check and remove directives after HOST and PATH sections + if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches, \PREG_OFFSET_CAPTURE)) { + $data = substr($data, 0, $matches[0][1]); + } + + $content .= $data."\n"; + } + + // Merge loaded settings into our ini content, if it is valid + $config = parse_ini_string($content); + $loaded = ini_get_all(null, false); + + if (false === $config || false === $loaded) { + throw new RuntimeException('Unable to parse ini data.'); + } + + $content .= $this->mergeLoadedConfig($loaded, $config); + + // Work-around for https://bugs.php.net/bug.php?id=75932 + $content .= "opcache.enable_cli=0\n"; + + if (false === @file_put_contents($tmpfile, $content)) { + throw new RuntimeException('Unable to write temporary ini file.'); + } + + return $tmpfile; + } + + private function mergeLoadedConfig(array $loadedConfig, array $iniConfig): string + { + $content = ''; + + foreach ($loadedConfig as $name => $value) { + if (!\is_string($value)) { + continue; + } + + if (!isset($iniConfig[$name]) || $iniConfig[$name] !== $value) { + // Double-quote escape each value + $content .= $name.'="'.addcslashes($value, '\\"')."\"\n"; + } + } + + return $content; + } + + private function getAllIniFiles(): array + { + $paths = [(string) php_ini_loaded_file()]; + + if (false !== $scanned = php_ini_scanned_files()) { + $paths = array_merge($paths, array_map('trim', explode(',', $scanned))); + } + + return $paths; + } +} diff --git a/Pipes/AbstractPipes.php b/Pipes/AbstractPipes.php index 656dc032..51a566f3 100644 --- a/Pipes/AbstractPipes.php +++ b/Pipes/AbstractPipes.php @@ -20,31 +20,27 @@ */ abstract class AbstractPipes implements PipesInterface { - public $pipes = []; + public array $pipes = []; - private $inputBuffer = ''; + private string $inputBuffer = ''; + /** @var resource|string|\Iterator */ private $input; - private $blocked = true; - private $lastError; + private bool $blocked = true; + private ?string $lastError = null; /** - * @param resource|string|int|float|bool|\Iterator|null $input + * @param resource|string|\Iterator $input */ public function __construct($input) { if (\is_resource($input) || $input instanceof \Iterator) { $this->input = $input; - } elseif (\is_string($input)) { - $this->inputBuffer = $input; } else { $this->inputBuffer = (string) $input; } } - /** - * {@inheritdoc} - */ - public function close() + public function close(): void { foreach ($this->pipes as $pipe) { if (\is_resource($pipe)) { @@ -69,7 +65,7 @@ protected function hasSystemCallBeenInterrupted(): bool /** * Unblocks streams. */ - protected function unblock() + protected function unblock(): void { if (!$this->blocked) { return; @@ -105,7 +101,7 @@ protected function write(): ?array } elseif (!isset($this->inputBuffer[0])) { if (!\is_string($input)) { if (!\is_scalar($input)) { - throw new InvalidArgumentException(sprintf('"%s" yielded a value of type "%s", but only scalars and stream resources are supported.', get_debug_type($this->input), get_debug_type($input))); + throw new InvalidArgumentException(\sprintf('"%s" yielded a value of type "%s", but only scalars and stream resources are supported.', get_debug_type($this->input), get_debug_type($input))); } $input = (string) $input; } @@ -173,7 +169,7 @@ protected function write(): ?array /** * @internal */ - public function handleError(int $type, string $msg) + public function handleError(int $type, string $msg): void { $this->lastError = $msg; } diff --git a/Pipes/PipesInterface.php b/Pipes/PipesInterface.php index 50eb5c47..967f8de7 100644 --- a/Pipes/PipesInterface.php +++ b/Pipes/PipesInterface.php @@ -57,5 +57,5 @@ public function haveReadSupport(): bool; /** * Closes file handles and pipes. */ - public function close(); + public function close(): void; } diff --git a/Pipes/UnixPipes.php b/Pipes/UnixPipes.php index 5a0e9d47..6f95a332 100644 --- a/Pipes/UnixPipes.php +++ b/Pipes/UnixPipes.php @@ -22,16 +22,12 @@ */ class UnixPipes extends AbstractPipes { - private $ttyMode; - private $ptyMode; - private $haveReadSupport; - - public function __construct(?bool $ttyMode, bool $ptyMode, $input, bool $haveReadSupport) - { - $this->ttyMode = $ttyMode; - $this->ptyMode = $ptyMode; - $this->haveReadSupport = $haveReadSupport; - + public function __construct( + private ?bool $ttyMode, + private bool $ptyMode, + mixed $input, + private bool $haveReadSupport, + ) { parent::__construct($input); } @@ -40,7 +36,7 @@ public function __sleep(): array throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } @@ -50,9 +46,6 @@ public function __destruct() $this->close(); } - /** - * {@inheritdoc} - */ public function getDescriptors(): array { if (!$this->haveReadSupport) { @@ -77,7 +70,7 @@ public function getDescriptors(): array return [ ['pty'], ['pty'], - ['pty'], + ['pipe', 'w'], // stderr needs to be in a pipe to correctly split error and output, since PHP will use the same stream for both ]; } @@ -88,17 +81,11 @@ public function getDescriptors(): array ]; } - /** - * {@inheritdoc} - */ public function getFiles(): array { return []; } - /** - * {@inheritdoc} - */ public function readAndWrite(bool $blocking, bool $close = false): array { $this->unblock(); @@ -109,7 +96,7 @@ public function readAndWrite(bool $blocking, bool $close = false): array unset($r[0]); // let's have a look if something changed in streams - set_error_handler([$this, 'handleError']); + set_error_handler($this->handleError(...)); if (($r || $w) && false === stream_select($r, $w, $e, 0, $blocking ? Process::TIMEOUT_PRECISION * 1E6 : 0)) { restore_error_handler(); // if a system call has been interrupted, forget about it, let's try again @@ -145,17 +132,11 @@ public function readAndWrite(bool $blocking, bool $close = false): array return $read; } - /** - * {@inheritdoc} - */ public function haveReadSupport(): bool { return $this->haveReadSupport; } - /** - * {@inheritdoc} - */ public function areOpen(): bool { return (bool) $this->pipes; diff --git a/Pipes/WindowsPipes.php b/Pipes/WindowsPipes.php index 968dd026..116b8e30 100644 --- a/Pipes/WindowsPipes.php +++ b/Pipes/WindowsPipes.php @@ -26,19 +26,18 @@ */ class WindowsPipes extends AbstractPipes { - private $files = []; - private $fileHandles = []; - private $lockHandles = []; - private $readBytes = [ + private array $files = []; + private array $fileHandles = []; + private array $lockHandles = []; + private array $readBytes = [ Process::STDOUT => 0, Process::STDERR => 0, ]; - private $haveReadSupport; - - public function __construct($input, bool $haveReadSupport) - { - $this->haveReadSupport = $haveReadSupport; + public function __construct( + mixed $input, + private bool $haveReadSupport, + ) { if ($this->haveReadSupport) { // Fix for PHP bug #51800: reading from STDOUT pipe hangs forever on Windows if the output is too big. // Workaround for this problem is to use temporary files instead of pipes on Windows platform. @@ -53,7 +52,7 @@ public function __construct($input, bool $haveReadSupport) set_error_handler(function ($type, $msg) use (&$lastError) { $lastError = $msg; }); for ($i = 0;; ++$i) { foreach ($pipes as $pipe => $name) { - $file = sprintf('%s\\sf_proc_%02X.%s', $tmpDir, $i, $name); + $file = \sprintf('%s\\sf_proc_%02X.%s', $tmpDir, $i, $name); if (!$h = fopen($file.'.lock', 'w')) { if (file_exists($file.'.lock')) { @@ -93,7 +92,7 @@ public function __sleep(): array throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } @@ -103,9 +102,6 @@ public function __destruct() $this->close(); } - /** - * {@inheritdoc} - */ public function getDescriptors(): array { if (!$this->haveReadSupport) { @@ -128,17 +124,11 @@ public function getDescriptors(): array ]; } - /** - * {@inheritdoc} - */ public function getFiles(): array { return $this->files; } - /** - * {@inheritdoc} - */ public function readAndWrite(bool $blocking, bool $close = false): array { $this->unblock(); @@ -171,26 +161,17 @@ public function readAndWrite(bool $blocking, bool $close = false): array return $read; } - /** - * {@inheritdoc} - */ public function haveReadSupport(): bool { return $this->haveReadSupport; } - /** - * {@inheritdoc} - */ public function areOpen(): bool { return $this->pipes && $this->fileHandles; } - /** - * {@inheritdoc} - */ - public function close() + public function close(): void { parent::close(); foreach ($this->fileHandles as $type => $handle) { diff --git a/Process.php b/Process.php index 91f9e8fe..6fe1086e 100644 --- a/Process.php +++ b/Process.php @@ -15,9 +15,9 @@ use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException; -use Symfony\Component\Process\Pipes\PipesInterface; use Symfony\Component\Process\Pipes\UnixPipes; use Symfony\Component\Process\Pipes\WindowsPipes; @@ -51,45 +51,47 @@ class Process implements \IteratorAggregate public const ITER_SKIP_OUT = 4; // Use this flag to skip STDOUT while iterating public const ITER_SKIP_ERR = 8; // Use this flag to skip STDERR while iterating - private $callback; - private $hasCallback = false; - private $commandline; - private $cwd; - private $env = []; + private ?\Closure $callback = null; + private array|string $commandline; + private ?string $cwd; + private array $env = []; + /** @var resource|string|\Iterator|null */ private $input; - private $starttime; - private $lastOutputTime; - private $timeout; - private $idleTimeout; - private $exitcode; - private $fallbackStatus = []; - private $processInformation; - private $outputDisabled = false; + private ?float $starttime = null; + private ?float $lastOutputTime = null; + private ?float $timeout = null; + private ?float $idleTimeout = null; + private ?int $exitcode = null; + private array $fallbackStatus = []; + private array $processInformation; + private bool $outputDisabled = false; + /** @var resource */ private $stdout; + /** @var resource */ private $stderr; + /** @var resource|null */ private $process; - private $status = self::STATUS_READY; - private $incrementalOutputOffset = 0; - private $incrementalErrorOutputOffset = 0; - private $tty = false; - private $pty; - private $options = ['suppress_errors' => true, 'bypass_shell' => true]; + private string $status = self::STATUS_READY; + private int $incrementalOutputOffset = 0; + private int $incrementalErrorOutputOffset = 0; + private bool $tty = false; + private bool $pty; + private array $options = ['suppress_errors' => true, 'bypass_shell' => true]; + private array $ignoredSignals = []; - private $useFileHandles = false; - /** @var PipesInterface */ - private $processPipes; + private WindowsPipes|UnixPipes $processPipes; - private $latestSignal; - private $cachedExitCode; + private ?int $latestSignal = null; - private static $sigchild; + private static ?bool $sigchild = null; + private static array $executables = []; /** * Exit codes translation table. * * User-defined errors must use exit codes in the 64-113 range. */ - public static $exitCodes = [ + public static array $exitCodes = [ 0 => 'OK', 1 => 'General error', 2 => 'Misuse of shell builtins', @@ -141,7 +143,7 @@ class Process implements \IteratorAggregate * * @throws LogicException When proc_open is not installed */ - public function __construct(array $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60) + public function __construct(array $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60) { if (!\function_exists('proc_open')) { throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.'); @@ -163,7 +165,6 @@ public function __construct(array $command, ?string $cwd = null, ?array $env = n $this->setInput($input); $this->setTimeout($timeout); - $this->useFileHandles = '\\' === \DIRECTORY_SEPARATOR; $this->pty = false; } @@ -186,11 +187,9 @@ public function __construct(array $command, ?string $cwd = null, ?array $env = n * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input * @param int|float|null $timeout The timeout in seconds or null to disable * - * @return static - * * @throws LogicException When proc_open is not installed */ - public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60) + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static { $process = new static([], $cwd, $env, $input, $timeout); $process->commandline = $command; @@ -198,15 +197,12 @@ public static function fromShellCommandline(string $command, ?string $cwd = null return $process; } - /** - * @return array - */ - public function __sleep() + public function __sleep(): array { throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } @@ -240,11 +236,11 @@ public function __clone() * * @return int The exit status code * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running - * @throws ProcessTimedOutException When process timed out - * @throws ProcessSignaledException When process stopped after receiving signal - * @throws LogicException In case a callback is provided and output has been disabled + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running + * @throws ProcessTimedOutException When process timed out + * @throws ProcessSignaledException When process stopped after receiving signal + * @throws LogicException In case a callback is provided and output has been disabled * * @final */ @@ -267,7 +263,7 @@ public function run(?callable $callback = null, array $env = []): int * * @final */ - public function mustRun(?callable $callback = null, array $env = []): self + public function mustRun(?callable $callback = null, array $env = []): static { if (0 !== $this->run($callback, $env)) { throw new ProcessFailedException($this); @@ -291,11 +287,11 @@ public function mustRun(?callable $callback = null, array $env = []): self * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running - * @throws LogicException In case a callback is provided and output has been disabled + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running + * @throws LogicException In case a callback is provided and output has been disabled */ - public function start(?callable $callback = null, array $env = []) + public function start(?callable $callback = null, array $env = []): void { if ($this->isRunning()) { throw new RuntimeException('Process is already running.'); @@ -304,8 +300,7 @@ public function start(?callable $callback = null, array $env = []) $this->resetProcessData(); $this->starttime = $this->lastOutputTime = microtime(true); $this->callback = $this->buildCallback($callback); - $this->hasCallback = null !== $callback; - $descriptors = $this->getDescriptors(); + $descriptors = $this->getDescriptors(null !== $callback); if ($this->env) { $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->env, $env, 'strcasecmp') : $this->env; @@ -314,29 +309,25 @@ public function start(?callable $callback = null, array $env = []) $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv(); if (\is_array($commandline = $this->commandline)) { - $commandline = implode(' ', array_map([$this, 'escapeArgument'], $commandline)); - - if ('\\' !== \DIRECTORY_SEPARATOR) { - // exec is mandatory to deal with sending a signal to the process - $commandline = 'exec '.$commandline; - } + $commandline = array_values(array_map(strval(...), $commandline)); } else { $commandline = $this->replacePlaceholders($commandline, $env); } if ('\\' === \DIRECTORY_SEPARATOR) { $commandline = $this->prepareWindowsCommandLine($commandline, $env); - } elseif (!$this->useFileHandles && $this->isSigchildEnabled()) { + } elseif ($this->isSigchildEnabled()) { // last exit code is output on the fourth pipe and caught to work around --enable-sigchild $descriptors[3] = ['pipe', 'w']; + if (\is_array($commandline)) { + // exec is mandatory to deal with sending a signal to the process + $commandline = 'exec '.$this->buildShellCommandline($commandline); + } + // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input $commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;'; $commandline .= 'pid=$!; echo $pid >&3; wait $pid 2>/dev/null; code=$?; echo $code >&3; exit $code'; - - // Workaround for the bug, when PTS functionality is enabled. - // @see : https://bugs.php.net/69442 - $ptsWorkaround = fopen(__FILE__, 'r'); } $envPairs = []; @@ -347,14 +338,44 @@ public function start(?callable $callback = null, array $env = []) } if (!is_dir($this->cwd)) { - throw new RuntimeException(sprintf('The provided cwd "%s" does not exist.', $this->cwd)); + throw new RuntimeException(\sprintf('The provided cwd "%s" does not exist.', $this->cwd)); } - $this->process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + $lastError = null; + set_error_handler(function ($type, $msg) use (&$lastError) { + $lastError = $msg; + + return true; + }); - if (!$this->process) { - throw new RuntimeException('Unable to launch a new process.'); + $oldMask = []; + + if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { + // we block signals we want to ignore, as proc_open will use fork / posix_spawn which will copy the signal mask this allow to block + // signals in the child process + pcntl_sigprocmask(\SIG_BLOCK, $this->ignoredSignals, $oldMask); } + + try { + $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + + // Ensure array vs string commands behave the same + if (!$process && \is_array($commandline)) { + $process = @proc_open('exec '.$this->buildShellCommandline($commandline), $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + } + } finally { + if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { + // we restore the signal mask here to avoid any side effects + pcntl_sigprocmask(\SIG_SETMASK, $oldMask); + } + + restore_error_handler(); + } + + if (!$process) { + throw new ProcessStartFailedException($this, $lastError); + } + $this->process = $process; $this->status = self::STATUS_STARTED; if (isset($descriptors[3])) { @@ -377,16 +398,14 @@ public function start(?callable $callback = null, array $env = []) * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @return static - * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running * * @see start() * * @final */ - public function restart(?callable $callback = null, array $env = []): self + public function restart(?callable $callback = null, array $env = []): static { if ($this->isRunning()) { throw new RuntimeException('Process is already running.'); @@ -413,7 +432,7 @@ public function restart(?callable $callback = null, array $env = []): self * @throws ProcessSignaledException When process stopped after receiving signal * @throws LogicException When process is not yet started */ - public function wait(?callable $callback = null) + public function wait(?callable $callback = null): int { $this->requireProcessIsStarted(__FUNCTION__); @@ -496,7 +515,7 @@ public function waitUntil(callable $callback): bool * * @return int|null The process id if running, null otherwise */ - public function getPid() + public function getPid(): ?int { return $this->isRunning() ? $this->processInformation['pid'] : null; } @@ -512,7 +531,7 @@ public function getPid() * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed * @throws RuntimeException In case of failure */ - public function signal(int $signal) + public function signal(int $signal): static { $this->doSignal($signal, true); @@ -527,7 +546,7 @@ public function signal(int $signal) * @throws RuntimeException In case the process is already running * @throws LogicException if an idle timeout is set */ - public function disableOutput() + public function disableOutput(): static { if ($this->isRunning()) { throw new RuntimeException('Disabling output while the process is running is not possible.'); @@ -548,7 +567,7 @@ public function disableOutput() * * @throws RuntimeException In case the process is already running */ - public function enableOutput() + public function enableOutput(): static { if ($this->isRunning()) { throw new RuntimeException('Enabling output while the process is running is not possible.'); @@ -561,10 +580,8 @@ public function enableOutput() /** * Returns true in case the output is disabled, false otherwise. - * - * @return bool */ - public function isOutputDisabled() + public function isOutputDisabled(): bool { return $this->outputDisabled; } @@ -572,12 +589,10 @@ public function isOutputDisabled() /** * Returns the current output of the process (STDOUT). * - * @return string - * * @throws LogicException in case the output has been disabled * @throws LogicException In case the process is not started */ - public function getOutput() + public function getOutput(): string { $this->readPipesForOutput(__FUNCTION__); @@ -594,12 +609,10 @@ public function getOutput() * In comparison with the getOutput method which always return the whole * output, this one returns the new output since the last call. * - * @return string - * * @throws LogicException in case the output has been disabled * @throws LogicException In case the process is not started */ - public function getIncrementalOutput() + public function getIncrementalOutput(): string { $this->readPipesForOutput(__FUNCTION__); @@ -623,8 +636,7 @@ public function getIncrementalOutput() * @throws LogicException in case the output has been disabled * @throws LogicException In case the process is not started */ - #[\ReturnTypeWillChange] - public function getIterator(int $flags = 0) + public function getIterator(int $flags = 0): \Generator { $this->readPipesForOutput(__FUNCTION__, false); @@ -676,7 +688,7 @@ public function getIterator(int $flags = 0) * * @return $this */ - public function clearOutput() + public function clearOutput(): static { ftruncate($this->stdout, 0); fseek($this->stdout, 0); @@ -688,12 +700,10 @@ public function clearOutput() /** * Returns the current error output of the process (STDERR). * - * @return string - * * @throws LogicException in case the output has been disabled * @throws LogicException In case the process is not started */ - public function getErrorOutput() + public function getErrorOutput(): string { $this->readPipesForOutput(__FUNCTION__); @@ -711,12 +721,10 @@ public function getErrorOutput() * whole error output, this one returns the new error output since the last * call. * - * @return string - * * @throws LogicException in case the output has been disabled * @throws LogicException In case the process is not started */ - public function getIncrementalErrorOutput() + public function getIncrementalErrorOutput(): string { $this->readPipesForOutput(__FUNCTION__); @@ -735,7 +743,7 @@ public function getIncrementalErrorOutput() * * @return $this */ - public function clearErrorOutput() + public function clearErrorOutput(): static { ftruncate($this->stderr, 0); fseek($this->stderr, 0); @@ -749,7 +757,7 @@ public function clearErrorOutput() * * @return int|null The exit status code, null if the Process is not terminated */ - public function getExitCode() + public function getExitCode(): ?int { $this->updateStatus(false); @@ -767,7 +775,7 @@ public function getExitCode() * @see http://tldp.org/LDP/abs/html/exitcodes.html * @see http://en.wikipedia.org/wiki/Unix_signal */ - public function getExitCodeText() + public function getExitCodeText(): ?string { if (null === $exitcode = $this->getExitCode()) { return null; @@ -778,10 +786,8 @@ public function getExitCodeText() /** * Checks if the process ended successfully. - * - * @return bool */ - public function isSuccessful() + public function isSuccessful(): bool { return 0 === $this->getExitCode(); } @@ -791,11 +797,9 @@ public function isSuccessful() * * It always returns false on Windows. * - * @return bool - * * @throws LogicException In case the process is not terminated */ - public function hasBeenSignaled() + public function hasBeenSignaled(): bool { $this->requireProcessIsTerminated(__FUNCTION__); @@ -807,12 +811,10 @@ public function hasBeenSignaled() * * It is only meaningful if hasBeenSignaled() returns true. * - * @return int - * * @throws RuntimeException In case --enable-sigchild is activated * @throws LogicException In case the process is not terminated */ - public function getTermSignal() + public function getTermSignal(): int { $this->requireProcessIsTerminated(__FUNCTION__); @@ -828,11 +830,9 @@ public function getTermSignal() * * It always returns false on Windows. * - * @return bool - * * @throws LogicException In case the process is not terminated */ - public function hasBeenStopped() + public function hasBeenStopped(): bool { $this->requireProcessIsTerminated(__FUNCTION__); @@ -844,11 +844,9 @@ public function hasBeenStopped() * * It is only meaningful if hasBeenStopped() returns true. * - * @return int - * * @throws LogicException In case the process is not terminated */ - public function getStopSignal() + public function getStopSignal(): int { $this->requireProcessIsTerminated(__FUNCTION__); @@ -857,10 +855,8 @@ public function getStopSignal() /** * Checks if the process is currently running. - * - * @return bool */ - public function isRunning() + public function isRunning(): bool { if (self::STATUS_STARTED !== $this->status) { return false; @@ -873,20 +869,16 @@ public function isRunning() /** * Checks if the process has been started with no regard to the current state. - * - * @return bool */ - public function isStarted() + public function isStarted(): bool { return self::STATUS_READY != $this->status; } /** * Checks if the process is terminated. - * - * @return bool */ - public function isTerminated() + public function isTerminated(): bool { $this->updateStatus(false); @@ -897,10 +889,8 @@ public function isTerminated() * Gets the process status. * * The status is one of: ready, started, terminated. - * - * @return string */ - public function getStatus() + public function getStatus(): string { $this->updateStatus(false); @@ -915,7 +905,7 @@ public function getStatus() * * @return int|null The exit-code of the process or null if it's not running */ - public function stop(float $timeout = 10, ?int $signal = null) + public function stop(float $timeout = 10, ?int $signal = null): ?int { $timeoutMicro = microtime(true) + $timeout; if ($this->isRunning()) { @@ -949,7 +939,7 @@ public function stop(float $timeout = 10, ?int $signal = null) * * @internal */ - public function addOutput(string $line) + public function addOutput(string $line): void { $this->lastOutputTime = microtime(true); @@ -963,7 +953,7 @@ public function addOutput(string $line) * * @internal */ - public function addErrorOutput(string $line) + public function addErrorOutput(string $line): void { $this->lastOutputTime = microtime(true); @@ -982,30 +972,24 @@ public function getLastOutputTime(): ?float /** * Gets the command line to be executed. - * - * @return string */ - public function getCommandLine() + public function getCommandLine(): string { - return \is_array($this->commandline) ? implode(' ', array_map([$this, 'escapeArgument'], $this->commandline)) : $this->commandline; + return $this->buildShellCommandline($this->commandline); } /** * Gets the process timeout in seconds (max. runtime). - * - * @return float|null */ - public function getTimeout() + public function getTimeout(): ?float { return $this->timeout; } /** * Gets the process idle timeout in seconds (max. time since last output). - * - * @return float|null */ - public function getIdleTimeout() + public function getIdleTimeout(): ?float { return $this->idleTimeout; } @@ -1019,7 +1003,7 @@ public function getIdleTimeout() * * @throws InvalidArgumentException if the timeout is negative */ - public function setTimeout(?float $timeout) + public function setTimeout(?float $timeout): static { $this->timeout = $this->validateTimeout($timeout); @@ -1036,7 +1020,7 @@ public function setTimeout(?float $timeout) * @throws LogicException if the output is disabled * @throws InvalidArgumentException if the timeout is negative */ - public function setIdleTimeout(?float $timeout) + public function setIdleTimeout(?float $timeout): static { if (null !== $timeout && $this->outputDisabled) { throw new LogicException('Idle timeout cannot be set while the output is disabled.'); @@ -1054,7 +1038,7 @@ public function setIdleTimeout(?float $timeout) * * @throws RuntimeException In case the TTY mode is not supported */ - public function setTty(bool $tty) + public function setTty(bool $tty): static { if ('\\' === \DIRECTORY_SEPARATOR && $tty) { throw new RuntimeException('TTY mode is not supported on Windows platform.'); @@ -1071,10 +1055,8 @@ public function setTty(bool $tty) /** * Checks if the TTY mode is enabled. - * - * @return bool */ - public function isTty() + public function isTty(): bool { return $this->tty; } @@ -1084,7 +1066,7 @@ public function isTty() * * @return $this */ - public function setPty(bool $bool) + public function setPty(bool $bool): static { $this->pty = $bool; @@ -1093,20 +1075,16 @@ public function setPty(bool $bool) /** * Returns PTY state. - * - * @return bool */ - public function isPty() + public function isPty(): bool { return $this->pty; } /** * Gets the working directory. - * - * @return string|null */ - public function getWorkingDirectory() + public function getWorkingDirectory(): ?string { if (null === $this->cwd) { // getcwd() will return false if any one of the parent directories does not have @@ -1122,7 +1100,7 @@ public function getWorkingDirectory() * * @return $this */ - public function setWorkingDirectory(string $cwd) + public function setWorkingDirectory(string $cwd): static { $this->cwd = $cwd; @@ -1131,10 +1109,8 @@ public function setWorkingDirectory(string $cwd) /** * Gets the environment variables. - * - * @return array */ - public function getEnv() + public function getEnv(): array { return $this->env; } @@ -1146,7 +1122,7 @@ public function getEnv() * * @return $this */ - public function setEnv(array $env) + public function setEnv(array $env): static { $this->env = $env; @@ -1168,13 +1144,13 @@ public function getInput() * * This content will be passed to the underlying process standard input. * - * @param string|int|float|bool|resource|\Traversable|null $input The content + * @param string|resource|\Traversable|self|null $input The content * * @return $this * * @throws LogicException In case the process is running */ - public function setInput($input) + public function setInput(mixed $input): static { if ($this->isRunning()) { throw new LogicException('Input cannot be set while the process is running.'); @@ -1193,7 +1169,7 @@ public function setInput($input) * * @throws ProcessTimedOutException In case the timeout was reached */ - public function checkTimeout() + public function checkTimeout(): void { if (self::STATUS_STARTED !== $this->status) { return; @@ -1232,7 +1208,7 @@ public function getStartTime(): float * Enabling the "create_new_console" option allows a subprocess to continue * to run after the main process exited, on both Windows and *nix */ - public function setOptions(array $options) + public function setOptions(array $options): void { if ($this->isRunning()) { throw new RuntimeException('Setting options while the process is running is not possible.'); @@ -1244,12 +1220,26 @@ public function setOptions(array $options) foreach ($options as $key => $value) { if (!\in_array($key, $existingOptions)) { $this->options = $defaultOptions; - throw new LogicException(sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions))); + throw new LogicException(\sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions))); } $this->options[$key] = $value; } } + /** + * Defines a list of posix signals that will not be propagated to the process. + * + * @param list<\SIG*> $signals + */ + public function setIgnoredSignals(array $signals): void + { + if ($this->isRunning()) { + throw new RuntimeException('Setting ignored signals while the process is running is not possible.'); + } + + $this->ignoredSignals = $signals; + } + /** * Returns whether TTY is supported on the current operating system. */ @@ -1257,19 +1247,13 @@ public static function isTtySupported(): bool { static $isTtySupported; - if (null === $isTtySupported) { - $isTtySupported = (bool) @proc_open('echo 1 >/dev/null', [['file', '/dev/tty', 'r'], ['file', '/dev/tty', 'w'], ['file', '/dev/tty', 'w']], $pipes); - } - - return $isTtySupported; + return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT) && @is_writable('/dev/tty')); } /** * Returns whether PTY is supported on the current operating system. - * - * @return bool */ - public static function isPtySupported() + public static function isPtySupported(): bool { static $result; @@ -1287,15 +1271,15 @@ public static function isPtySupported() /** * Creates the descriptors needed by the proc_open. */ - private function getDescriptors(): array + private function getDescriptors(bool $hasCallback): array { if ($this->input instanceof \Iterator) { $this->input->rewind(); } if ('\\' === \DIRECTORY_SEPARATOR) { - $this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $this->hasCallback); + $this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $hasCallback); } else { - $this->processPipes = new UnixPipes($this->isTty(), $this->isPty(), $this->input, !$this->outputDisabled || $this->hasCallback); + $this->processPipes = new UnixPipes($this->isTty(), $this->isPty(), $this->input, !$this->outputDisabled || $hasCallback); } return $this->processPipes->getDescriptors(); @@ -1308,15 +1292,11 @@ private function getDescriptors(): array * the user callback (if present) with the received output. * * @param callable|null $callback The user defined PHP callback - * - * @return \Closure */ - protected function buildCallback(?callable $callback = null) + protected function buildCallback(?callable $callback = null): \Closure { if ($this->outputDisabled) { - return function ($type, $data) use ($callback): bool { - return null !== $callback && $callback($type, $data); - }; + return fn ($type, $data): bool => null !== $callback && $callback($type, $data); } $out = self::OUT; @@ -1337,27 +1317,16 @@ protected function buildCallback(?callable $callback = null) * * @param bool $blocking Whether to use a blocking read call */ - protected function updateStatus(bool $blocking) + protected function updateStatus(bool $blocking): void { if (self::STATUS_STARTED !== $this->status) { return; } - $this->processInformation = proc_get_status($this->process); - $running = $this->processInformation['running']; - - // In PHP < 8.3, "proc_get_status" only returns the correct exit status on the first call. - // Subsequent calls return -1 as the process is discarded. This workaround caches the first - // retrieved exit status for consistent results in later calls, mimicking PHP 8.3 behavior. - if (\PHP_VERSION_ID < 80300) { - if (!isset($this->cachedExitCode) && !$running && -1 !== $this->processInformation['exitcode']) { - $this->cachedExitCode = $this->processInformation['exitcode']; - } - - if (isset($this->cachedExitCode) && !$running && -1 === $this->processInformation['exitcode']) { - $this->processInformation['exitcode'] = $this->cachedExitCode; - } + if ($this->processInformation['running'] ?? true) { + $this->processInformation = proc_get_status($this->process); } + $running = $this->processInformation['running']; $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running); @@ -1372,10 +1341,8 @@ protected function updateStatus(bool $blocking) /** * Returns whether PHP has been compiled with the '--enable-sigchild' option or not. - * - * @return bool */ - protected function isSigchildEnabled() + protected function isSigchildEnabled(): bool { if (null !== self::$sigchild) { return self::$sigchild; @@ -1399,7 +1366,7 @@ protected function isSigchildEnabled() * * @throws LogicException in case output has been disabled or process is not started */ - private function readPipesForOutput(string $caller, bool $blocking = false) + private function readPipesForOutput(string $caller, bool $blocking = false): void { if ($this->outputDisabled) { throw new LogicException('Output has been disabled.'); @@ -1434,7 +1401,7 @@ private function validateTimeout(?float $timeout): ?float * @param bool $blocking Whether to use blocking calls or not * @param bool $close Whether to close file handles or not */ - private function readPipes(bool $blocking, bool $close) + private function readPipes(bool $blocking, bool $close): void { $result = $this->processPipes->readAndWrite($blocking, $close); @@ -1484,13 +1451,13 @@ private function close(): int /** * Resets data related to the latest run of the process. */ - private function resetProcessData() + private function resetProcessData(): void { $this->starttime = null; $this->callback = null; $this->exitcode = null; $this->fallbackStatus = []; - $this->processInformation = null; + $this->processInformation = []; $this->stdout = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); $this->stderr = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); $this->process = null; @@ -1512,6 +1479,11 @@ private function resetProcessData() */ private function doSignal(int $signal, bool $throwException): bool { + // Signal seems to be send when sigchild is enable, this allow blocking the signal correctly in this case + if ($this->isSigchildEnabled() && \in_array($signal, $this->ignoredSignals)) { + return false; + } + if (null === $pid = $this->getPid()) { if ($throwException) { throw new LogicException('Cannot send signal on a non running process.'); @@ -1521,10 +1493,10 @@ private function doSignal(int $signal, bool $throwException): bool } if ('\\' === \DIRECTORY_SEPARATOR) { - exec(sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); + exec(\sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); if ($exitCode && $this->isRunning()) { if ($throwException) { - throw new RuntimeException(sprintf('Unable to kill the process (%s).', implode(' ', $output))); + throw new RuntimeException(\sprintf('Unable to kill the process (%s).', implode(' ', $output))); } return false; @@ -1534,12 +1506,12 @@ private function doSignal(int $signal, bool $throwException): bool $ok = @proc_terminate($this->process, $signal); } elseif (\function_exists('posix_kill')) { $ok = @posix_kill($pid, $signal); - } elseif ($ok = proc_open(sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) { + } elseif ($ok = proc_open(\sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) { $ok = false === fgets($pipes[2]); } if (!$ok) { if ($throwException) { - throw new RuntimeException(sprintf('Error while sending signal "%s".', $signal)); + throw new RuntimeException(\sprintf('Error while sending signal "%s".', $signal)); } return false; @@ -1554,11 +1526,25 @@ private function doSignal(int $signal, bool $throwException): bool return true; } - private function prepareWindowsCommandLine(string $cmd, array &$env): string + private function buildShellCommandline(string|array $commandline): string + { + if (\is_string($commandline)) { + return $commandline; + } + + if ('\\' === \DIRECTORY_SEPARATOR && isset($commandline[0][0]) && \strlen($commandline[0]) === strcspn($commandline[0], ':/\\')) { + // On Windows, we don't rely on the OS to find the executable if possible to avoid lookups + // in the current directory which could be untrusted. Instead we use the ExecutableFinder. + $commandline[0] = (self::$executables[$commandline[0]] ??= (new ExecutableFinder())->find($commandline[0])) ?? $commandline[0]; + } + + return implode(' ', array_map($this->escapeArgument(...), $commandline)); + } + + private function prepareWindowsCommandLine(string|array $cmd, array &$env): string { - $uid = uniqid('', true); - $varCount = 0; - $varCache = []; + $cmd = $this->buildShellCommandline($cmd); + $uid = bin2hex(random_bytes(4)); $cmd = preg_replace_callback( '/"(?:( [^"%!^]*+ @@ -1567,7 +1553,9 @@ private function prepareWindowsCommandLine(string $cmd, array &$env): string [^"%!^]*+ )++ ) | [^"]*+ )"/x', - function ($m) use (&$env, &$varCache, &$varCount, $uid) { + function ($m) use (&$env, $uid) { + static $varCount = 0; + static $varCache = []; if (!isset($m[1])) { return $m[0]; } @@ -1612,10 +1600,10 @@ function ($m) use (&$env, &$varCache, &$varCount, $uid) { * * @throws LogicException if the process has not run */ - private function requireProcessIsStarted(string $functionName) + private function requireProcessIsStarted(string $functionName): void { if (!$this->isStarted()) { - throw new LogicException(sprintf('Process must be started before calling "%s()".', $functionName)); + throw new LogicException(\sprintf('Process must be started before calling "%s()".', $functionName)); } } @@ -1624,10 +1612,10 @@ private function requireProcessIsStarted(string $functionName) * * @throws LogicException if the process is not yet terminated */ - private function requireProcessIsTerminated(string $functionName) + private function requireProcessIsTerminated(string $functionName): void { if (!$this->isTerminated()) { - throw new LogicException(sprintf('Process must be terminated before calling "%s()".', $functionName)); + throw new LogicException(\sprintf('Process must be terminated before calling "%s()".', $functionName)); } } @@ -1653,11 +1641,11 @@ private function escapeArgument(?string $argument): string return '"'.str_replace(['"', '^', '%', '!', "\n"], ['""', '"^^"', '"^%"', '"^!"', '!LF!'], $argument).'"'; } - private function replacePlaceholders(string $commandline, array $env) + private function replacePlaceholders(string $commandline, array $env): string { return preg_replace_callback('/"\$\{:([_a-zA-Z]++[_a-zA-Z0-9]*+)\}"/', function ($matches) use ($commandline, $env) { if (!isset($env[$matches[1]]) || false === $env[$matches[1]]) { - throw new InvalidArgumentException(sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline); + throw new InvalidArgumentException(\sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline); } return $this->escapeArgument($env[$matches[1]]); diff --git a/ProcessUtils.php b/ProcessUtils.php index 2a7aff71..a2dbde9f 100644 --- a/ProcessUtils.php +++ b/ProcessUtils.php @@ -35,19 +35,14 @@ private function __construct() * @param string $caller The name of method call that validates the input * @param mixed $input The input to validate * - * @return mixed - * * @throws InvalidArgumentException In case the input is not valid */ - public static function validateInput(string $caller, $input) + public static function validateInput(string $caller, mixed $input): mixed { if (null !== $input) { if (\is_resource($input)) { return $input; } - if (\is_string($input)) { - return $input; - } if (\is_scalar($input)) { return (string) $input; } @@ -61,7 +56,7 @@ public static function validateInput(string $caller, $input) return new \IteratorIterator($input); } - throw new InvalidArgumentException(sprintf('"%s" only accepts strings, Traversable objects or stream resources.', $caller)); + throw new InvalidArgumentException(\sprintf('"%s" only accepts strings, Traversable objects or stream resources.', $caller)); } return $input; diff --git a/README.md b/README.md index 8777de4a..afce5e45 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,6 @@ Process Component The Process component executes commands in sub-processes. -Sponsor -------- - -The Process component for Symfony 5.4/6.0 is [backed][1] by [SensioLabs][2]. - -As the creator of Symfony, SensioLabs supports companies using Symfony, with an -offering encompassing consultancy, expertise, services, training, and technical -assistance to ensure the success of web application development projects. - -Help Symfony by [sponsoring][3] its development! - Resources --------- @@ -22,7 +11,3 @@ Resources * [Report issues](https://github.com/symfony/symfony/issues) and [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) - -[1]: https://symfony.com/backers -[2]: https://sensiolabs.com -[3]: https://symfony.com/sponsor diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index c102ab68..87288360 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -85,6 +85,31 @@ public function testFindWithExtraDirs() $this->assertSamePath(\PHP_BINARY, $result); } + public function testFindWithoutSuffix() + { + $fixturesDir = __DIR__.\DIRECTORY_SEPARATOR.'Fixtures'; + $name = 'executable_without_suffix'; + + $finder = new ExecutableFinder(); + $result = $finder->find($name, null, [$fixturesDir]); + + $this->assertSamePath($fixturesDir.\DIRECTORY_SEPARATOR.$name, $result); + } + + public function testFindWithAddedSuffixes() + { + $fixturesDir = __DIR__.\DIRECTORY_SEPARATOR.'Fixtures'; + $name = 'executable_with_added_suffix'; + $suffix = '.foo'; + + $finder = new ExecutableFinder(); + $finder->addSuffix($suffix); + + $result = $finder->find($name, null, [$fixturesDir]); + + $this->assertSamePath($fixturesDir.\DIRECTORY_SEPARATOR.$name.$suffix, $result); + } + /** * @runInSeparateProcess */ diff --git a/Tests/Fixtures/executable_with_added_suffix.foo b/Tests/Fixtures/executable_with_added_suffix.foo new file mode 100755 index 00000000..471a493a --- /dev/null +++ b/Tests/Fixtures/executable_with_added_suffix.foo @@ -0,0 +1 @@ +See \Symfony\Component\Process\Tests\ExecutableFinderTest::testFindWithAddedSuffixes() diff --git a/Tests/Fixtures/executable_without_suffix b/Tests/Fixtures/executable_without_suffix new file mode 100755 index 00000000..9bf8b4db --- /dev/null +++ b/Tests/Fixtures/executable_without_suffix @@ -0,0 +1 @@ +See \Symfony\Component\Process\Tests\ExecutableFinderTest::testFindWithoutSuffix() diff --git a/Tests/Fixtures/memory.php b/Tests/Fixtures/memory.php new file mode 100644 index 00000000..1d3b2487 --- /dev/null +++ b/Tests/Fixtures/memory.php @@ -0,0 +1,3 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Tests\Messenger; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Exception\RunProcessFailedException; +use Symfony\Component\Process\Messenger\RunProcessMessage; +use Symfony\Component\Process\Messenger\RunProcessMessageHandler; + +class RunProcessMessageHandlerTest extends TestCase +{ + public function testRunSuccessfulProcess() + { + $context = (new RunProcessMessageHandler())(new RunProcessMessage(['ls'], cwd: __DIR__)); + + $this->assertSame(['ls'], $context->message->command); + $this->assertSame(0, $context->exitCode); + $this->assertStringContainsString(basename(__FILE__), $context->output); + } + + public function testRunFailedProcess() + { + try { + (new RunProcessMessageHandler())(new RunProcessMessage(['invalid'])); + } catch (RunProcessFailedException $e) { + $this->assertSame(['invalid'], $e->context->message->command); + $this->assertContains( + $e->context->exitCode, + [null, '\\' === \DIRECTORY_SEPARATOR ? 1 : 127], + 'Exit code should be 1 on Windows, 127 on other systems, or null', + ); + + return; + } + + $this->fail('Exception not thrown'); + } +} diff --git a/Tests/NonStopableProcess.php b/Tests/NonStopableProcess.php index c695f544..d1a694db 100644 --- a/Tests/NonStopableProcess.php +++ b/Tests/NonStopableProcess.php @@ -18,17 +18,11 @@ */ function handleSignal($signal) { - switch ($signal) { - case \SIGTERM: - $name = 'SIGTERM'; - break; - case \SIGINT: - $name = 'SIGINT'; - break; - default: - $name = $signal.' (unknown)'; - break; - } + $name = match ($signal) { + \SIGTERM => 'SIGTERM', + \SIGINT => 'SIGINT', + default => $signal.' (unknown)', + }; echo "signal $name\n"; } diff --git a/Tests/OutputMemoryLimitProcess.php b/Tests/OutputMemoryLimitProcess.php new file mode 100644 index 00000000..5f85b0f4 --- /dev/null +++ b/Tests/OutputMemoryLimitProcess.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Tests; + +use Symfony\Component\Process\PhpSubprocess; +use Symfony\Component\Process\Process; + +require is_file(\dirname(__DIR__).'/vendor/autoload.php') ? \dirname(__DIR__).'/vendor/autoload.php' : \dirname(__DIR__, 5).'/vendor/autoload.php'; + +['e' => $php, 'p' => $process] = getopt('e:p:') + ['e' => 'php', 'p' => 'Process']; + +if ('Process' === $process) { + $p = new Process([$php, __DIR__.'/Fixtures/memory.php']); +} else { + $p = new PhpSubprocess([__DIR__.'/Fixtures/memory.php'], null, null, 60, [$php]); +} + +$p->mustRun(); +echo $p->getOutput(); diff --git a/Tests/PhpSubprocessTest.php b/Tests/PhpSubprocessTest.php new file mode 100644 index 00000000..3406e649 --- /dev/null +++ b/Tests/PhpSubprocessTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +class PhpSubprocessTest extends TestCase +{ + private static $phpBin; + + public static function setUpBeforeClass(): void + { + $phpBin = new PhpExecutableFinder(); + self::$phpBin = getenv('SYMFONY_PROCESS_PHP_TEST_BINARY') ?: ('phpdbg' === \PHP_SAPI ? 'php' : $phpBin->find()); + } + + /** + * @dataProvider subprocessProvider + */ + public function testSubprocess(string $processClass, string $memoryLimit, string $expectedMemoryLimit) + { + $process = new Process([self::$phpBin, + '-d', + 'memory_limit='.$memoryLimit, + __DIR__.'/OutputMemoryLimitProcess.php', + '-e', self::$phpBin, + '-p', $processClass, + ]); + + $process->mustRun(); + $this->assertEquals($expectedMemoryLimit, trim($process->getOutput())); + } + + public static function subprocessProvider(): \Generator + { + yield 'Process does ignore dynamic memory_limit' => [ + 'Process', + self::getRandomMemoryLimit(), + self::getDefaultMemoryLimit(), + ]; + + yield 'PhpSubprocess does not ignore dynamic memory_limit' => [ + 'PhpSubprocess', + self::getRandomMemoryLimit(), + self::getRandomMemoryLimit(), + ]; + } + + private static function getDefaultMemoryLimit(): string + { + return trim(ini_get_all()['memory_limit']['global_value']); + } + + private static function getRandomMemoryLimit(): string + { + $memoryLimit = 123; // Take something that's really unlikely to be configured on a user system. + + while (($formatted = $memoryLimit.'M') === self::getDefaultMemoryLimit()) { + ++$memoryLimit; + } + + return $formatted; + } +} diff --git a/Tests/ProcessFailedExceptionTest.php b/Tests/ProcessFailedExceptionTest.php index 259ffd63..d05beb85 100644 --- a/Tests/ProcessFailedExceptionTest.php +++ b/Tests/ProcessFailedExceptionTest.php @@ -80,8 +80,8 @@ public function testProcessFailedExceptionPopulatesInformationFromProcessOutput( $exception = new ProcessFailedException($process); - $this->assertEquals( - "The command \"$cmd\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}\n\nOutput:\n================\n{$output}\n\nError Output:\n================\n{$errorOutput}", + $this->assertStringMatchesFormat( + "The command \"%s\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}\n\nOutput:\n================\n{$output}\n\nError Output:\n================\n{$errorOutput}", str_replace("'php'", 'php', $exception->getMessage()) ); } @@ -126,9 +126,9 @@ public function testDisabledOutputInFailedExceptionDoesNotPopulateOutput() $exception = new ProcessFailedException($process); - $this->assertEquals( - "The command \"$cmd\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}", - str_replace("'php'", 'php', $exception->getMessage()) + $this->assertStringMatchesFormat( + "The command \"%s\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}", + $exception->getMessage() ); } } diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index e4d92874..00d06208 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -28,9 +28,9 @@ */ class ProcessTest extends TestCase { - private static $phpBin; - private static $process; - private static $sigchild; + private static string $phpBin; + private static ?Process $process = null; + private static bool $sigchild; public static function setUpBeforeClass(): void { @@ -66,6 +66,23 @@ public function testInvalidCwd() $cmd->run(); } + /** + * @dataProvider invalidProcessProvider + */ + public function testInvalidCommand(Process $process) + { + // An invalid command should not fail during start + $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); + } + + public static function invalidProcessProvider(): array + { + return [ + [new Process(['invalid'])], + [Process::fromShellCommandline('invalid')], + ]; + } + /** * @group transient-on-windows */ @@ -154,7 +171,7 @@ public function testWaitUntilCanReturnFalse() { $p = $this->getProcess('echo foo'); $p->start(); - $this->assertFalse($p->waitUntil(function () { return false; })); + $this->assertFalse($p->waitUntil(fn () => false)); } public function testAllOutputIsActuallyReadOnTermination() @@ -168,14 +185,13 @@ public function testAllOutputIsActuallyReadOnTermination() // another byte which will never be read. $expectedOutputSize = PipesInterface::CHUNK_SIZE * 2 + 2; - $code = sprintf('echo str_repeat(\'*\', %d);', $expectedOutputSize); + $code = \sprintf('echo str_repeat(\'*\', %d);', $expectedOutputSize); $p = $this->getProcessForCode($code); $p->start(); // Don't call Process::run nor Process::wait to avoid any read of pipes $h = new \ReflectionProperty($p, 'process'); - $h->setAccessible(true); $h = $h->getValue($p); $s = @proc_get_status($h); @@ -201,6 +217,20 @@ public function testCallbacksAreExecutedWithStart() $this->assertSame('foo'.\PHP_EOL, $data); } + public function testReadSupportIsDisabledWithoutCallback() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::wait".'); + + $process = $this->getProcess('echo foo'); + // disabling output + not passing a callback to start() => read support disabled + $process->disableOutput(); + $process->start(); + $process->wait(function ($type, $buffer) use (&$data) { + $data .= $buffer; + }); + } + /** * tests results from sub processes. * @@ -292,11 +322,13 @@ public function testSetInputWhileRunningThrowsAnException() /** * @dataProvider provideInvalidInputValues */ - public function testInvalidInput($value) + public function testInvalidInput(array|object $value) { + $process = $this->getProcess('foo'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('"Symfony\Component\Process\Process::setInput" only accepts strings, Traversable objects or stream resources.'); - $process = $this->getProcess('foo'); + $process->setInput($value); } @@ -311,7 +343,7 @@ public static function provideInvalidInputValues() /** * @dataProvider provideInputValues */ - public function testValidInput($expected, $value) + public function testValidInput(?string $expected, float|string|null $value) { $process = $this->getProcess('foo'); $process->setInput($value); @@ -346,7 +378,7 @@ public static function chainedCommandsOutputProvider() */ public function testChainedCommandsOutput($expected, $operator, $input) { - $process = $this->getProcess(sprintf('echo %s %s echo %s', $input, $operator, $input)); + $process = $this->getProcess(\sprintf('echo %s %s echo %s', $input, $operator, $input)); $process->run(); $this->assertEquals($expected, $process->getOutput()); } @@ -527,6 +559,20 @@ public function testExitCodeTextIsNullWhenExitCodeIsNull() $this->assertNull($process->getExitCodeText()); } + public function testStderrNotMixedWithStdout() + { + if (!Process::isPtySupported()) { + $this->markTestSkipped('PTY is not supported on this operating system.'); + } + + $process = $this->getProcess('echo "foo" && echo "bar" >&2'); + $process->setPty(true); + $process->run(); + + $this->assertSame("foo\r\n", $process->getOutput()); + $this->assertSame("bar\n", $process->getErrorOutput()); + } + public function testPTYCommand() { if (!Process::isPtySupported()) { @@ -557,8 +603,10 @@ public function testSuccessfulMustRunHasCorrectExitCode() public function testMustRunThrowsException() { - $this->expectException(ProcessFailedException::class); $process = $this->getProcess('exit 1'); + + $this->expectException(ProcessFailedException::class); + $process->mustRun(); } @@ -567,7 +615,6 @@ public function testExitCodeText() $process = $this->getProcess(''); $r = new \ReflectionObject($process); $p = $r->getProperty('exitcode'); - $p->setAccessible(true); $p->setValue($process, 2); $this->assertEquals('Misuse of shell builtins', $process->getExitCodeText()); @@ -699,6 +746,9 @@ public function testProcessIsSignaledIfStopped() if ('\\' === \DIRECTORY_SEPARATOR) { $this->markTestSkipped('Windows does not support POSIX signals'); } + if (\PHP_VERSION_ID < 80300 && isset($_SERVER['GITHUB_ACTIONS'])) { + $this->markTestSkipped('Transient on GHA with PHP < 8.3'); + } $process = $this->getProcessForCode('sleep(32);'); $process->start(); @@ -937,9 +987,11 @@ public function testExitCodeIsAvailableAfterSignal() public function testSignalProcessNotRunning() { + $process = $this->getProcess('foo'); + $this->expectException(LogicException::class); $this->expectExceptionMessage('Cannot send signal on a non running process.'); - $process = $this->getProcess('foo'); + $process->signal(1); // SIGHUP } @@ -951,7 +1003,7 @@ public function testMethodsThatNeedARunningProcess($method) $process = $this->getProcess('foo'); $this->expectException(LogicException::class); - $this->expectExceptionMessage(sprintf('Process must be started before calling "%s()".', $method)); + $this->expectExceptionMessage(\sprintf('Process must be started before calling "%s()".', $method)); $process->{$method}(); } @@ -1027,20 +1079,24 @@ public function testDisableOutputDisablesTheOutput() public function testDisableOutputWhileRunningThrowsException() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Disabling output while the process is running is not possible.'); $p = $this->getProcessForCode('sleep(39);'); $p->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Disabling output while the process is running is not possible.'); + $p->disableOutput(); } public function testEnableOutputWhileRunningThrowsException() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Enabling output while the process is running is not possible.'); $p = $this->getProcessForCode('sleep(40);'); $p->disableOutput(); $p->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Enabling output while the process is running is not possible.'); + $p->enableOutput(); } @@ -1056,19 +1112,23 @@ public function testEnableOrDisableOutputAfterRunDoesNotThrowException() public function testDisableOutputWhileIdleTimeoutIsSet() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Output cannot be disabled while an idle timeout is set.'); $process = $this->getProcess('foo'); $process->setIdleTimeout(1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Output cannot be disabled while an idle timeout is set.'); + $process->disableOutput(); } public function testSetIdleTimeoutWhileOutputIsDisabled() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('timeout cannot be set while the output is disabled.'); $process = $this->getProcess('foo'); $process->disableOutput(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('timeout cannot be set while the output is disabled.'); + $process->setIdleTimeout(1); } @@ -1084,11 +1144,13 @@ public function testSetNullIdleTimeoutWhileOutputIsDisabled() */ public function testGetOutputWhileDisabled($fetchMethod) { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Output has been disabled.'); $p = $this->getProcessForCode('sleep(41);'); $p->disableOutput(); $p->start(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Output has been disabled.'); + $p->{$fetchMethod}(); } @@ -1446,7 +1508,7 @@ public function testEscapeArgument($arg) public function testRawCommandLine() { - $p = Process::fromShellCommandline(sprintf('"%s" -r %s "a" "" "b"', self::$phpBin, escapeshellarg('print_r($argv);'))); + $p = Process::fromShellCommandline(\sprintf('"%s" -r %s "a" "" "b"', self::$phpBin, escapeshellarg('print_r($argv);'))); $p->run(); $expected = "Array\n(\n [0] => -\n [1] => a\n [2] => \n [3] => b\n)\n"; @@ -1493,17 +1555,21 @@ public function testPreparedCommandWithQuoteInIt() public function testPreparedCommandWithMissingValue() { + $p = Process::fromShellCommandline('echo "${:abc}"'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Command line is missing a value for parameter "abc": echo "${:abc}"'); - $p = Process::fromShellCommandline('echo "${:abc}"'); + $p->run(null, ['bcd' => 'BCD']); } public function testPreparedCommandWithNoValues() { + $p = Process::fromShellCommandline('echo "${:abc}"'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Command line is missing a value for parameter "abc": echo "${:abc}"'); - $p = Process::fromShellCommandline('echo "${:abc}"'); + $p->run(null, []); } @@ -1529,7 +1595,7 @@ public function testWaitStoppedDeadProcess() $process->wait(); $this->assertFalse($process->isRunning()); - if ('\\' !== \DIRECTORY_SEPARATOR && !\Closure::bind(function () { return $this->isSigchildEnabled(); }, $process, $process)()) { + if ('\\' !== \DIRECTORY_SEPARATOR && !\Closure::bind(fn () => $this->isSigchildEnabled(), $process, $process)()) { $this->assertSame(0, $process->getExitCode()); } } @@ -1613,10 +1679,51 @@ public function testNotTerminableInputPipe() $this->assertFalse($process->isRunning()); } - /** - * @param string|array $commandline - */ - private function getProcess($commandline, ?string $cwd = null, ?array $env = null, $input = null, ?int $timeout = 60): Process + public function testIgnoringSignal() + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pnctl extension is required.'); + } + + $process = $this->getProcess(['sleep', '10']); + $process->setIgnoredSignals([\SIGTERM]); + + $process->start(); + $process->stop(timeout: 0.2); + + $this->assertNotSame(\SIGTERM, $process->getTermSignal()); + } + + // This test ensure that the previous test is reliable, in case of the sleep command ignoring the SIGTERM signal + public function testNotIgnoringSignal() + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pnctl extension is required.'); + } + if (\PHP_VERSION_ID < 80300 && isset($_SERVER['GITHUB_ACTIONS'])) { + $this->markTestSkipped('Transient on GHA with PHP < 8.3'); + } + + $process = $this->getProcess(['sleep', '10']); + + $process->start(); + $process->stop(timeout: 0.2); + + $this->assertSame(\SIGTERM, $process->getTermSignal()); + } + + public function testPathResolutionOnWindows() + { + if ('\\' !== \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('This test is for Windows platform only'); + } + + $process = $this->getProcess(['where']); + + $this->assertSame('C:\\Windows\\system32\\where.EXE', $process->getCommandLine()); + } + + private function getProcess(string|array $commandline, ?string $cwd = null, ?array $env = null, mixed $input = null, ?int $timeout = 60): Process { if (\is_string($commandline)) { $process = Process::fromShellCommandline($commandline, $cwd, $env, $input, $timeout); @@ -1624,9 +1731,7 @@ private function getProcess($commandline, ?string $cwd = null, ?array $env = nul $process = new Process($commandline, $cwd, $env, $input, $timeout); } - if (self::$process) { - self::$process->stop(0); - } + self::$process?->stop(0); return self::$process = $process; } diff --git a/Tests/SignalListener.php b/Tests/SignalListener.php index 618be740..7a351858 100644 --- a/Tests/SignalListener.php +++ b/Tests/SignalListener.php @@ -9,7 +9,10 @@ * file that was distributed with this source code. */ -pcntl_signal(\SIGUSR1, function () { echo 'SIGUSR1'; exit; }); +pcntl_signal(\SIGUSR1, function () { + echo 'SIGUSR1'; + exit; +}); echo 'Caught '; diff --git a/composer.json b/composer.json index 1669eba5..dda5575e 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,7 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.2" }, "autoload": { "psr-4": { "Symfony\\Component\\Process\\": "" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c32f2510..13bd3f83 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - + + ./ - - ./Tests - ./vendor - - - + + + ./Tests + ./vendor + +