diff --git a/src/Symfony/Component/DependencyInjection/Kernel/KernelTrait.php b/src/Symfony/Component/DependencyInjection/Kernel/KernelTrait.php index 1d07d804125d6..12dabcb4ded34 100644 --- a/src/Symfony/Component/DependencyInjection/Kernel/KernelTrait.php +++ b/src/Symfony/Component/DependencyInjection/Kernel/KernelTrait.php @@ -594,7 +594,7 @@ private function getKernelParameters(): array 'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', 'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%', 'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', - 'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%', + 'kernel.runtime_mode.worker' => '%env(int:default::key:worker:default:kernel.runtime_mode:)%', 'kernel.debug' => $this->debug, 'kernel.build_dir' => realpath($dir = $this->getEffectiveBuildDir()) ?: $dir, 'kernel.cache_dir' => realpath($dir = ($this->getCacheDir() === $this->getBuildDir() ? $this->getEffectiveBuildDir() : $this->getCacheDir())) ?: $dir, diff --git a/src/Symfony/Component/Runtime/GenericRuntime.php b/src/Symfony/Component/Runtime/GenericRuntime.php index cada4514831f5..699e7bb24e24d 100644 --- a/src/Symfony/Component/Runtime/GenericRuntime.php +++ b/src/Symfony/Component/Runtime/GenericRuntime.php @@ -168,10 +168,14 @@ protected function getArgument(\ReflectionParameter $parameter, ?string $type): return $this; } - if (!$runtime = $this->getRuntime($type ?? 'mixed')) { + if (!$runtime = (null !== $type ? $this->getRuntime($type) : null)) { $r = $parameter->getDeclaringFunction(); - throw new \InvalidArgumentException(\sprintf('Cannot resolve argument "%s $%s" in "%s" on line "%d": "%s" supports only arguments "array $context", "array $argv" and "array $request", or a runtime named "Symfony\Runtime\%1$sRuntime".', $type ?? 'mixed', $parameter->name, $r->getFileName(), $r->getStartLine(), get_debug_type($this))); + if (null === $type) { + throw new \InvalidArgumentException(\sprintf('Cannot resolve untyped argument "$%s" in "%s" on line "%d": "%s" supports only typed arguments, or "array $context", "array $argv" and "array $request".', $parameter->name, $r->getFileName(), $r->getStartLine(), get_debug_type($this))); + } + + throw new \InvalidArgumentException(\sprintf('Cannot resolve argument "%s $%s" in "%s" on line "%d": "%s" supports only arguments "array $context", "array $argv" and "array $request", or a runtime named "Symfony\Runtime\%1$sRuntime".', $type, $parameter->name, $r->getFileName(), $r->getStartLine(), get_debug_type($this))); } return $runtime->getArgument($parameter, $type); diff --git a/src/Symfony/Component/Runtime/Runner/FrankenPhpWorkerRunner.php b/src/Symfony/Component/Runtime/Runner/FrankenPhpWorkerRunner.php index 52e6572183277..aed9ad4d3d130 100644 --- a/src/Symfony/Component/Runtime/Runner/FrankenPhpWorkerRunner.php +++ b/src/Symfony/Component/Runtime/Runner/FrankenPhpWorkerRunner.php @@ -20,6 +20,15 @@ /** * A runner for FrankenPHP in worker mode. * + * Loops up to $loopMax times; pass 0 or a negative integer to loop indefinitely. + * + * When the application is an HttpKernelInterface and "FRANKENPHP_RESET_KERNEL" is truthy, + * the kernel is cloned after each request to mitigate cross-request state leaks; subclasses + * keeping non-resettable state should override __clone accordingly. + * + * "APP_RUNTIME_MODE" is set to "web=1&worker=1", or "web=1&worker=2" when FRANKENPHP_RESET_KERNEL + * is active. + * * @author Kévin Dunglas */ class FrankenPhpWorkerRunner implements RunnerInterface diff --git a/src/Symfony/Component/Runtime/SymfonyRuntime.php b/src/Symfony/Component/Runtime/SymfonyRuntime.php index 8e4c7dd950be9..97d50ffc64b29 100644 --- a/src/Symfony/Component/Runtime/SymfonyRuntime.php +++ b/src/Symfony/Component/Runtime/SymfonyRuntime.php @@ -44,12 +44,17 @@ 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 + * - "worker_loop_max" to define the number of requests after which the worker must restart; + * use 0 or a negative integer to never restart. Falls back to the "FRANKENPHP_LOOP_MAX" env var, defaults to 500. * * 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" * command line arguments if "symfony/console" is installed. * + * When the application is an HttpKernelInterface or Response and "FRANKENPHP_WORKER" is truthy, + * a FrankenPhpWorkerRunner is used. For HttpKernelInterface, "FRANKENPHP_RESET_KERNEL" additionally + * clones the kernel after each request. + * * When the "symfony/dotenv" component is installed, .env files are loaded. * When "symfony/error-handler" is installed, it is registered in debug mode. * @@ -225,6 +230,9 @@ protected function getArgument(\ReflectionParameter $parameter, ?string $type): return (null !== $type ? $this->resolveType($type) : null) ?? parent::getArgument($parameter, $type); } + /** + * Returns an instance for the given $type, or null to delegate to the default resolver. + */ protected function resolveType(string $type): mixed { return match ($type) { diff --git a/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php b/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php index 103add2cd55bd..8f648aebfd2ff 100644 --- a/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php +++ b/src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php @@ -26,6 +26,11 @@ interface TestAppInterface extends HttpKernelInterface, TerminableInterface class FrankenPhpWorkerRunnerTest extends TestCase { + protected function tearDown(): void + { + unset($_SERVER['FRANKENPHP_RESET_KERNEL'], $_SERVER['APP_RUNTIME_MODE']); + } + public function testRun() { $application = $this->createMock(TestAppInterface::class); @@ -55,4 +60,69 @@ public function testRunWithResponse() $runner = new FrankenPhpWorkerRunner($response, 500); $this->assertSame(0, $runner->run()); } + + public function testRunWithResetKernelTagsRuntimeMode() + { + $application = $this->createMock(TestAppInterface::class); + $application + ->expects($this->once()) + ->method('handle') + ->willReturnCallback(function (Request $request): Response { + $this->assertSame('web=1&worker=2', $request->server->get('APP_RUNTIME_MODE')); + + return new Response(); + }); + + $_SERVER['FRANKENPHP_RESET_KERNEL'] = '1'; + + $runner = new FrankenPhpWorkerRunner($application, 500); + $this->assertSame(0, $runner->run()); + } + + public function testRunWithoutResetKernelTagsRuntimeMode() + { + $application = $this->createMock(TestAppInterface::class); + $application + ->expects($this->once()) + ->method('handle') + ->willReturnCallback(function (Request $request): Response { + $this->assertSame('web=1&worker=1', $request->server->get('APP_RUNTIME_MODE')); + + return new Response(); + }); + + unset($_SERVER['FRANKENPHP_RESET_KERNEL']); + + $runner = new FrankenPhpWorkerRunner($application, 500); + $this->assertSame(0, $runner->run()); + } + + public function testRunWithResponseIgnoresResetKernel() + { + $response = $this->createMock(Response::class); + $response->expects($this->once())->method('send'); + + $_SERVER['FRANKENPHP_RESET_KERNEL'] = '1'; + + $runner = new FrankenPhpWorkerRunner($response, 500); + $this->assertSame(0, $runner->run()); + } + + public function testRunWithZeroLoopMaxLoopsAtLeastOnce() + { + $application = $this->createMock(TestAppInterface::class); + $application->expects($this->once())->method('handle')->willReturn(new Response()); + + $runner = new FrankenPhpWorkerRunner($application, 0); + $this->assertSame(0, $runner->run()); + } + + public function testRunWithNegativeLoopMaxLoopsAtLeastOnce() + { + $application = $this->createMock(TestAppInterface::class); + $application->expects($this->once())->method('handle')->willReturn(new Response()); + + $runner = new FrankenPhpWorkerRunner($application, -1); + $this->assertSame(0, $runner->run()); + } } diff --git a/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php b/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php index 890223303297c..cfa18d75776c0 100644 --- a/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php +++ b/src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php @@ -11,13 +11,21 @@ namespace Symfony\Component\Runtime\Tests; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\RawInputInterface; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner; use Symfony\Component\Runtime\SymfonyRuntime; class SymfonyRuntimeTest extends TestCase { + protected function tearDown(): void + { + unset($_SERVER['FRANKENPHP_WORKER'], $_SERVER['FRANKENPHP_LOOP_MAX']); + } + public function testGetRunner() { $application = $this->createStub(HttpKernelInterface::class); @@ -35,6 +43,134 @@ public function testGetRunner() } } + #[TestWith(['0'])] + #[TestWith([''])] + #[TestWith(['false'])] + #[TestWith(['off'])] + public function testFalsyFrankenPhpWorkerDoesNotEnableWorkerRunner(string $value) + { + $application = $this->createStub(HttpKernelInterface::class); + $_SERVER['FRANKENPHP_WORKER'] = $value; + + $runtime = new SymfonyRuntime(); + + try { + $this->assertNotInstanceOf(FrankenPhpWorkerRunner::class, $runtime->getRunner($application)); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } + + public function testFrankenPhpLoopMaxEnvVarFallback() + { + $_SERVER['FRANKENPHP_LOOP_MAX'] = '42'; + + $runtime = new SymfonyRuntime(); + + $r = new \ReflectionProperty($runtime, 'options'); + $options = $r->getValue($runtime); + + try { + $this->assertSame(42, $options['worker_loop_max']); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } + + public function testFrankenPhpLoopMaxEnvVarInvalidThrows() + { + $_SERVER['FRANKENPHP_LOOP_MAX'] = 'not-a-number'; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The "worker_loop_max" runtime option must be an integer, "string" given.'); + + new SymfonyRuntime(); + } + + public function testResolveTypeOverrideCanInjectCustomInstances() + { + $runtime = new class extends SymfonyRuntime { + public \stdClass $customInput; + + public function __construct() + { + parent::__construct(); + $this->customInput = new \stdClass(); + } + + protected function resolveType(string $type): mixed + { + if (InputInterface::class === $type) { + return $this->customInput; + } + + return parent::resolveType($type); + } + }; + + try { + $resolver = $runtime->getResolver(static fn (InputInterface $input) => $input); + [$callable, $args] = $resolver->resolve(); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + + $this->assertSame([$runtime->customInput], $args); + } + + public function testResolveTypeReturningNullDelegatesToParent() + { + $runtime = new class extends SymfonyRuntime { + protected function resolveType(string $type): mixed + { + return null; + } + }; + + try { + $resolver = $runtime->getResolver(static fn (RawInputInterface $input) => $input); + $this->expectException(\InvalidArgumentException::class); + $resolver->resolve(); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } + + public function testRawInputInterfaceIsResolved() + { + $runtime = new SymfonyRuntime(); + + try { + $resolver = $runtime->getResolver(static fn (RawInputInterface $input) => $input); + [$callable, $args] = $resolver->resolve(); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + + $this->assertCount(1, $args); + $this->assertInstanceOf(RawInputInterface::class, $args[0]); + } + + public function testUntypedRequiredArgumentMessageMentionsUntyped() + { + $runtime = new SymfonyRuntime(); + + try { + $resolver = $runtime->getResolver(static fn ($untyped) => $untyped); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Cannot resolve untyped argument "\$untyped"/'); + $resolver->resolve(); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } + public function testStringWorkerMaxLoopThrows() { $this->expectException(\LogicException::class);