Updated Symfony/Process to version 7.1.3

This commit is contained in:
netkas 2024-09-19 15:14:53 -04:00
parent 910477df8e
commit dd48100f06
21 changed files with 547 additions and 178 deletions

View file

@ -69,6 +69,7 @@ This update introduces a refactored code-base, code quality improvements, and be
- Refactor project constants handling in NccCompiler - Refactor project constants handling in NccCompiler
- Updated Symfony/Yaml to version 7.1.4 - Updated Symfony/Yaml to version 7.1.4
- Updated Symfony/Uid to version 7.1.4 - Updated Symfony/Uid to version 7.1.4
- Updated Symfony/Process to version 7.1.3
### Fixed ### Fixed
- Fixed Division by zero in PackageManager - Fixed Division by zero in PackageManager

View file

@ -1,6 +1,18 @@
CHANGELOG 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 5.2.0
----- -----

View file

@ -20,7 +20,7 @@ use ncc\ThirdParty\Symfony\Process\Process;
*/ */
class ProcessFailedException extends RuntimeException class ProcessFailedException extends RuntimeException
{ {
private $process; private Process $process;
public function __construct(Process $process) public function __construct(Process $process)
{ {
@ -47,10 +47,7 @@ class ProcessFailedException extends RuntimeException
$this->process = $process; $this->process = $process;
} }
/** public function getProcess(): Process
* @return Process
*/
public function getProcess()
{ {
return $this->process; return $this->process;
} }

View file

@ -20,7 +20,7 @@ use ncc\ThirdParty\Symfony\Process\Process;
*/ */
final class ProcessSignaledException extends RuntimeException final class ProcessSignaledException extends RuntimeException
{ {
private $process; private Process $process;
public function __construct(Process $process) public function __construct(Process $process)
{ {

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ncc\ThirdParty\Symfony\Process\Exception;
use ncc\ThirdParty\Symfony\Process\Process;
/**
* Exception for processes failed during startup.
*/
class ProcessStartFailedException extends ProcessFailedException
{
private Process $process;
public function __construct(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);
$this->process = $process;
}
public function getProcess(): Process
{
return $this->process;
}
}

View file

@ -23,8 +23,8 @@ class ProcessTimedOutException extends RuntimeException
public const TYPE_GENERAL = 1; public const TYPE_GENERAL = 1;
public const TYPE_IDLE = 2; public const TYPE_IDLE = 2;
private $process; private Process $process;
private $timeoutType; private int $timeoutType;
public function __construct(Process $process, int $timeoutType) public function __construct(Process $process, int $timeoutType)
{ {
@ -38,26 +38,17 @@ class ProcessTimedOutException extends RuntimeException
)); ));
} }
/** public function getProcess(): Process
* @return Process
*/
public function getProcess()
{ {
return $this->process; return $this->process;
} }
/** public function isGeneralTimeout(): bool
* @return bool
*/
public function isGeneralTimeout()
{ {
return self::TYPE_GENERAL === $this->timeoutType; return self::TYPE_GENERAL === $this->timeoutType;
} }
/** public function isIdleTimeout(): bool
* @return bool
*/
public function isIdleTimeout()
{ {
return self::TYPE_IDLE === $this->timeoutType; return self::TYPE_IDLE === $this->timeoutType;
} }

View file

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ncc\ThirdParty\Symfony\Process\Exception;
use ncc\ThirdParty\Symfony\Process\Messenger\RunProcessContext;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class RunProcessFailedException extends RuntimeException
{
public function __construct(ProcessFailedException $exception, public readonly RunProcessContext $context)
{
parent::__construct($exception->getMessage(), $exception->getCode());
}
}

View file

@ -19,24 +19,20 @@ namespace ncc\ThirdParty\Symfony\Process;
*/ */
class ExecutableFinder class ExecutableFinder
{ {
private $suffixes = ['.exe', '.bat', '.cmd', '.com']; private array $suffixes = ['.exe', '.bat', '.cmd', '.com'];
/** /**
* Replaces default suffixes of executable. * Replaces default suffixes of executable.
*
* @return void
*/ */
public function setSuffixes(array $suffixes) public function setSuffixes(array $suffixes): void
{ {
$this->suffixes = $suffixes; $this->suffixes = $suffixes;
} }
/** /**
* Adds new possible suffix to check for executable. * Adds new possible suffix to check for executable.
*
* @return void
*/ */
public function addSuffix(string $suffix) public function addSuffix(string $suffix): void
{ {
$this->suffixes[] = $suffix; $this->suffixes[] = $suffix;
} }
@ -48,27 +44,12 @@ class ExecutableFinder
* @param string|null $default The default to return if no executable is found * @param string|null $default The default to return if no executable is found
* @param array $extraDirs Additional dirs to check into * @param array $extraDirs Additional dirs to check into
*/ */
public function find(string $name, string $default = null, array $extraDirs = []): ?string public function find(string $name, ?string $default = null, array $extraDirs = []): ?string
{ {
if (\ini_get('open_basedir')) {
$searchPath = array_merge(explode(\PATH_SEPARATOR, \ini_get('open_basedir')), $extraDirs);
$dirs = [];
foreach ($searchPath as $path) {
// Silencing against https://bugs.php.net/69240
if (@is_dir($path)) {
$dirs[] = $path;
} else {
if (basename($path) == $name && @is_executable($path)) {
return $path;
}
}
}
} else {
$dirs = array_merge( $dirs = array_merge(
explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')),
$extraDirs $extraDirs
); );
}
$suffixes = ['']; $suffixes = [''];
if ('\\' === \DIRECTORY_SEPARATOR) { if ('\\' === \DIRECTORY_SEPARATOR) {
@ -80,8 +61,17 @@ class ExecutableFinder
if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) { if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) {
return $file; return $file;
} }
if (!@is_dir($dir) && basename($dir) === $name.$suffix && @is_executable($dir)) {
return $dir;
} }
} }
}
$command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --';
if (\function_exists('exec') && ($executablePath = strtok(@exec($command.' '.escapeshellarg($name)), \PHP_EOL)) && @is_executable($executablePath)) {
return $executablePath;
}
return $default; return $default;
} }

