Skip to content

Commit 9364391

Browse files
committed
Introducing a new PhpSubprocess handler
1 parent 08b93ad commit 9364391

File tree

5 files changed

+271
-0
lines changed

5 files changed

+271
-0
lines changed

src/Symfony/Component/Process/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ CHANGELOG
44
6.4
55
---
66

7+
* Add `PhpSubprocess` to handle PHP subprocesses that take over the
8+
configuration from their parent
79
* Add `RunProcessMessage` and `RunProcessMessageHandler`
810
* Support using `Process::findExecutable()` independently of `open_basedir`
911

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Process;
13+
14+
use Symfony\Component\Process\Exception\LogicException;
15+
use Symfony\Component\Process\Exception\RuntimeException;
16+
17+
/**
18+
* PhpSubprocess runs a PHP command as a subprocess while keeping the original php.ini settings.
19+
*
20+
* For this, it generates a temporary php.ini file taking over all the current settings and disables
21+
* loading additional .ini files. Basically, your command gets prefixed using "php -n -c /tmp/temp.ini".
22+
*
23+
* Given your php.ini contains "memory_limit=-1" and you have a "MemoryTest.php" with the following content:
24+
*
25+
* <?php var_dump(ini_get('memory_limit'));
26+
*
27+
* These are the differences between the regular Process and PhpSubprocess classes:
28+
*
29+
* $p = new Process(['php', '-d', 'memory_limit=256M', 'MemoryTest.php']);
30+
* $p->run();
31+
* print $p->getOutput()."\n";
32+
*
33+
* This will output "string(2) "-1", because the process is started with the default php.ini settings.
34+
*
35+
* $p = new PhpSubprocess(['MemoryTest.php'], null, null, 60, ['php', '-d', 'memory_limit=256M']);
36+
* $p->run();
37+
* print $p->getOutput()."\n";
38+
*
39+
* This will output "string(4) "256M"", because the process is started with the temporarily created php.ini settings.
40+
*
41+
* @author Yanick Witschi <yanick.witschi@terminal42.ch>
42+
* @author Partially copied and heavily inspired from composer/xdebug-handler by John Stevenson <john-stevenson@blueyonder.co.uk>
43+
*/
44+
class PhpSubprocess extends Process
45+
{
46+
/**
47+
* @param array $command The command to run and its arguments listed as separate entries. They will automatically
48+
* get prefixed with the PHP binary
49+
* @param string|null $cwd The working directory or null to use the working dir of the current PHP process
50+
* @param array|null $env The environment variables or null to use the same environment as the current PHP process
51+
* @param int $timeout The timeout in seconds
52+
* @param array|null $php Path to the PHP binary to use with any additional arguments
53+
*/
54+
public function __construct(array $command, string $cwd = null, array $env = null, int $timeout = 60, array $php = null)
55+
{
56+
if (null === $php) {
57+
$executableFinder = new PhpExecutableFinder();
58+
$php = $executableFinder->find(false);
59+
$php = false === $php ? null : array_merge([$php], $executableFinder->findArguments());
60+
}
61+
62+
$tmpIni = $this->writeTmpIni($this->getAllIniFiles(), sys_get_temp_dir());
63+
64+
if ($tmpIni) {
65+
$php = array_merge($php, ['-n', '-c', $tmpIni]);
66+
register_shutdown_function('unlink', $tmpIni);
67+
}
68+
69+
$command = array_merge($php, $command);
70+
71+
parent::__construct($command, $cwd, $env, null, $timeout);
72+
}
73+
74+
public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, mixed $input = null, ?float $timeout = 60): static
75+
{
76+
throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class));
77+
}
78+
79+
public function start(callable $callback = null, array $env = [])
80+
{
81+
if (null === $this->getCommandLine()) {
82+
throw new RuntimeException('Unable to find the PHP executable.');
83+
}
84+
85+
parent::start($callback, $env);
86+
}
87+
88+
private function writeTmpIni(array $iniFiles, string $tmpDir): string
89+
{
90+
if (false === $tmpfile = @tempnam($tmpDir, '')) {
91+
throw new RuntimeException('Unable to create temporary ini file.');
92+
}
93+
94+
// $iniFiles has at least one item and it may be empty
95+
if ('' === $iniFiles[0]) {
96+
array_shift($iniFiles);
97+
}
98+
99+
$content = '';
100+
101+
foreach ($iniFiles as $file) {
102+
// Check for inaccessible ini files
103+
if (($data = @file_get_contents($file)) === false) {
104+
throw new RuntimeException('Unable to read ini: '.$file);
105+
}
106+
// Check and remove directives after HOST and PATH sections
107+
if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches)) {
108+
$data = substr($data, 0, $matches[0][1]);
109+
}
110+
111+
$content .= $data."\n";
112+
}
113+
114+
// Merge loaded settings into our ini content, if it is valid
115+
$config = parse_ini_string($content);
116+
$loaded = ini_get_all(null, false);
117+
118+
if (false === $config || false === $loaded) {
119+
throw new RuntimeException('Unable to parse ini data.');
120+
}
121+
122+
$content .= $this->mergeLoadedConfig($loaded, $config);
123+
124+
// Work-around for https://bugs.php.net/bug.php?id=75932
125+
$content .= "opcache.enable_cli=0\n";
126+
127+
if (false === @file_put_contents($tmpfile, $content)) {
128+
throw new RuntimeException('Unable to write temporary ini file.');
129+
}
130+
131+
return $tmpfile;
132+
}
133+
134+
private function mergeLoadedConfig(array $loadedConfig, array $iniConfig): string
135+
{
136+
$content = '';
137+
138+
foreach ($loadedConfig as $name => $value) {
139+
// Value will either be null, string or array (HHVM only)
140+
if (!\is_string($value)) {
141+
continue;
142+
}
143+
144+
if (!isset($iniConfig[$name]) || $iniConfig[$name] !== $value) {
145+
// Double-quote escape each value
146+
$content .= $name.'="'.addcslashes($value, '\\"')."\"\n";
147+
}
148+
}
149+
150+
return $content;
151+
}
152+
153+
private function getAllIniFiles(): array
154+
{
155+
$paths = [(string) php_ini_loaded_file()];
156+
157+
if (false !== $scanned = php_ini_scanned_files()) {
158+
$paths = array_merge($paths, array_map('trim', explode(',', $scanned)));
159+
}
160+
161+
return $paths;
162+
}
163+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php
2+
3+
echo ini_get('memory_limit');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Process\Tests;
13+
14+
use Symfony\Component\Process\PhpSubprocess;
15+
use Symfony\Component\Process\Process;
16+
17+
require is_file(\dirname(__DIR__).'/vendor/autoload.php') ? \dirname(__DIR__).'/vendor/autoload.php' : \dirname(__DIR__, 5).'/vendor/autoload.php';
18+
19+
['e' => $php, 'p' => $process] = getopt('e:p:') + ['e' => 'php', 'p' => 'Process'];
20+
21+
if ('Process' === $process) {
22+
$p = new Process([$php, __DIR__.'/Fixtures/memory.php']);
23+
} else {
24+
$p = new PhpSubprocess([__DIR__.'/Fixtures/memory.php'], null, null, 60, [$php]);
25+
}
26+
27+
$p->mustRun();
28+
echo $p->getOutput();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Process\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Process\PhpExecutableFinder;
16+
use Symfony\Component\Process\Process;
17+
18+
class PhpSubprocessTest extends TestCase
19+
{
20+
private static $phpBin;
21+
22+
public static function setUpBeforeClass(): void
23+
{
24+
$phpBin = new PhpExecutableFinder();
25+
self::$phpBin = getenv('SYMFONY_PROCESS_PHP_TEST_BINARY') ?: ('phpdbg' === \PHP_SAPI ? 'php' : $phpBin->find());
26+
}
27+
28+
/**
29+
* @dataProvider subprocessProvider
30+
*/
31+
public function testSubprocess(string $processClass, string $memoryLimit, string $expectedMemoryLimit)
32+
{
33+
$process = new Process([self::$phpBin,
34+
'-d',
35+
'memory_limit='.$memoryLimit,
36+
__DIR__.'/OutputMemoryLimitProcess.php',
37+
'-e', self::$phpBin,
38+
'-p', $processClass,
39+
]);
40+
41+
$process->mustRun();
42+
$this->assertEquals($expectedMemoryLimit, trim($process->getOutput()));
43+
}
44+
45+
public static function subprocessProvider(): \Generator
46+
{
47+
yield 'Process does ignore dynamic memory_limit' => [
48+
'Process',
49+
self::getRandomMemoryLimit(),
50+
self::getCurrentMemoryLimit(),
51+
];
52+
53+
yield 'PhpSubprocess does not ignore dynamic memory_limit' => [
54+
'PhpSubprocess',
55+
self::getRandomMemoryLimit(),
56+
self::getRandomMemoryLimit(),
57+
];
58+
}
59+
60+
private static function getCurrentMemoryLimit(): string
61+
{
62+
return trim(\ini_get('memory_limit'));
63+
}
64+
65+
private static function getRandomMemoryLimit(): string
66+
{
67+
$memoryLimit = 123; // Take something that's really unlikely to be configured on a user system.
68+
69+
while (($formatted = $memoryLimit.'M') === self::getCurrentMemoryLimit()) {
70+
++$memoryLimit;
71+
}
72+
73+
return $formatted;
74+
}
75+
}

0 commit comments

Comments
 (0)