File tree

6 files changed

+203
-1
lines changed

6 files changed

+203
-1
lines changed
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

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 <[email protected]>
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 <[email protected]>
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 && (0 >= $this->loopMax || ++$loops < $this->loopMax));
65+
66+
return 0;
67+
}
68+
}
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 s
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"
@@ -73,7 +75,7 @@ class SymfonyRuntime extends GenericRuntime
7375
private readonly Command $command;
7476

7577
/**
76-
* @param array {
78+
* @param array{
7779
* debug?: ?bool,
7880
* env?: ?string,
7981
* disable_dotenv?: ?bool,
@@ -88,6 +90,7 @@ class SymfonyRuntime extends GenericRuntime
8890
* debug_var_name?: string,
8991
* dotenv_overload?: ?bool,
9092
* dotenv_extra_paths?: ?string[],
93+
* worker_loop_max?: int, // Use 0 or a negative integer to never restart the worker. Default: 500
9194
* } $options
9295
*/
9396
public function __construct(array $options = [])
@@ -143,12 +146,23 @@ public function __construct(array $options = [])
143146

144147
$options['error_handler'] ??= SymfonyErrorHandler::class;
145148

149+
$workerLoopMax = $options['worker_loop_max'] ?? $_SERVER['FRANKENPHP_LOOP_MAX'] ?? $_ENV['FRANKENPHP_LOOP_MAX'] ?? null;
150+
if (null !== $workerLoopMax && null === filter_var($workerLoopMax, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE)) {
151+
throw new \LogicException(\sprintf('The "worker_loop_max" runtime option must be an integer, "%s" given.', get_debug_type($workerLoopMax)));
152+
}
153+
154+
$options['worker_loop_max'] = (int) ($workerLoopMax ?? 500);
155+
146156
parent::__construct($options);
147157
}
148158

149159
public function getRunner(?object $application): RunnerInterface
150160
{
151161
if ($application instanceof HttpKernelInterface) {
162+
if ($_SERVER['FRANKENPHP_WORKER'] ?? false) {
163+
return new FrankenPhpWorkerRunner($application, $this->options['worker_loop_max']);
164+
}
165+
152166
return new HttpKernelRunner($application, Request::createFromGlobals(), $this->options['debug'] ?? false);
153167
}
154168

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
class FrankenPhpWorkerRunnerTest extends TestCase
28+
{
29+
public function testRun()
30+
{
31+
$application = $this->createMock(TestAppInterface::class);
32+
$application
33+
->expects($this->once())
34+
->method('handle')
35+
->willReturnCallback(function (Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response {
36+
$this->assertSame('bar', $request->server->get('FOO'));
37+
38+
return new Response();
39+
});
40+
$application->expects($this->once())->method('terminate');
41+
42+
$_SERVER['FOO'] = 'bar';
43+
44+
$runner = new FrankenPhpWorkerRunner($application, 500);
45+
$this->assertSame(0, $runner->run());
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
class SymfonyRuntimeTest extends TestCase
20+
{
21+
public function testGetRunner()
22+
{
23+
$application = $this->createStub(HttpKernelInterface::class);
24+
25+
$runtime = new SymfonyRuntime();
26+
$this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner(null));
27+
$this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application));
28+
29+
$_SERVER['FRANKENPHP_WORKER'] = 1;
30+
$this->assertInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application));
31+
}
32+
33+
public function testStringWorkerMaxLoopThrows()
34+
{
35+
$this->expectException(\LogicException::class);
36+
$this->expectExceptionMessage('The "worker_loop_max" runtime option must be an integer, "string" given.');
37+
38+
new SymfonyRuntime(['worker_loop_max' => 'foo']);
39+
}
40+
41+
public function testBoolWorkerMaxLoopThrows()
42+
{
43+
$this->expectException(\LogicException::class);
44+
$this->expectExceptionMessage('The "worker_loop_max" runtime option must be an integer, "bool" given.');
45+
46+
new SymfonyRuntime(['worker_loop_max' => false]);
47+
}
48+
}
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 <[email protected]>
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)