From 5365efa46820a293a2d6996da948167fee961a5f Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Wed, 21 May 2025 17:07:06 +0200 Subject: [PATCH 01/10] [Runtime] Automatically use FrankenPHP runner when its worker mode is detected --- CHANGELOG.md | 6 +++ Runner/FrankenPhpWorkerRunner.php | 68 ++++++++++++++++++++++++++++ SymfonyRuntime.php | 16 ++++++- Tests/FrankenPhpWorkerRunnerTest.php | 47 +++++++++++++++++++ Tests/SymfonyRuntimeTest.php | 48 ++++++++++++++++++++ Tests/frankenphp-function-mock.php | 19 ++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 Runner/FrankenPhpWorkerRunner.php create mode 100644 Tests/FrankenPhpWorkerRunnerTest.php create mode 100644 Tests/SymfonyRuntimeTest.php create mode 100644 Tests/frankenphp-function-mock.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a608b4..05cbfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.4 +--- + + * Add `FrankenPhpWorkerRunner` + * Add automatic detection of FrankenPHP worker mode in `SymfonyRuntime` + 6.4 --- diff --git a/Runner/FrankenPhpWorkerRunner.php b/Runner/FrankenPhpWorkerRunner.php new file mode 100644 index 0000000..4d44791 --- /dev/null +++ b/Runner/FrankenPhpWorkerRunner.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Runtime\Runner; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; +use Symfony\Component\Runtime\RunnerInterface; + +/** + * A runner for FrankenPHP in worker mode. + * + * @author Kévin Dunglas + */ +class FrankenPhpWorkerRunner implements RunnerInterface +{ + public function __construct( + private HttpKernelInterface $kernel, + private int $loopMax, + ) { + } + + public function run(): int + { + // Prevent worker script termination when a client connection is interrupted + ignore_user_abort(true); + + $server = array_filter($_SERVER, static fn (string $key) => !str_starts_with($key, 'HTTP_'), ARRAY_FILTER_USE_KEY); + $server['APP_RUNTIME_MODE'] = 'web=1&worker=1'; + + $handler = function () use ($server, &$sfRequest, &$sfResponse): void { + // Connect to the Xdebug client if it's available + if (\extension_loaded('xdebug') && \function_exists('xdebug_connect_to_client')) { + xdebug_connect_to_client(); + } + + // Merge the environment variables coming from DotEnv with the ones tied to the current request + $_SERVER += $server; + + $sfRequest = Request::createFromGlobals(); + $sfResponse = $this->kernel->handle($sfRequest); + + $sfResponse->send(); + }; + + $loops = 0; + do { + $ret = \frankenphp_handle_request($handler); + + if ($this->kernel instanceof TerminableInterface && $sfRequest && $sfResponse) { + $this->kernel->terminate($sfRequest, $sfResponse); + } + + gc_collect_cycles(); + } while ($ret && (0 >= $this->loopMax || ++$loops < $this->loopMax)); + + return 0; + } +} diff --git a/SymfonyRuntime.php b/SymfonyRuntime.php index 4035f28..6e29fe2 100644 --- a/SymfonyRuntime.php +++ b/SymfonyRuntime.php @@ -23,6 +23,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Runtime\Internal\MissingDotenv; use Symfony\Component\Runtime\Internal\SymfonyErrorHandler; +use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner; use Symfony\Component\Runtime\Runner\Symfony\ConsoleApplicationRunner; use Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner; use Symfony\Component\Runtime\Runner\Symfony\ResponseRunner; @@ -42,6 +43,7 @@ class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || clas * - "use_putenv" to tell Dotenv to set env vars using putenv() (NOT RECOMMENDED.) * - "dotenv_overload" to tell Dotenv to override existing vars * - "dotenv_extra_paths" to define a list of additional dot-env files + * - "worker_loop_max" to define the number of requests after which the worker must restart to prevent memory leaks * * When the "debug" / "env" options are not defined, they will fallback to the * "APP_DEBUG" / "APP_ENV" environment variables, and to the "--env|-e" / "--no-debug" @@ -73,7 +75,7 @@ class SymfonyRuntime extends GenericRuntime private readonly Command $command; /** - * @param array { + * @param array{ * debug?: ?bool, * env?: ?string, * disable_dotenv?: ?bool, @@ -88,6 +90,7 @@ class SymfonyRuntime extends GenericRuntime * debug_var_name?: string, * dotenv_overload?: ?bool, * dotenv_extra_paths?: ?string[], + * worker_loop_max?: int, // Use 0 or a negative integer to never restart the worker. Default: 500 * } $options */ public function __construct(array $options = []) @@ -143,12 +146,23 @@ public function __construct(array $options = []) $options['error_handler'] ??= SymfonyErrorHandler::class; + $workerLoopMax = $options['worker_loop_max'] ?? $_SERVER['FRANKENPHP_LOOP_MAX'] ?? $_ENV['FRANKENPHP_LOOP_MAX'] ?? null; + if (null !== $workerLoopMax && null === filter_var($workerLoopMax, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE)) { + throw new \LogicException(\sprintf('The "worker_loop_max" runtime option must be an integer, "%s" given.', get_debug_type($workerLoopMax))); + } + + $options['worker_loop_max'] = (int) ($workerLoopMax ?? 500); + parent::__construct($options); } public function getRunner(?object $application): RunnerInterface { if ($application instanceof HttpKernelInterface) { + if ($_SERVER['FRANKENPHP_WORKER'] ?? false) { + return new FrankenPhpWorkerRunner($application, $this->options['worker_loop_max']); + } + return new HttpKernelRunner($application, Request::createFromGlobals(), $this->options['debug'] ?? false); } diff --git a/Tests/FrankenPhpWorkerRunnerTest.php b/Tests/FrankenPhpWorkerRunnerTest.php new file mode 100644 index 0000000..1b5ec99 --- /dev/null +++ b/Tests/FrankenPhpWorkerRunnerTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Runtime\Tests; + +require_once __DIR__.'/frankenphp-function-mock.php'; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; +use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner; + +interface TestAppInterface extends HttpKernelInterface, TerminableInterface +{ +} + +class FrankenPhpWorkerRunnerTest extends TestCase +{ + public function testRun() + { + $application = $this->createMock(TestAppInterface::class); + $application + ->expects($this->once()) + ->method('handle') + ->willReturnCallback(function (Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response { + $this->assertSame('bar', $request->server->get('FOO')); + + return new Response(); + }); + $application->expects($this->once())->method('terminate'); + + $_SERVER['FOO'] = 'bar'; + + $runner = new FrankenPhpWorkerRunner($application, 500); + $this->assertSame(0, $runner->run()); + } +} diff --git a/Tests/SymfonyRuntimeTest.php b/Tests/SymfonyRuntimeTest.php new file mode 100644 index 0000000..c6aff2a --- /dev/null +++ b/Tests/SymfonyRuntimeTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Runtime\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner; +use Symfony\Component\Runtime\SymfonyRuntime; + +class SymfonyRuntimeTest extends TestCase +{ + public function testGetRunner() + { + $application = $this->createStub(HttpKernelInterface::class); + + $runtime = new SymfonyRuntime(); + $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner(null)); + $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); + + $_SERVER['FRANKENPHP_WORKER'] = 1; + $this->assertInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); + } + + public function testStringWorkerMaxLoopThrows() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "worker_loop_max" runtime option must be an integer, "string" given.'); + + new SymfonyRuntime(['worker_loop_max' => 'foo']); + } + + public function testBoolWorkerMaxLoopThrows() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "worker_loop_max" runtime option must be an integer, "bool" given.'); + + new SymfonyRuntime(['worker_loop_max' => false]); + } +} diff --git a/Tests/frankenphp-function-mock.php b/Tests/frankenphp-function-mock.php new file mode 100644 index 0000000..4842fbd --- /dev/null +++ b/Tests/frankenphp-function-mock.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (!function_exists('frankenphp_handle_request')) { + function frankenphp_handle_request(callable $callable): bool + { + $callable(); + + return false; + } +} From 38692fbb8987de2af1a20157f946ef6a47cc93f8 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 2 Jun 2025 16:08:14 +0200 Subject: [PATCH 02/10] Allow Symfony ^8.0 --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index fa9c2cb..624f905 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,10 @@ }, "require-dev": { "composer/composer": "^2.6", - "symfony/console": "^6.4|^7.0", - "symfony/dotenv": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/dotenv": "<6.4" From 77c754703be5e6a347c72c2b81ec6b9da9894749 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 2 Jun 2025 17:50:55 +0200 Subject: [PATCH 03/10] Bump Symfony 8 to PHP >= 8.4 --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 624f905..b25fa84 100644 --- a/composer.json +++ b/composer.json @@ -16,18 +16,18 @@ } ], "require": { - "php": ">=8.2", + "php": ">=8.4", "composer-plugin-api": "^1.0|^2.0" }, "require-dev": { "composer/composer": "^2.6", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dotenv": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" }, "conflict": { - "symfony/dotenv": "<6.4" + "symfony/error-handler": "<7.4" }, "autoload": { "psr-4": { From 3c7162ec37f4f464b96655177a09ffccd79e7579 Mon Sep 17 00:00:00 2001 From: HypeMC Date: Mon, 9 Jun 2025 17:40:54 +0200 Subject: [PATCH 04/10] [Console] Simplify using invokable commands when the component is used standalone --- SymfonyRuntime.php | 6 +++++- Tests/phpt/application.php | 6 +++++- Tests/phpt/command_list.php | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/SymfonyRuntime.php b/SymfonyRuntime.php index 4035f28..4667bbd 100644 --- a/SymfonyRuntime.php +++ b/SymfonyRuntime.php @@ -162,7 +162,11 @@ public function getRunner(?object $application): RunnerInterface if (!$application->getName() || !$console->has($application->getName())) { $application->setName($_SERVER['argv'][0]); - $console->add($application); + if (method_exists($console, 'addCommand')) { + $console->addCommand($application); + } else { + $console->add($application); + } } $console->setDefaultCommand($application->getName(), true); diff --git a/Tests/phpt/application.php b/Tests/phpt/application.php index ca2de55..b51947c 100644 --- a/Tests/phpt/application.php +++ b/Tests/phpt/application.php @@ -25,7 +25,11 @@ }); $app = new Application(); - $app->add($command); + if (method_exists($app, 'addCommand')) { + $app->addCommand($command); + } else { + $app->add($command); + } $app->setDefaultCommand('go', true); return $app; diff --git a/Tests/phpt/command_list.php b/Tests/phpt/command_list.php index 929b440..aa40eda 100644 --- a/Tests/phpt/command_list.php +++ b/Tests/phpt/command_list.php @@ -23,7 +23,11 @@ $command->setName('my_command'); [$cmd, $args] = $runtime->getResolver(require __DIR__.'/command.php')->resolve(); - $app->add($cmd(...$args)); + if (method_exists($app, 'addCommand')) { + $app->addCommand($cmd(...$args)); + } else { + $app->add($cmd(...$args)); + } return $app; }; From e0c2024db138439e8ed7ff418a55dc1b9582308b Mon Sep 17 00:00:00 2001 From: HypeMC Date: Fri, 13 Jun 2025 02:20:31 +0200 Subject: [PATCH 05/10] [Console][FrameworkBundle] Remove deprecated `Application::add()` methods --- SymfonyRuntime.php | 6 +----- Tests/phpt/application.php | 6 +----- Tests/phpt/command_list.php | 6 +----- composer.json | 1 + 4 files changed, 4 insertions(+), 15 deletions(-) diff --git a/SymfonyRuntime.php b/SymfonyRuntime.php index 4667bbd..45500cd 100644 --- a/SymfonyRuntime.php +++ b/SymfonyRuntime.php @@ -162,11 +162,7 @@ public function getRunner(?object $application): RunnerInterface if (!$application->getName() || !$console->has($application->getName())) { $application->setName($_SERVER['argv'][0]); - if (method_exists($console, 'addCommand')) { - $console->addCommand($application); - } else { - $console->add($application); - } + $console->addCommand($application); } $console->setDefaultCommand($application->getName(), true); diff --git a/Tests/phpt/application.php b/Tests/phpt/application.php index b51947c..c0e37d6 100644 --- a/Tests/phpt/application.php +++ b/Tests/phpt/application.php @@ -25,11 +25,7 @@ }); $app = new Application(); - if (method_exists($app, 'addCommand')) { - $app->addCommand($command); - } else { - $app->add($command); - } + $app->addCommand($command); $app->setDefaultCommand('go', true); return $app; diff --git a/Tests/phpt/command_list.php b/Tests/phpt/command_list.php index aa40eda..805a417 100644 --- a/Tests/phpt/command_list.php +++ b/Tests/phpt/command_list.php @@ -23,11 +23,7 @@ $command->setName('my_command'); [$cmd, $args] = $runtime->getResolver(require __DIR__.'/command.php')->resolve(); - if (method_exists($app, 'addCommand')) { - $app->addCommand($cmd(...$args)); - } else { - $app->add($cmd(...$args)); - } + $app->addCommand($cmd(...$args)); return $app; }; diff --git a/composer.json b/composer.json index b25fa84..465df72 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "symfony/http-kernel": "^7.4|^8.0" }, "conflict": { + "symfony/console": "<7.4", "symfony/error-handler": "<7.4" }, "autoload": { From 86c48554f489c55353a128c62c5ae27025bac155 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 15 Jun 2025 21:39:02 +0200 Subject: [PATCH 06/10] fix backwards-compatibility with overridden add() methods --- SymfonyRuntime.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/SymfonyRuntime.php b/SymfonyRuntime.php index 4667bbd..7eff3f5 100644 --- a/SymfonyRuntime.php +++ b/SymfonyRuntime.php @@ -162,10 +162,11 @@ public function getRunner(?object $application): RunnerInterface if (!$application->getName() || !$console->has($application->getName())) { $application->setName($_SERVER['argv'][0]); - if (method_exists($console, 'addCommand')) { - $console->addCommand($application); - } else { + + if (!method_exists($console, 'addCommand') || (new \ReflectionMethod($console, 'add'))->getDeclaringClass()->getName() !== (new \ReflectionMethod($console, 'addCommand'))->getDeclaringClass()->getName()) { $console->add($application); + } else { + $console->addCommand($application); } } From b814dd0f7f86203e7730bbe253e759b1410fa8de Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Wed, 18 Jun 2025 12:21:51 +0200 Subject: [PATCH 07/10] fix forward-compatibility with Symfony 8 --- SymfonyRuntime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SymfonyRuntime.php b/SymfonyRuntime.php index 7eff3f5..aae1fb1 100644 --- a/SymfonyRuntime.php +++ b/SymfonyRuntime.php @@ -163,7 +163,7 @@ public function getRunner(?object $application): RunnerInterface if (!$application->getName() || !$console->has($application->getName())) { $application->setName($_SERVER['argv'][0]); - if (!method_exists($console, 'addCommand') || (new \ReflectionMethod($console, 'add'))->getDeclaringClass()->getName() !== (new \ReflectionMethod($console, 'addCommand'))->getDeclaringClass()->getName()) { + if (!method_exists($console, 'addCommand') || method_exists($console, 'add') && (new \ReflectionMethod($console, 'add'))->getDeclaringClass()->getName() !== (new \ReflectionMethod($console, 'addCommand'))->getDeclaringClass()->getName()) { $console->add($application); } else { $console->addCommand($application); From 00ceeb82a07df9e526b6a329783790e922d02c1a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 8 Jul 2025 11:08:29 +0200 Subject: [PATCH 08/10] Various CS fixes --- Runner/FrankenPhpWorkerRunner.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Runner/FrankenPhpWorkerRunner.php b/Runner/FrankenPhpWorkerRunner.php index 4d44791..0f219fd 100644 --- a/Runner/FrankenPhpWorkerRunner.php +++ b/Runner/FrankenPhpWorkerRunner.php @@ -34,7 +34,7 @@ public function run(): int // Prevent worker script termination when a client connection is interrupted ignore_user_abort(true); - $server = array_filter($_SERVER, static fn (string $key) => !str_starts_with($key, 'HTTP_'), ARRAY_FILTER_USE_KEY); + $server = array_filter($_SERVER, static fn (string $key) => !str_starts_with($key, 'HTTP_'), \ARRAY_FILTER_USE_KEY); $server['APP_RUNTIME_MODE'] = 'web=1&worker=1'; $handler = function () use ($server, &$sfRequest, &$sfResponse): void { @@ -54,7 +54,7 @@ public function run(): int $loops = 0; do { - $ret = \frankenphp_handle_request($handler); + $ret = frankenphp_handle_request($handler); if ($this->kernel instanceof TerminableInterface && $sfRequest && $sfResponse) { $this->kernel->terminate($sfRequest, $sfResponse); From 199d14e56a6f2cd0d628b011f50123a8064e9fdf Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Wed, 9 Oct 2024 11:06:51 +0200 Subject: [PATCH 09/10] run tests using PHPUnit 11.5 --- Tests/SymfonyRuntimeTest.php | 13 +++++++++---- phpunit.xml.dist | 11 ++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Tests/SymfonyRuntimeTest.php b/Tests/SymfonyRuntimeTest.php index c6aff2a..4b30dec 100644 --- a/Tests/SymfonyRuntimeTest.php +++ b/Tests/SymfonyRuntimeTest.php @@ -23,11 +23,16 @@ public function testGetRunner() $application = $this->createStub(HttpKernelInterface::class); $runtime = new SymfonyRuntime(); - $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner(null)); - $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); - $_SERVER['FRANKENPHP_WORKER'] = 1; - $this->assertInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); + try { + $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner(null)); + $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); + $_SERVER['FRANKENPHP_WORKER'] = 1; + $this->assertInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); + } finally { + restore_error_handler(); + restore_exception_handler(); + } } public function testStringWorkerMaxLoopThrows() diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fc2aa6e..a1c76a7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,10 +1,11 @@ @@ -18,7 +19,7 @@ - + ./ @@ -26,5 +27,9 @@ ./Tests ./vendor - + + + + + From f305b5a0030a83a29b57d3b7ecc58619bd29ba99 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Sat, 9 Aug 2025 23:58:04 +0200 Subject: [PATCH 10/10] chore: PHP CS Fixer - restore PHP / PHPUnit rulesets --- GenericRuntime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenericRuntime.php b/GenericRuntime.php index effabe2..c6e30c0 100644 --- a/GenericRuntime.php +++ b/GenericRuntime.php @@ -69,7 +69,7 @@ public function __construct(array $options = []) } if ($debug) { - umask(0000); + umask(0o000); $_SERVER[$debugKey] = $_ENV[$debugKey] = '1'; } else { $_SERVER[$debugKey] = $_ENV[$debugKey] = '0';