Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[Runtime] Various fixes and hardenings
  • Loading branch information
nicolas-grekas committed May 19, 2026
commit 30cb9845189939d84dc8205e0d44c6d24e91f5b8
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions 8 src/Symfony/Component/Runtime/GenericRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kevin@dunglas.dev>
*/
class FrankenPhpWorkerRunner implements RunnerInterface
Expand Down
10 changes: 9 additions & 1 deletion 10 src/Symfony/Component/Runtime/SymfonyRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down
70 changes: 70 additions & 0 deletions 70 src/Symfony/Component/Runtime/Tests/FrankenPhpWorkerRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}
}
136 changes: 136 additions & 0 deletions 136 src/Symfony/Component/Runtime/Tests/SymfonyRuntimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.