diff --git a/src/Symfony/Component/Process/CHANGELOG.md b/src/Symfony/Component/Process/CHANGELOG.md index d6ec2032ce58f..e26819b5bca17 100644 --- a/src/Symfony/Component/Process/CHANGELOG.md +++ b/src/Symfony/Component/Process/CHANGELOG.md @@ -4,6 +4,8 @@ CHANGELOG 6.4 --- + * Add `PhpSubprocess` to handle PHP subprocesses that take over the + configuration from their parent * Add `RunProcessMessage` and `RunProcessMessageHandler` * Support using `Process::findExecutable()` independently of `open_basedir` diff --git a/src/Symfony/Component/Process/PhpSubprocess.php b/src/Symfony/Component/Process/PhpSubprocess.php new file mode 100644 index 0000000000000..5467e9ba52172 --- /dev/null +++ b/src/Symfony/Component/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 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 = []) + { + 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)) { + $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/Symfony/Component/Process/Tests/Fixtures/memory.php b/src/Symfony/Component/Process/Tests/Fixtures/memory.php new file mode 100644 index 0000000000000..1d3b248700afc --- /dev/null +++ b/src/Symfony/Component/Process/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; + +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/src/Symfony/Component/Process/Tests/PhpSubprocessTest.php b/src/Symfony/Component/Process/Tests/PhpSubprocessTest.php new file mode 100644 index 0000000000000..56b32ae805429 --- /dev/null +++ b/src/Symfony/Component/Process/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::getCurrentMemoryLimit(), + ]; + + yield 'PhpSubprocess does not ignore dynamic memory_limit' => [ + 'PhpSubprocess', + self::getRandomMemoryLimit(), + self::getRandomMemoryLimit(), + ]; + } + + private static function getCurrentMemoryLimit(): string + { + return trim(\ini_get('memory_limit')); + } + + 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::getCurrentMemoryLimit()) { + ++$memoryLimit; + } + + return $formatted; + } +}