View file

@ -22,19 +22,16 @@ use ncc\ThirdParty\Symfony\Process\Exception\RuntimeException;
*/ */
class InputStream implements \IteratorAggregate class InputStream implements \IteratorAggregate
{ {
/** @var callable|null */ private ?\Closure $onEmpty = null;
private $onEmpty; private array $input = [];
private $input = []; private bool $open = true;
private $open = true;
/** /**
* Sets a callback that is called when the write buffer becomes empty. * Sets a callback that is called when the write buffer becomes empty.
*
* @return void
*/ */
public function onEmpty(callable $onEmpty = null) public function onEmpty(?callable $onEmpty = null): void
{ {
$this->onEmpty = $onEmpty; $this->onEmpty = null !== $onEmpty ? $onEmpty(...) : null;
} }
/** /**
@ -42,10 +39,8 @@ class InputStream implements \IteratorAggregate
* *
* @param resource|string|int|float|bool|\Traversable|null $input The input to append as scalar, * @param resource|string|int|float|bool|\Traversable|null $input The input to append as scalar,
* stream resource or \Traversable * stream resource or \Traversable
*
* @return void
*/ */
public function write(mixed $input) public function write(mixed $input): void
{ {
if (null === $input) { if (null === $input) {
return; return;
@ -58,20 +53,16 @@ class InputStream implements \IteratorAggregate
/** /**
* Closes the write buffer. * Closes the write buffer.
*
* @return void
*/ */
public function close() public function close(): void
{ {
$this->open = false; $this->open = false;
} }
/** /**
* Tells whether the write buffer is closed or not. * Tells whether the write buffer is closed or not.
*
* @return bool
*/ */
public function isClosed() public function isClosed(): bool
{ {
return !$this->open; return !$this->open;
} }

View file

@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ncc\ThirdParty\Symfony\Process\Messenger;
use ncc\ThirdParty\Symfony\Process\Process;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
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();
}
}

View file

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ncc\ThirdParty\Symfony\Process\Messenger;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
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);
}
}

View file

@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ncc\ThirdParty\Symfony\Process\Messenger;
use ncc\ThirdParty\Symfony\Process\Exception\ProcessFailedException;
use ncc\ThirdParty\Symfony\Process\Exception\RunProcessFailedException;
use ncc\ThirdParty\Symfony\Process\Process;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
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()));
}
}
}

