diff --git a/CHANGELOG.md b/CHANGELOG.md index 27033e6..a484867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ This update introduces a refactored code-base, code quality improvements, and be - Refactor project constants handling in NccCompiler - Updated Symfony/Yaml to version 7.1.4 - Updated Symfony/Uid to version 7.1.4 + - Updated Symfony/Process to version 7.1.3 ### Fixed - Fixed Division by zero in PackageManager diff --git a/src/ncc/ThirdParty/Symfony/Process/CHANGELOG.md b/src/ncc/ThirdParty/Symfony/Process/CHANGELOG.md index 31b9ee6..3e33cd0 100644 --- a/src/ncc/ThirdParty/Symfony/Process/CHANGELOG.md +++ b/src/ncc/ThirdParty/Symfony/Process/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/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessFailedException.php b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessFailedException.php index 1a3c997..8a56df1 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessFailedException.php +++ b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessFailedException.php @@ -20,7 +20,7 @@ use ncc\ThirdParty\Symfony\Process\Process; */ class ProcessFailedException extends RuntimeException { - private $process; + private Process $process; public function __construct(Process $process) { @@ -47,10 +47,7 @@ class ProcessFailedException extends RuntimeException $this->process = $process; } - /** - * @return Process - */ - public function getProcess() + public function getProcess(): Process { return $this->process; } diff --git a/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessSignaledException.php b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessSignaledException.php index b14e6ab..9b1efa6 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessSignaledException.php +++ b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessSignaledException.php @@ -20,7 +20,7 @@ use ncc\ThirdParty\Symfony\Process\Process; */ final class ProcessSignaledException extends RuntimeException { - private $process; + private Process $process; public function __construct(Process $process) { diff --git a/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessStartFailedException.php b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessStartFailedException.php new file mode 100644 index 0000000..917d569 --- /dev/null +++ b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessStartFailedException.php @@ -0,0 +1,45 @@ + + * + * 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; + } +} diff --git a/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessTimedOutException.php b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessTimedOutException.php index a3a4fb4..3e2b57a 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessTimedOutException.php +++ b/src/ncc/ThirdParty/Symfony/Process/Exception/ProcessTimedOutException.php @@ -23,8 +23,8 @@ class ProcessTimedOutException extends RuntimeException public const TYPE_GENERAL = 1; public const TYPE_IDLE = 2; - private $process; - private $timeoutType; + private Process $process; + private int $timeoutType; public function __construct(Process $process, int $timeoutType) { @@ -38,26 +38,17 @@ class ProcessTimedOutException extends RuntimeException )); } - /** - * @return Process - */ - public function getProcess() + public function getProcess(): Process { return $this->process; } - /** - * @return bool - */ - public function isGeneralTimeout() + public function isGeneralTimeout(): bool { return self::TYPE_GENERAL === $this->timeoutType; } - /** - * @return bool - */ - public function isIdleTimeout() + public function isIdleTimeout(): bool { return self::TYPE_IDLE === $this->timeoutType; } diff --git a/src/ncc/ThirdParty/Symfony/Process/Exception/RunProcessFailedException.php b/src/ncc/ThirdParty/Symfony/Process/Exception/RunProcessFailedException.php new file mode 100644 index 0000000..016551e --- /dev/null +++ b/src/ncc/ThirdParty/Symfony/Process/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 ncc\ThirdParty\Symfony\Process\Exception; + +use ncc\ThirdParty\Symfony\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/src/ncc/ThirdParty/Symfony/Process/ExecutableFinder.php b/src/ncc/ThirdParty/Symfony/Process/ExecutableFinder.php index 397e8f4..c4dc3fa 100644 --- a/src/ncc/ThirdParty/Symfony/Process/ExecutableFinder.php +++ b/src/ncc/ThirdParty/Symfony/Process/ExecutableFinder.php @@ -19,24 +19,20 @@ namespace ncc\ThirdParty\Symfony\Process; */ class ExecutableFinder { - private $suffixes = ['.exe', '.bat', '.cmd', '.com']; + private array $suffixes = ['.exe', '.bat', '.cmd', '.com']; /** * Replaces default suffixes of executable. - * - * @return void */ - public function setSuffixes(array $suffixes) + public function setSuffixes(array $suffixes): void { $this->suffixes = $suffixes; } /** * Adds new possible suffix to check for executable. - * - * @return void */ - public function addSuffix(string $suffix) + public function addSuffix(string $suffix): void { $this->suffixes[] = $suffix; } @@ -48,27 +44,12 @@ class ExecutableFinder * @param string|null $default The default to return if no executable is found * @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( - explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), - $extraDirs - ); - } + $dirs = array_merge( + explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), + $extraDirs + ); $suffixes = ['']; if ('\\' === \DIRECTORY_SEPARATOR) { @@ -80,9 +61,18 @@ class ExecutableFinder if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($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; } } diff --git a/src/ncc/ThirdParty/Symfony/Process/InputStream.php b/src/ncc/ThirdParty/Symfony/Process/InputStream.php index bed86da..44116ae 100644 --- a/src/ncc/ThirdParty/Symfony/Process/InputStream.php +++ b/src/ncc/ThirdParty/Symfony/Process/InputStream.php @@ -22,19 +22,16 @@ use ncc\ThirdParty\Symfony\Process\Exception\RuntimeException; */ class InputStream implements \IteratorAggregate { - /** @var callable|null */ - private $onEmpty; - 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. - * - * @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, * stream resource or \Traversable - * - * @return void */ - public function write(mixed $input) + public function write(mixed $input): void { if (null === $input) { return; @@ -58,20 +53,16 @@ class InputStream implements \IteratorAggregate /** * Closes the write buffer. - * - * @return void */ - public function close() + public function close(): void { $this->open = false; } /** * Tells whether the write buffer is closed or not. - * - * @return bool */ - public function isClosed() + public function isClosed(): bool { return !$this->open; } diff --git a/src/ncc/ThirdParty/Symfony/Process/Messenger/RunProcessContext.php b/src/ncc/ThirdParty/Symfony/Process/Messenger/RunProcessContext.php new file mode 100644 index 0000000..54cbf9f --- /dev/null +++ b/src/ncc/ThirdParty/Symfony/Process/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 ncc\ThirdParty\Symfony\Process\Messenger; + +use ncc\ThirdParty\Symfony\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/src/ncc/ThirdParty/Symfony/Process/Messenger/RunProcessMessage.php b/src/ncc/ThirdParty/Symfony/Process/Messenger/RunProcessMessage.php new file mode 100644 index 0000000..6db0677 --- /dev/null +++ b/src/ncc/ThirdParty/Symfony/Process/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 ncc\ThirdParty\Symfony\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/src/ncc/ThirdParty/Symfony/Process/Messenger/RunProcessMessageHandler.php b/src/ncc/ThirdParty/Symfony/Process/Messenger/RunProcessMessageHandler.php new file mode 100644 index 0000000..a292f7f --- /dev/null +++ b/src/ncc/ThirdParty/Symfony/Process/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 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 + */ +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/src/ncc/ThirdParty/Symfony/Process/PhpExecutableFinder.php b/src/ncc/ThirdParty/Symfony/Process/PhpExecutableFinder.php index 2fa756c..2f727cd 100644 --- a/src/ncc/ThirdParty/Symfony/Process/PhpExecutableFinder.php +++ b/src/ncc/ThirdParty/Symfony/Process/PhpExecutableFinder.php @@ -19,7 +19,7 @@ namespace ncc\ThirdParty\Symfony\Process; */ class PhpExecutableFinder { - private $executableFinder; + private ExecutableFinder $executableFinder; public function __construct() { @@ -33,8 +33,8 @@ class PhpExecutableFinder { if ($php = getenv('PHP_BINARY')) { if (!is_executable($php)) { - $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v'; - if ($php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { + $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; + if (\function_exists('exec') && $php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { if (!is_executable($php)) { return false; } diff --git a/src/ncc/ThirdParty/Symfony/Process/PhpProcess.php b/src/ncc/ThirdParty/Symfony/Process/PhpProcess.php index 6923a90..ee4b3a4 100644 --- a/src/ncc/ThirdParty/Symfony/Process/PhpProcess.php +++ b/src/ncc/ThirdParty/Symfony/Process/PhpProcess.php @@ -32,7 +32,7 @@ class PhpProcess extends 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(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) { $executableFinder = new PhpExecutableFinder(); @@ -50,15 +50,12 @@ class PhpProcess extends Process 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)); } - /** - * @return void - */ - 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/src/ncc/ThirdParty/Symfony/Process/PhpSubprocess.php b/src/ncc/ThirdParty/Symfony/Process/PhpSubprocess.php new file mode 100644 index 0000000..3d271a9 --- /dev/null +++ b/src/ncc/ThirdParty/Symfony/Process/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 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: + * + * 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/src/ncc/ThirdParty/Symfony/Process/Pipes/AbstractPipes.php b/src/ncc/ThirdParty/Symfony/Process/Pipes/AbstractPipes.php index 283da52..8096b60 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Pipes/AbstractPipes.php +++ b/src/ncc/ThirdParty/Symfony/Process/Pipes/AbstractPipes.php @@ -22,20 +22,19 @@ abstract class AbstractPipes implements PipesInterface { 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(mixed $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; } diff --git a/src/ncc/ThirdParty/Symfony/Process/Pipes/UnixPipes.php b/src/ncc/ThirdParty/Symfony/Process/Pipes/UnixPipes.php index c35f843..c617899 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Pipes/UnixPipes.php +++ b/src/ncc/ThirdParty/Symfony/Process/Pipes/UnixPipes.php @@ -22,9 +22,9 @@ use ncc\ThirdParty\Symfony\Process\Process; */ class UnixPipes extends AbstractPipes { - private $ttyMode; - private $ptyMode; - private $haveReadSupport; + private ?bool $ttyMode; + private bool $ptyMode; + private 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__); } - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } diff --git a/src/ncc/ThirdParty/Symfony/Process/Pipes/WindowsPipes.php b/src/ncc/ThirdParty/Symfony/Process/Pipes/WindowsPipes.php index 65225ae..1dc6dfe 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Pipes/WindowsPipes.php +++ b/src/ncc/ThirdParty/Symfony/Process/Pipes/WindowsPipes.php @@ -26,14 +26,14 @@ use ncc\ThirdParty\Symfony\Process\Process; */ 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; + private bool $haveReadSupport; public function __construct(mixed $input, bool $haveReadSupport) { @@ -93,7 +93,7 @@ class WindowsPipes extends AbstractPipes throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } @@ -140,7 +140,7 @@ class WindowsPipes extends AbstractPipes if ($w) { @stream_select($r, $w, $e, 0, Process::TIMEOUT_PRECISION * 1E6); } elseif ($this->fileHandles) { - usleep(Process::TIMEOUT_PRECISION * 1E6); + usleep((int) (Process::TIMEOUT_PRECISION * 1E6)); } } foreach ($this->fileHandles as $type => $fileHandle) { diff --git a/src/ncc/ThirdParty/Symfony/Process/Process.php b/src/ncc/ThirdParty/Symfony/Process/Process.php index 38e3e8c..fc0cb34 100644 --- a/src/ncc/ThirdParty/Symfony/Process/Process.php +++ b/src/ncc/ThirdParty/Symfony/Process/Process.php @@ -15,9 +15,9 @@ use ncc\ThirdParty\Symfony\Process\Exception\InvalidArgumentException; use ncc\ThirdParty\Symfony\Process\Exception\LogicException; use ncc\ThirdParty\Symfony\Process\Exception\ProcessFailedException; 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\RuntimeException; -use ncc\ThirdParty\Symfony\Process\Pipes\PipesInterface; use ncc\ThirdParty\Symfony\Process\Pipes\UnixPipes; 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_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 ?int $latestSignal = null; + private ?int $cachedExitCode = null; - private static $sigchild; + private static ?bool $sigchild = null; /** * 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', @@ -140,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, 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')) { 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->setTimeout($timeout); - $this->useFileHandles = '\\' === \DIRECTORY_SEPARATOR; $this->pty = false; } @@ -187,7 +189,7 @@ class Process implements \IteratorAggregate * * @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->commandline = $command; @@ -200,7 +202,7 @@ class Process implements \IteratorAggregate throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } @@ -234,15 +236,15 @@ class Process implements \IteratorAggregate * * @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 */ - public function run(callable $callback = null, array $env = []): int + public function run(?callable $callback = null, array $env = []): int { $this->start($callback, $env); @@ -261,7 +263,7 @@ class Process implements \IteratorAggregate * * @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)) { 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 * output available on STDOUT or STDERR * - * @return void - * - * @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.'); @@ -300,8 +300,7 @@ class Process implements \IteratorAggregate $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; @@ -310,29 +309,25 @@ class Process implements \IteratorAggregate $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 = []; @@ -346,11 +341,41 @@ class Process implements \IteratorAggregate 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)) { - throw new RuntimeException('Unable to launch a new process.'); + return true; + }); + + $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])) { @@ -373,14 +398,14 @@ class Process implements \IteratorAggregate * @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 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 = []): static + public function restart(?callable $callback = null, array $env = []): static { if ($this->isRunning()) { throw new RuntimeException('Process is already running.'); @@ -407,7 +432,7 @@ class Process implements \IteratorAggregate * @throws ProcessSignaledException When process stopped after receiving signal * @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__); @@ -880,7 +905,7 @@ class Process implements \IteratorAggregate * * @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; if ($this->isRunning()) { @@ -950,7 +975,7 @@ class Process implements \IteratorAggregate */ 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. * - * @param string|int|float|bool|resource|\Traversable|null $input The content + * @param string|resource|\Traversable|self|null $input The content * * @return $this * @@ -1142,11 +1167,9 @@ class Process implements \IteratorAggregate * In case you run a background process (with the start method), you should * trigger this method regularly to ensure the process timeout * - * @return void - * * @throws ProcessTimedOutException In case the timeout was reached */ - public function checkTimeout() + public function checkTimeout(): void { if (self::STATUS_STARTED !== $this->status) { return; @@ -1184,10 +1207,8 @@ class Process implements \IteratorAggregate * * Enabling the "create_new_console" option allows a subprocess to continue * 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()) { 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. */ @@ -1212,7 +1247,7 @@ class Process implements \IteratorAggregate { 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. */ - 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(); @@ -1258,7 +1293,7 @@ class Process implements \IteratorAggregate * * @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) { 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. * * @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) { return; @@ -1293,6 +1326,19 @@ class Process implements \IteratorAggregate $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; + } + } + $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running); if ($this->fallbackStatus && $this->isSigchildEnabled()) { @@ -1388,8 +1434,9 @@ class Process implements \IteratorAggregate private function close(): int { $this->processPipes->close(); - if (\is_resource($this->process)) { + if ($this->process) { proc_close($this->process); + $this->process = null; } $this->exitcode = $this->processInformation['exitcode']; $this->status = self::STATUS_TERMINATED; @@ -1421,7 +1468,7 @@ class Process implements \IteratorAggregate $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; @@ -1443,6 +1490,11 @@ class Process implements \IteratorAggregate */ 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.'); @@ -1485,8 +1537,18 @@ class Process implements \IteratorAggregate 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); $cmd = preg_replace_callback( '/"(?:( diff --git a/src/ncc/ThirdParty/Symfony/Process/ProcessUtils.php b/src/ncc/ThirdParty/Symfony/Process/ProcessUtils.php index fae0b5f..056539f 100644 --- a/src/ncc/ThirdParty/Symfony/Process/ProcessUtils.php +++ b/src/ncc/ThirdParty/Symfony/Process/ProcessUtils.php @@ -43,9 +43,6 @@ class ProcessUtils if (\is_resource($input)) { return $input; } - if (\is_string($input)) { - return $input; - } if (\is_scalar($input)) { return (string) $input; } diff --git a/src/ncc/ThirdParty/Symfony/Process/VERSION b/src/ncc/ThirdParty/Symfony/Process/VERSION index 9152abb..ad955e9 100644 --- a/src/ncc/ThirdParty/Symfony/Process/VERSION +++ b/src/ncc/ThirdParty/Symfony/Process/VERSION @@ -1 +1 @@ -6.3.4 \ No newline at end of file +7.1.3 \ No newline at end of file