Skip to content

Commit fbf64c4

Browse files
[Runtime] Automatically use FrankenPHP runner when its worker mode is detected
1 parent cb08480 commit fbf64c4

File tree

6 files changed

+228
-1
lines changed

6 files changed

+228
-1
lines changed

src/Symfony/Component/Runtime/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `FrankenPhpWorkerRunner`
8+
* Add automatic detection of FrankenPHP worker mode in `SymfonyRuntime`
9+
410
6.4
511
---
612

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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\Runtime\Runner;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpKernel\HttpKernelInterface;
16+
use Symfony\Component\HttpKernel\TerminableInterface;
17+
use Symfony\Component\Runtime\RunnerInterface;
18+
19+
/**
20+
* A runner for FrankenPHP in worker mode.
21+
*
22+
* @author Kévin Dunglas <kevin@dunglas.dev>
23+
*/
24+
class FrankenPhpWorkerRunner implements RunnerInterface
25+
{
26+
public function __construct(
27+
private HttpKernelInterface $kernel,
28+
private int $loopMax,
29+
) {
30+
}
31+
32+
public function run(): int
33+
{
34+
// Prevent worker script termination when a client connection is interrupted
35+
ignore_user_abort(true);
36+
37+
$server = array_filter($_SERVER, static fn (string $key) => !str_starts_with($key, 'HTTP_'), ARRAY_FILTER_USE_KEY);
38+
$server['APP_RUNTIME_MODE'] = 'web=1&worker=1';
39+
40+
$handler = function () use ($server, &$sfRequest, &$sfResponse): void {
41+
// Connect to the Xdebug client if it's available
42+
if (\extension_loaded('xdebug') && \function_exists('xdebug_connect_to_client')) {
43+
xdebug_connect_to_client();
44+
}
45+
46+
// Merge the environment variables coming from DotEnv with the ones tied to the current request
47+
$_SERVER += $server;
48+
49+
$sfRequest = Request::createFromGlobals();
50+
$sfResponse = $this->kernel->handle($sfRequest);
51+
52+
$sfResponse->send();
53+
};
54+
55+
$loops = 0;
56+
do {
57+
$ret = \frankenphp_handle_request($handler);
58+
59+
if ($this->kernel instanceof TerminableInterface && $sfRequest && $sfResponse) {
60+
$this->kernel->terminate($sfRequest, $sfResponse);
61+
}
62+
63+
gc_collect_cycles();
64+
} while ($ret && (-1 === $this->loopMax || ++$loops < $this->loopMax));
65+
66+
return 0;
67+
}
68+
}

src/Symfony/Component/Runtime/SymfonyRuntime.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\Component\HttpKernel\HttpKernelInterface;
2424
use Symfony\Component\Runtime\Internal\MissingDotenv;
2525
use Symfony\Component\Runtime\Internal\SymfonyErrorHandler;
26+
use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner;
2627
use Symfony\Component\Runtime\Runner\Symfony\ConsoleApplicationRunner;
2728
use Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner;
2829
use Symfony\Component\Runtime\Runner\Symfony\ResponseRunner;
@@ -42,6 +43,7 @@ class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || clas
4243
* - "use_putenv" to tell Dotenv to set env vars using putenv() (NOT RECOMMENDED.)
4344
* - "dotenv_overload" to tell Dotenv to override existing vars
4445
* - "dotenv_extra_paths" to define a list of additional dot-env files
46+
* - "worker_loop_max" to define the number of requests after which the worker must restart to prevent memory leaks
4547
*
4648
* When the "debug" / "env" options are not defined, they will fallback to the
4749
* "APP_DEBUG" / "APP_ENV" environment variables, and to the "--env|-e" / "--no-debug"
@@ -64,6 +66,7 @@ class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || clas
6466
* - int|string|null as handled by GenericRuntime.
6567
*
6668
* @author Nicolas Grekas <p@tchwork.com>
69+
* @author Kévin Dunglas <kevin@dunglas.dev>
6770
*/
6871
class SymfonyRuntime extends GenericRuntime
6972
{
@@ -73,7 +76,7 @@ class SymfonyRuntime extends GenericRuntime
7376
private readonly Command $command;
7477

7578
/**
76-
* @param array {
79+
* @param array{
7780
* debug?: ?bool,
7881
* env?: ?string,
7982
* disable_dotenv?: ?bool,
@@ -88,6 +91,7 @@ class SymfonyRuntime extends GenericRuntime
8891
* debug_var_name?: string,
8992
* dotenv_overload?: ?bool,
9093
* dotenv_extra_paths?: ?string[],
94+
* worker_loop_max?: positive-int, // Default: 500
9195
* } $options
9296
*/
9397
public function __construct(array $options = [])
@@ -143,12 +147,23 @@ public function __construct(array $options = [])
143147

144148
$options['error_handler'] ??= SymfonyErrorHandler::class;
145149

150+
$workerLoopMax = $options['worker_loop_max'] ?? $_SERVER['FRANKENPHP_LOOP_MAX'] ?? $_ENV['FRANKENPHP_LOOP_MAX'] ?? null;
151+
if (null !== $workerLoopMax && (!\is_numeric($workerLoopMax) || $workerLoopMax <= 0)) {
152+
throw new \LogicException(\sprintf('The "worker_loop_max" runtime option must be a positive integer, "%s" given.', $workerLoopMax));
153+
}
154+
155+
$options['worker_loop_max'] = (int) ($workerLoopMax ?? 500);
156+
146157
parent::__construct($options);
147158
}
148159

