diff --git a/src/Symfony/Component/Runtime/CHANGELOG.md b/src/Symfony/Component/Runtime/CHANGELOG.md index 1a608b4cf734b..05cbfe9bc5287 100644 --- a/src/Symfony/Component/Runtime/CHANGELOG.md +++ b/src/Symfony/Component/Runtime/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/src/Symfony/Component/Runtime/Runner/FrankenPhpWorkerRunner.php b/src/Symfony/Component/Runtime/Runner/FrankenPhpWorkerRunner.php new file mode 100644 index 0000000000000..4d44791775cab --- /dev/null +++ b/src/Symfony/Component/Runtime/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/src/Symfony/Component/Runtime/SymfonyRuntime.php b/src/Symfony/Component/Runtime/SymfonyRuntime.php index 4035f28c806cd..314aa2f7837e0 100644 --- a/src/Symfony/Component/Runtime/SymfonyRuntime.php +++ b/src/Symfony/Component/Runtime/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" @@ -64,6 +66,7 @@ class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || clas * - int|string|null as handled by GenericRuntime. * * @author Nicolas Grekas + * @author Kévin Dunglas */ class SymfonyRuntime extends GenericRuntime { @@ -73,7 +76,7 @@ class SymfonyRuntime extends GenericRuntime private readonly Command $command; /** - * @param array { + * @param array{ * debug?: ?bool, * env?: ?string, * disable_dotenv?: ?bool, @@ -88,6 +91,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 +147,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/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php b/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php new file mode 100644 index 0000000000000..728394666b288 --- /dev/null +++ b/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php @@ -0,0 +1,50 @@ + + * + * 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 +{ +} + +/** + * @author Kévin Dunglas + */ +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/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php b/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php new file mode 100644 index 0000000000000..6dcda21282bcd --- /dev/null +++ b/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php @@ -0,0 +1,52 @@ + + * + * 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; + +/** + * @author Kévin Dunglas + * @author Alexandre Daubois + */ +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/src/Symfony/Component/Runtime/Tests/frankenphp-function-mock.php b/src/Symfony/Component/Runtime/Tests/frankenphp-function-mock.php new file mode 100644 index 0000000000000..4842fbdcd95c5 --- /dev/null +++ b/src/Symfony/Component/Runtime/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; + } +}