8000 Introducing a new PhpSubprocess handler · symfony/symfony@f682a29 · GitHub
[go: up one dir, main page]

Skip to content

Commit f682a29

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

File tree

5 files changed

+272
-0
lines changed

5 files changed

+272
-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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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+
if (null === $php) {
63+
throw new RuntimeException('Unable to find PHP binary.');
64+
}
65+
66+
$tmpIni = $this->writeTmpIni($this->getAllIniFiles(), sys_get_temp_dir());
67+
68+
$php = array_merge($php, ['-n', '-c', $tmpIni]);
69+
register_shutdown_function('unlink', $tmpIni);
70+
71+
$command = array_merge($php, $command);
72+
73+
parent::__construct($command, $cwd, $env, null, $timeout);
74+
}
75+
76+
public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, mixed $input = null, ?float $timeout = 60): static
77+
{
78+
throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class));
79+
}
80+
81+
public function start(callable $callback = null, array $env = [])
82+
{
83+
if (null === $this->getCommandLine()) {
84+
throw new RuntimeException('Unable to find the PHP executable.');
85+
}
86+
87+
parent::start($callback, $env);
88+
}
89+
90+
private function writeTmpIni(array $iniFiles, string $tmpDir): string
91+
{
92+
if (false === $tmpfile = @tempnam($tmpDir, '')) {
93+
throw new RuntimeException('Unable to create temporary ini file.');
94+
}
95+
96+
// $iniFiles has at least one item and it may be empty
97+
if ('' === $iniFiles[0]) {
98+
array_shift($iniFiles);
99+
}
100+
101+
$content = '';
102+
103+
foreach ($iniFiles as $file) {
104+
// Check for inaccessible ini files
105+
if (($data = @file_get_contents($file)) === false) {
106+
throw new RuntimeException('Unable to read ini: '.$file);
107+
}
108+
// Check and remove directives after HOST and PATH sections
109+
if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches)) {
110+
$data = substr($data, 0, $matches[0][1]);
111+
}
112+
113+
$content .= $data."\n";
114+
}
115+
116+
// Merge loaded settings into our ini content, if it is valid
117+
$config = parse_ini_string($content);
118+
$loaded = ini_get_all(null, false);
119+
120+
if (false === $config || false === $loaded) {
121+
throw new RuntimeException('Unable to parse ini data.');
122+
}
123+
124+
$content .= $this->mergeLoadedConfig($loaded, $config);
125+
126+
// Work-around for https://bugs.php.net/bug.php?id=75932
127+
$content .= "opcache.enable_cli=0\n";
128+
129+
if (false === @file_put_contents($tmpfile, $content)) {
130+
throw new RuntimeException('Unable to write temporary ini file.');
131+
}
132+
133+
return $tmpfile;
134+
}
135+
136+
private function mergeLoadedConfig(array $loadedConfig, array $iniConfig): string
137+
{
138+
$content = '';
139+
140+
foreach ($loadedConfig as $name => $value) {
141+
if (!\is_string($value)) {
142+
continue;
143+
}
144+
145+
if (!isset($iniConfig[$name]) || $iniConfig[$name] !== $value) {
146+
// Double-quote escape each value
147+
$content .= $name.'="'.addcslashes($value, '\\"')."\"\n";
148+
}
149+
}
150+
151+
return $content;
152+
}
153+
154+
private function getAllIniFiles(): array
155+
{
156+
$paths = [(string) php_ini_loaded_file()];
157+
158+
if (false !== $scanned = php_ini_scanned_files()) {
159+
$paths = array_merge($paths, array_map('trim', explode(',', $scanned)));
160+
}
161+
162+
return $paths;
163+
}
164+
}
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)
0