8000 feature #48485 [Process] Introducing a new `PhpSubprocess` handler (T… · l-vo/symfony@0ba946a · GitHub
[go: up one dir, main page]

Skip to content

Commit 0ba946a

Browse files
feature symfony#48485 [Process] Introducing a new PhpSubprocess handler (Toflar)
This PR was merged into the 6.4 branch. Discussion ---------- [Process] Introducing a new `PhpSubprocess` handler | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | | License | MIT | Doc PR | will provide as soon as the API is clear This PR provides a new `PhpSubprocess` handler which can be used to run PHP commands and taking over their parent PHP configuration. This is not super easy in PHP because the only way to do that requires a temporary `ini` file. That's why I see perfect fit for it in the `symfony/process` component as it solves a common issue. The code is heavily inspired/copied from `composer/xdebug-handler` where the requirement is similar - being able to have a child process/subprocess that takes over the config of the parent process. Hence, mentioning `@Seldaek` here. Basically, this can be very useful in Symfony when you want to run `bin/console other-command` from within Symfony. Let's imagine a general cronjob command that is supposed to delegate to other commands like this (simplified a lot): ```php #[AsCommand(name: 'run-cronjobs')] class RunCronJobsCommand extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { $cron1 = new Process(['bin/console', 'cache:pool:prune']); $cron2 = new Process(['bin/console', 'something:else']); // etc. } } ``` Now if you run the cronjob command itself with dynamic configuration like so: `php -d memory_limit=256M bin/console run-cronjobs`, the subprocesses (`cache:pool:prune` etc.) will not be limited to `256M` because they themselves will start a completely new PHP process which will take the default PHP settings again. This affects everything, not just `memory_limit` but also runtime, extensions etc. You name it. Oftentimes, however, that's not what you want. You'd like to have the children have the same restrictions as your parent process. With this new handler, all you have to do is to use `PhpSubprocess` instead of `Process` and it will create a temporary `ini` file taking over all the settings of the parent process. Done 😊 The current design is pretty straightforward without any extension points just yet (no way to fetch the `ini` files separately etc.). Also, it could maybe do with some more tests for the `ini` parsing but at this stage I would like to ask if this would even be a welcomed addition to Symfony or not 😊 Commits ------- f682a29 Introducing a new PhpSubprocess handler
2 parents 3c9bf28 + f682a29 commit 0ba946a

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