View file

@ -19,7 +19,7 @@ namespace ncc\ThirdParty\Symfony\Process;
*/ */
class PhpExecutableFinder class PhpExecutableFinder
{ {
private $executableFinder; private ExecutableFinder $executableFinder;
public function __construct() public function __construct()
{ {
@ -33,8 +33,8 @@ class PhpExecutableFinder
{ {
if ($php = getenv('PHP_BINARY')) { if ($php = getenv('PHP_BINARY')) {
if (!is_executable($php)) { if (!is_executable($php)) {
$command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v'; $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --';
if ($php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { if (\function_exists('exec') && $php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) {
if (!is_executable($php)) { if (!is_executable($php)) {
return false; return false;
} }

View file

@ -32,7 +32,7 @@ class PhpProcess extends Process
* @param int $timeout The timeout in seconds * @param int $timeout The timeout in seconds
* @param array|null $php Path to the PHP binary to use with any additional arguments * @param array|null $php Path to the PHP binary to use with any additional arguments
*/ */
public function __construct(string $script, string $cwd = null, array $env = null, int $timeout = 60, array $php = null) public function __construct(string $script, ?string $cwd = null, ?array $env = null, int $timeout = 60, ?array $php = null)
{ {
if (null === $php) { if (null === $php) {
$executableFinder = new PhpExecutableFinder(); $executableFinder = new PhpExecutableFinder();
@ -50,15 +50,12 @@ class PhpProcess extends Process
parent::__construct($php, $cwd, $env, $script, $timeout); parent::__construct($php, $cwd, $env, $script, $timeout);
} }
public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, mixed $input = null, ?float $timeout = 60): static 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));
} }
/** public function start(?callable $callback = null, array $env = []): void
* @return void
*/
public function start(callable $callback = null, array $env = [])
{ {
if (null === $this->getCommandLine()) { if (null === $this->getCommandLine()) {
throw new RuntimeException('Unable to find the PHP executable.'); throw new RuntimeException('Unable to find the PHP executable.');

View file

@ -0,0 +1,164 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace ncc\ThirdParty\Symfony\Process;
use ncc\ThirdParty\Symfony\Process\Exception\LogicException;
use ncc\ThirdParty\Symfony\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:
*
* <?php var_dump(ini_get('memory_limit'));
*
* These are the differences between the regular Process and PhpSubprocess classes:
*
* $p = new Process(['php', '-d', 'memory_limit=256M', 'MemoryTest.php']);
* $p->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 <yanick.witschi@terminal42.ch>
* @author Partially copied and heavily inspired from composer/xdebug-handler by John Stevenson <john-stevenson@blueyonder.co.uk>
*/
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;
}
}

View file

@ -22,20 +22,19 @@ abstract class AbstractPipes implements PipesInterface
{ {
public array $pipes = []; public array $pipes = [];
private $inputBuffer = ''; private string $inputBuffer = '';
/** @var resource|string|\Iterator */
private $input; private $input;
private $blocked = true; private bool $blocked = true;
private $lastError; private ?string $lastError = null;
/** /**
* @param resource|string|int|float|bool|\Iterator|null $input * @param resource|string|\Iterator $input
*/ */
public function __construct(mixed $input) public function __construct($input)
{ {
if (\is_resource($input) || $input instanceof \Iterator) { if (\is_resource($input) || $input instanceof \Iterator) {
$this->input = $input; $this->input = $input;
} elseif (\is_string($input)) {
$this->inputBuffer = $input;
} else { } else {
$this->inputBuffer = (string) $input; $this->inputBuffer = (string) $input;
} }

View file

@ -22,9 +22,9 @@ use ncc\ThirdParty\Symfony\Process\Process;
*/ */
class UnixPipes extends AbstractPipes class UnixPipes extends AbstractPipes
{ {
private $ttyMode; private ?bool $ttyMode;
private $ptyMode; private bool $ptyMode;
private $haveReadSupport; private bool $haveReadSupport;
public function __construct(?bool $ttyMode, bool $ptyMode, mixed $input, bool $haveReadSupport) public function __construct(?bool $ttyMode, bool $ptyMode, mixed $input, bool $haveReadSupport)
{ {
@ -40,7 +40,7 @@ class UnixPipes extends AbstractPipes
throw new \BadMethodCallException('Cannot serialize '.__CLASS__); throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
} }
public function __wakeup() public function __wakeup(): void
{ {
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
} }

View file

@ -26,14 +26,14 @@ use ncc\ThirdParty\Symfony\Process\Process;
*/ */
class WindowsPipes extends AbstractPipes class WindowsPipes extends AbstractPipes
{ {
private $files = []; private array $files = [];
private $fileHandles = []; private array $fileHandles = [];
private $lockHandles = []; private array $lockHandles = [];
private $readBytes = [ private array $readBytes = [
Process::STDOUT => 0, Process::STDOUT => 0,
Process::STDERR => 0, Process::STDERR => 0,
]; ];
private $haveReadSupport; private bool $haveReadSupport;
public function __construct(mixed $input, bool $haveReadSupport) public function __construct(mixed $input, bool $haveReadSupport)
{ {
@ -93,7 +93,7 @@ class WindowsPipes extends AbstractPipes
throw new \BadMethodCallException('Cannot serialize '.__CLASS__); throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
} }
public function __wakeup() public function __wakeup(): void
{ {
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
} }
@ -140,7 +140,7 @@ class WindowsPipes extends AbstractPipes
if ($w) { if ($w) {
@stream_select($r, $w, $e, 0, Process::TIMEOUT_PRECISION * 1E6); @stream_select($r, $w, $e, 0, Process::TIMEOUT_PRECISION * 1E6);
} elseif ($this->fileHandles) { } elseif ($this->fileHandles) {
usleep(Process::TIMEOUT_PRECISION * 1E6); usleep((int) (Process::TIMEOUT_PRECISION * 1E6));
} }
} }
foreach ($this->fileHandles as $type => $fileHandle) { foreach ($this->fileHandles as $type => $fileHandle) {

View file

@ -15,9 +15,9 @@ use ncc\ThirdParty\Symfony\Process\Exception\InvalidArgumentException;
use ncc\ThirdParty\Symfony\Process\Exception\LogicException; use ncc\ThirdParty\Symfony\Process\Exception\LogicException;
use ncc\ThirdParty\Symfony\Process\Exception\ProcessFailedException; use ncc\ThirdParty\Symfony\Process\Exception\ProcessFailedException;
use ncc\ThirdParty\Symfony\Process\Exception\ProcessSignaledException; use ncc\ThirdParty\Symfony\Process\Exception\ProcessSignaledException;
use ncc\ThirdParty\Symfony\Process\Exception\ProcessStartFailedException;
use ncc\ThirdParty\Symfony\Process\Exception\ProcessTimedOutException; use ncc\ThirdParty\Symfony\Process\Exception\ProcessTimedOutException;
use ncc\ThirdParty\Symfony\Process\Exception\RuntimeException; use ncc\ThirdParty\Symfony\Process\Exception\RuntimeException;
use ncc\ThirdParty\Symfony\Process\Pipes\PipesInterface;
use ncc\ThirdParty\Symfony\Process\Pipes\UnixPipes; use ncc\ThirdParty\Symfony\Process\Pipes\UnixPipes;
use ncc\ThirdParty\Symfony\Process\Pipes\WindowsPipes; use ncc\ThirdParty\Symfony\Process\Pipes\WindowsPipes;
@ -51,44 +51,47 @@ class Process implements \IteratorAggregate
public const ITER_SKIP_OUT = 4; // Use this flag to skip STDOUT while iterating 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 public const ITER_SKIP_ERR = 8; // Use this flag to skip STDERR while iterating
private $callback; private ?\Closure $callback = null;
private $hasCallback = false; private array|string $commandline;
private $commandline; private ?string $cwd;
private $cwd; private array $env = [];
private $env = []; /** @var resource|string|\Iterator|null */
private $input; private $input;
private $starttime; private ?float $starttime = null;
private $lastOutputTime; private ?float $lastOutputTime = null;
private $timeout; private ?float $timeout = null;
private $idleTimeout; private ?float $idleTimeout = null;
private $exitcode; private ?int $exitcode = null;
private $fallbackStatus = []; private array $fallbackStatus = [];
private $processInformation; private array $processInformation;
private $outputDisabled = false; private bool $outputDisabled = false;
/** @var resource */
private $stdout; private $stdout;
/** @var resource */
private $stderr; private $stderr;
/** @var resource|null */
private $process; private $process;
private $status = self::STATUS_READY; private string $status = self::STATUS_READY;
private $incrementalOutputOffset = 0; private int $incrementalOutputOffset = 0;
private $incrementalErrorOutputOffset = 0; private int $incrementalErrorOutputOffset = 0;
private $tty = false; private bool $tty = false;
private $pty; private bool $pty;
private $options = ['suppress_errors' => true, 'bypass_shell' => true]; private array $options = ['suppress_errors' => true, 'bypass_shell' => true];
private array $ignoredSignals = [];
private $useFileHandles = false; private WindowsPipes|UnixPipes $processPipes;
/** @var PipesInterface */
private $processPipes;
private $latestSignal; private ?int $latestSignal = null;
private ?int $cachedExitCode = null;
private static $sigchild; private static ?bool $sigchild = null;
/** /**
* Exit codes translation table. * Exit codes translation table.
* *
* User-defined errors must use exit codes in the 64-113 range. * User-defined errors must use exit codes in the 64-113 range.
*/ */
public static $exitCodes = [ public static array $exitCodes = [
0 => 'OK', 0 => 'OK',
1 => 'General error', 1 => 'General error',
2 => 'Misuse of shell builtins', 2 => 'Misuse of shell builtins',
@ -140,7 +143,7 @@ class Process implements \IteratorAggregate
* *
* @throws LogicException When proc_open is not installed * @throws LogicException When proc_open is not installed
*/ */
public function __construct(array $command, string $cwd = null, array $env = null, mixed $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')) { if (!\function_exists('proc_open')) {
throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.'); throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.');
@ -162,7 +165,6 @@ class Process implements \IteratorAggregate
$this->setInput($input); $this->setInput($input);
$this->setTimeout($timeout); $this->setTimeout($timeout);
$this->useFileHandles = '\\' === \DIRECTORY_SEPARATOR;
$this->pty = false; $this->pty = false;
} }
@ -187,7 +189,7 @@ class Process implements \IteratorAggregate
* *
* @throws LogicException When proc_open is not installed * @throws LogicException When proc_open is not installed
*/ */
public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, mixed $input = null, ?float $timeout = 60): static 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 = new static([], $cwd, $env, $input, $timeout);
$process->commandline = $command; $process->commandline = $command;
@ -200,7 +202,7 @@ class Process implements \IteratorAggregate
throw new \BadMethodCallException('Cannot serialize '.__CLASS__); throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
} }
public function __wakeup() public function __wakeup(): void
{ {
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
} }
@ -234,7 +236,7 @@ class Process implements \IteratorAggregate
* *
* @return int The exit status code * @return int The exit status code
* *
* @throws RuntimeException When process can't be launched * @throws ProcessStartFailedException When process can't be launched
* @throws RuntimeException When process is already running * @throws RuntimeException When process is already running
* @throws ProcessTimedOutException When process timed out * @throws ProcessTimedOutException When process timed out
* @throws ProcessSignaledException When process stopped after receiving signal * @throws ProcessSignaledException When process stopped after receiving signal
@ -242,7 +244,7 @@ class Process implements \IteratorAggregate
* *
* @final * @final
*/ */
public function run(callable $callback = null, array $env = []): int public function run(?callable $callback = null, array $env = []): int
{ {
$this->start($callback, $env); $this->start($callback, $env);
@ -261,7 +263,7 @@ class Process implements \IteratorAggregate
* *
* @final * @final
*/ */
public function mustRun(callable $callback = null, array $env = []): static public function mustRun(?callable $callback = null, array $env = []): static
{ {
if (0 !== $this->run($callback, $env)) { if (0 !== $this->run($callback, $env)) {
throw new ProcessFailedException($this); throw new ProcessFailedException($this);
@ -285,13 +287,11 @@ class Process implements \IteratorAggregate
* @param callable|null $callback A PHP callback to run whenever there is some * @param callable|null $callback A PHP callback to run whenever there is some
* output available on STDOUT or STDERR * output available on STDOUT or STDERR
* *
* @return void * @throws ProcessStartFailedException When process can't be launched
*
* @throws RuntimeException When process can't be launched
* @throws RuntimeException When process is already running * @throws RuntimeException When process is already running
* @throws LogicException In case a callback is provided and output has been disabled * @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()) { if ($this->isRunning()) {
throw new RuntimeException('Process is already running.'); throw new RuntimeException('Process is already running.');
@ -300,8 +300,7 @@ class Process implements \IteratorAggregate
$this->resetProcessData(); $this->resetProcessData();
$this->starttime = $this->lastOutputTime = microtime(true); $this->starttime = $this->lastOutputTime = microtime(true);
$this->callback = $this->buildCallback($callback); $this->callback = $this->buildCallback($callback);
$this->hasCallback = null !== $callback; $descriptors = $this->getDescriptors(null !== $callback);
$descriptors = $this->getDescriptors();
if ($this->env) { if ($this->env) {
$env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->env, $env, 'strcasecmp') : $this->env; $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->env, $env, 'strcasecmp') : $this->env;
@ -310,29 +309,25 @@ class Process implements \IteratorAggregate
$env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv(); $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv();
if (\is_array($commandline = $this->commandline)) { if (\is_array($commandline = $this->commandline)) {
$commandline = implode(' ', array_map($this->escapeArgument(...), $commandline)); $commandline = array_values(array_map(strval(...), $commandline));
if ('\\' !== \DIRECTORY_SEPARATOR) {
// exec is mandatory to deal with sending a signal to the process
$commandline = 'exec '.$commandline;
}
} else { } else {
$commandline = $this->replacePlaceholders($commandline, $env); $commandline = $this->replacePlaceholders($commandline, $env);
} }
if ('\\' === \DIRECTORY_SEPARATOR) { if ('\\' === \DIRECTORY_SEPARATOR) {
$commandline = $this->prepareWindowsCommandLine($commandline, $env); $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 // last exit code is output on the fourth pipe and caught to work around --enable-sigchild
$descriptors[3] = ['pipe', 'w']; $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 // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input
$commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;'; $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'; $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 = []; $envPairs = [];
@ -346,11 +341,41 @@ class Process implements \IteratorAggregate
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;
if (!\is_resource($this->process)) { return true;
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; $this->status = self::STATUS_STARTED;
if (isset($descriptors[3])) { if (isset($descriptors[3])) {
@ -373,14 +398,14 @@ class Process implements \IteratorAggregate
* @param callable|null $callback A PHP callback to run whenever there is some * @param callable|null $callback A PHP callback to run whenever there is some
* output available on STDOUT or STDERR * output available on STDOUT or STDERR
* *
* @throws RuntimeException When process can't be launched * @throws ProcessStartFailedException When process can't be launched
* @throws RuntimeException When process is already running * @throws RuntimeException When process is already running
* *
* @see start() * @see start()
* *
* @final * @final
*/ */
public function restart(callable $callback = null, array $env = []): static public function restart(?callable $callback = null, array $env = []): static
{ {
if ($this->isRunning()) { if ($this->isRunning()) {
throw new RuntimeException('Process is already running.'); throw new RuntimeException('Process is already running.');
@ -407,7 +432,7 @@ class Process implements \IteratorAggregate
* @throws ProcessSignaledException When process stopped after receiving signal * @throws ProcessSignaledException When process stopped after receiving signal
* @throws LogicException When process is not yet started * @throws LogicException When process is not yet started
*/ */
public function wait(callable $callback = null): int public function wait(?callable $callback = null): int
{ {
$this->requireProcessIsStarted(__FUNCTION__); $this->requireProcessIsStarted(__FUNCTION__);
@ -880,7 +905,7 @@ class Process implements \IteratorAggregate
* *
* @return int|null The exit-code of the process or null if it's not running * @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): ?int public function stop(float $timeout = 10, ?int $signal = null): ?int
{ {
$timeoutMicro = microtime(true) + $timeout; $timeoutMicro = microtime(true) + $timeout;
if ($this->isRunning()) { if ($this->isRunning()) {
@ -950,7 +975,7 @@ class Process implements \IteratorAggregate
*/ */
public function getCommandLine(): string public function getCommandLine(): string
{ {
return \is_array($this->commandline) ? implode(' ', array_map($this->escapeArgument(...), $this->commandline)) : $this->commandline; return $this->buildShellCommandline($this->commandline);
} }
/** /**
@ -1119,7 +1144,7 @@ class Process implements \IteratorAggregate
* *
* This content will be passed to the underlying process standard input. * 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 * @return $this
* *
@ -1142,11 +1167,9 @@ class Process implements \IteratorAggregate
* In case you run a background process (with the start method), you should * In case you run a background process (with the start method), you should
* trigger this method regularly to ensure the process timeout * trigger this method regularly to ensure the process timeout
* *
* @return void
*
* @throws ProcessTimedOutException In case the timeout was reached * @throws ProcessTimedOutException In case the timeout was reached
*/ */
public function checkTimeout() public function checkTimeout(): void
{ {
if (self::STATUS_STARTED !== $this->status) { if (self::STATUS_STARTED !== $this->status) {
return; return;
@ -1184,10 +1207,8 @@ class Process implements \IteratorAggregate
* *
* Enabling the "create_new_console" option allows a subprocess to continue * Enabling the "create_new_console" option allows a subprocess to continue
* to run after the main process exited, on both Windows and *nix * to run after the main process exited, on both Windows and *nix
*
* @return void
*/ */
public function setOptions(array $options) public function setOptions(array $options): void
{ {
if ($this->isRunning()) { if ($this->isRunning()) {
throw new RuntimeException('Setting options while the process is running is not possible.'); throw new RuntimeException('Setting options while the process is running is not possible.');
@ -1205,6 +1226,20 @@ class Process implements \IteratorAggregate
} }
} }
/**
* 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. * Returns whether TTY is supported on the current operating system.
*/ */
@ -1212,7 +1247,7 @@ class Process implements \IteratorAggregate
{ {
static $isTtySupported; static $isTtySupported;
return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT)); return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT) && @is_writable('/dev/tty'));
} }
/** /**
@ -1236,15 +1271,15 @@ class Process implements \IteratorAggregate
/** /**
* Creates the descriptors needed by the proc_open. * Creates the descriptors needed by the proc_open.
*/ */
private function getDescriptors(): array private function getDescriptors(bool $hasCallback): array
{ {
if ($this->input instanceof \Iterator) { if ($this->input instanceof \Iterator) {
$this->input->rewind(); $this->input->rewind();
} }
if ('\\' === \DIRECTORY_SEPARATOR) { if ('\\' === \DIRECTORY_SEPARATOR) {
$this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $this->hasCallback); $this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $hasCallback);
} else { } 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(); return $this->processPipes->getDescriptors();
@ -1258,7 +1293,7 @@ class Process implements \IteratorAggregate
* *
* @param callable|null $callback The user defined PHP callback * @param callable|null $callback The user defined PHP callback
*/ */
protected function buildCallback(callable $callback = null): \Closure protected function buildCallback(?callable $callback = null): \Closure
{ {
if ($this->outputDisabled) { if ($this->outputDisabled) {
return fn ($type, $data): bool => null !== $callback && $callback($type, $data); return fn ($type, $data): bool => null !== $callback && $callback($type, $data);
@ -1281,10 +1316,8 @@ class Process implements \IteratorAggregate
* Updates the status of the process, reads pipes. * Updates the status of the process, reads pipes.
* *
* @param bool $blocking Whether to use a blocking read call * @param bool $blocking Whether to use a blocking read call
*
* @return void
*/ */
protected function updateStatus(bool $blocking) protected function updateStatus(bool $blocking): void
{ {
if (self::STATUS_STARTED !== $this->status) { if (self::STATUS_STARTED !== $this->status) {
return; return;
@ -1293,6 +1326,19 @@ class Process implements \IteratorAggregate
$this->processInformation = proc_get_status($this->process); $this->processInformation = proc_get_status($this->process);
$running = $this->processInformation['running']; $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;
}
}
$this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running); $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running);
if ($this->fallbackStatus && $this->isSigchildEnabled()) { if ($this->fallbackStatus && $this->isSigchildEnabled()) {
@ -1388,8 +1434,9 @@ class Process implements \IteratorAggregate
private function close(): int private function close(): int
{ {
$this->processPipes->close(); $this->processPipes->close();
if (\is_resource($this->process)) { if ($this->process) {
proc_close($this->process); proc_close($this->process);
$this->process = null;
} }
$this->exitcode = $this->processInformation['exitcode']; $this->exitcode = $this->processInformation['exitcode'];
$this->status = self::STATUS_TERMINATED; $this->status = self::STATUS_TERMINATED;
@ -1421,7 +1468,7 @@ class Process implements \IteratorAggregate
$this->callback = null; $this->callback = null;
$this->exitcode = null; $this->exitcode = null;
$this->fallbackStatus = []; $this->fallbackStatus = [];
$this->processInformation = null; $this->processInformation = [];
$this->stdout = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); $this->stdout = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+');
$this->stderr = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); $this->stderr = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+');
$this->process = null; $this->process = null;
@ -1443,6 +1490,11 @@ class Process implements \IteratorAggregate
*/ */
private function doSignal(int $signal, bool $throwException): bool 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 (null === $pid = $this->getPid()) {
if ($throwException) { if ($throwException) {
throw new LogicException('Cannot send signal on a non running process.'); throw new LogicException('Cannot send signal on a non running process.');
@ -1485,8 +1537,18 @@ class Process implements \IteratorAggregate
return true; return true;
} }
private function prepareWindowsCommandLine(string $cmd, array &$env): string private function buildShellCommandline(string|array $commandline): string
{ {
if (\is_string($commandline)) {
return $commandline;
}
return implode(' ', array_map($this->escapeArgument(...), $commandline));
}
private function prepareWindowsCommandLine(string|array $cmd, array &$env): string
{
$cmd = $this->buildShellCommandline($cmd);
$uid = uniqid('', true); $uid = uniqid('', true);
$cmd = preg_replace_callback( $cmd = preg_replace_callback(
'/"(?:( '/"(?:(

View file

@ -43,9 +43,6 @@ class ProcessUtils
if (\is_resource($input)) { if (\is_resource($input)) {
return $input; return $input;
} }
if (\is_string($input)) {
return $input;
}
if (\is_scalar($input)) { if (\is_scalar($input)) {
return (string) $input; return (string) $input;
} }

View file

@ -1 +1 @@
6.3.4 7.1.3