149160
public function getRunner(?object $application): RunnerInterface
150161
{
151162
if ($application instanceof HttpKernelInterface) {
163+
if ($_SERVER['FRANKENPHP_WORKER'] ?? false) {
164+
return new FrankenPhpWorkerRunner($application, $this->options['worker_loop_max']);
165+
}
166+
152167
return new HttpKernelRunner($application, Request::createFromGlobals(), $this->options['debug'] ?? false);
153168
}
154169

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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\Runtime\Tests;
13+
14+
require_once __DIR__.'/frankenphp-function-mock.php';
15+
16+
use PHPUnit\Framework\TestCase;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpFoundation\Response;
19+
use Symfony\Component\HttpKernel\HttpKernelInterface;
20+
use Symfony\Component\HttpKernel\TerminableInterface;
21+
use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner;
22+
23+
interface TestAppInterface extends HttpKernelInterface, TerminableInterface
24+
{
25+
}
26+
27+
/**
28+
* @author Kévin Dunglas <kevin@dunglas.fr>
29+
*/
30+
class FrankenPhpWorkerRunnerTest extends TestCase
31+
{
32+
public function testRun()
33+
{
34+
$application = $this->createMock(TestAppInterface::class);
35+
$application
36+
->expects($this->once())
37+
->method('handle')
38+
->willReturnCallback(function (Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response {
39+
$this->assertSame('bar', $request->server->get('FOO'));
40+
41+
return new Response();
42+
});
43+
$application->expects($this->once())->method('terminate');
44+
45+
$_SERVER['FOO'] = 'bar';
46+
47+
$runner = new FrankenPhpWorkerRunner($application, 500);
48+
$this->assertSame(0, $runner->run());
49+
}
50+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\Runtime\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpKernel\HttpKernelInterface;
16+
use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner;
17+
use Symfony\Component\Runtime\SymfonyRuntime;
18+
19+
/**
20+
* @author Kévin Dunglas <kevin@dunglas.dev>
21+
* @author Alexandre Daubois <alex.daubois@gmail.com>
22+
*/
23+
class SymfonyRuntimeTest extends TestCase
24+
{
25+
public function testGetRunner()
26+
{
27+
$application = $this->createStub(HttpKernelInterface::class);
28+
29+
$runtime = new SymfonyRuntime();
30+
$this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner(null));
31+
$this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application));
32+
33+
$_SERVER['FRANKENPHP_WORKER'] = 1;
34+
$this->assertInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application));
35+
}
36+
37+
public function testStringWorkerMaxLoopThrows()
38+
{
39+
$this->expectException(\LogicException::class);
40+
$this->expectExceptionMessage('The "worker_loop_max" runtime option must be a positive integer, "foo" given.');
41+
42+
new SymfonyRuntime(['worker_loop_max' => 'foo']);
43+
}
44+
45+
public function testNegativeWorkerMaxLoopThrows()
46+
{
47+
$this->expectException(\LogicException::class);
48+
$this->expectExceptionMessage('The "worker_loop_max" runtime option must be a positive integer, "-1" given.');
49+
50+
new SymfonyRuntime(['worker_loop_max' => -1]);
51+
}
52+
53+
public function testZeroWorkerMaxLoopThrows()
54+
{
55+
$this->expectException(\LogicException::class);
56+
$this->expectExceptionMessage('The "worker_loop_max" runtime option must be a positive integer, "0" given.');
57+
58+
new SymfonyRuntime(['worker_loop_max' => 0]);
59+
}
60+
61+
public function testInvalidWorkerMaxLoopsThroughEnvVarThrows()
62+
{
63+
$this->expectException(\LogicException::class);
64+
$this->expectExceptionMessage('The "worker_loop_max" runtime option must be a positive integer, "-1" given.');
65+
66+
$_SERVER['FRANKENPHP_LOOP_MAX'] = '-1';
67+
new SymfonyRuntime();
68+
}
69+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
if (!function_exists('frankenphp_handle_request')) {
13+
function frankenphp_handle_request(callable $callable): bool
14+
{
15+
$callable();
16+
17+
return false;
18+
}
19+
}

0 commit comments

Comments
 (0)