From 1449878699e3dba073d00306b5c79303dec3c5f1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 4 May 2026 15:23:07 +0200 Subject: [PATCH 1/2] test: phpunit exception not restored --- src/Symfony/Tests/EventListener/ErrorListenerTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Tests/EventListener/ErrorListenerTest.php b/src/Symfony/Tests/EventListener/ErrorListenerTest.php index effa85bb10..a5069d36b1 100644 --- a/src/Symfony/Tests/EventListener/ErrorListenerTest.php +++ b/src/Symfony/Tests/EventListener/ErrorListenerTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ApiResource\Error; use ApiPlatform\Symfony\EventListener\ErrorListener; +use Composer\InstalledVersions; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -32,7 +33,11 @@ class ErrorListenerTest extends TestCase { protected function tearDown(): void { - restore_exception_handler(); + // symfony/http-kernel < 6.4.13 leaks an exception handler when no previous handler was set: + // restore_exception_handler() was only called when set_exception_handler() returned a non-null value. + if (version_compare(InstalledVersions::getVersion('symfony/http-kernel'), '6.4.13', '<')) { + restore_exception_handler(); + } } public function testDuplicateException(): void From 9d069b441c16f83bca57c3d51ee94c401b94f7c3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 5 May 2026 11:29:30 +0200 Subject: [PATCH 2/2] test(symfony): track exception handler stack to fix risky tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | main | Tickets | ∅ | License | MIT | Doc PR | ∅ ApiTestCase now snapshots the exception handler stack via #[Before]/#[After] hooks (works even when subclasses override setUp without parent::setUp()). Bump symfony/http-kernel to ^6.4.13 to skip the ErrorListener handler leak and drop the AppKernel restore_exception_handler() workarounds. --- composer.json | 2 +- phpunit.xml.dist | 3 +- src/Doctrine/Odm/Tests/AppKernel.php | 7 ---- .../DoctrineMongoDbOdmFilterTestCase.php | 33 ++++++++++++++++++ src/Doctrine/Orm/Tests/AppKernel.php | 7 ---- .../Orm/Tests/DoctrineOrmFilterTestCase.php | 33 ++++++++++++++++++ src/State/composer.json | 2 +- src/Symfony/Bundle/Test/ApiTestCase.php | 34 +++++++++++++++++++ .../Tests/EventListener/ErrorListenerTest.php | 10 ------ src/Symfony/composer.json | 1 + src/Validator/composer.json | 2 +- tests/Fixtures/app/AppKernel.php | 6 ---- 12 files changed, 106 insertions(+), 34 deletions(-) diff --git a/composer.json b/composer.json index c1c9240540..02dd37b934 100644 --- a/composer.json +++ b/composer.json @@ -114,7 +114,7 @@ "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d8527fddfa..9f177ef37f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,7 +3,8 @@ backupGlobals="false" bootstrap="tests/Fixtures/app/bootstrap.php" colors="true" - cacheDirectory=".phpunit.cache"> + cacheDirectory=".phpunit.cache" + failOnRisky="true"> diff --git a/src/Doctrine/Odm/Tests/AppKernel.php b/src/Doctrine/Odm/Tests/AppKernel.php index e33f36dced..773a4e3159 100644 --- a/src/Doctrine/Odm/Tests/AppKernel.php +++ b/src/Doctrine/Odm/Tests/AppKernel.php @@ -18,7 +18,6 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; /** @@ -43,12 +42,6 @@ public function registerBundles(): array return [ new FrameworkBundle(), new DoctrineMongoDBBundle(), - new class extends Bundle { - public function shutdown(): void - { - restore_exception_handler(); - } - }, ]; } diff --git a/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php b/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php index 02f6a2f68f..388b1bbade 100644 --- a/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php +++ b/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php @@ -17,8 +17,11 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\ErrorHandler\ErrorHandler; /** * @internal @@ -37,6 +40,8 @@ abstract class DoctrineMongoDbOdmFilterTestCase extends KernelTestCase protected string $filterClass; + private bool $symfonyErrorHandlerWasRegistered = false; + protected function setUp(): void { self::bootKernel(); @@ -46,6 +51,34 @@ protected function setUp(): void $this->repository = $this->manager->getRepository($this->resourceClass); } + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test so we only pop the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + #[DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, array $expectedPipeline, ?callable $factory = null, ?string $resourceClass = null): void { diff --git a/src/Doctrine/Orm/Tests/AppKernel.php b/src/Doctrine/Orm/Tests/AppKernel.php index 2037665337..66c5948a28 100644 --- a/src/Doctrine/Orm/Tests/AppKernel.php +++ b/src/Doctrine/Orm/Tests/AppKernel.php @@ -18,7 +18,6 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; /** @@ -44,12 +43,6 @@ public function registerBundles(): array new FrameworkBundle(), new DoctrineBundle(), new TestBundle(), - new class extends Bundle { - public function shutdown(): void - { - restore_exception_handler(); - } - }, ]; } diff --git a/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php b/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php index e588c48f12..d43e67b51f 100644 --- a/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php +++ b/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php @@ -18,8 +18,11 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\ErrorHandler\ErrorHandler; /** * @internal @@ -38,6 +41,8 @@ abstract class DoctrineOrmFilterTestCase extends KernelTestCase protected string $filterClass; + private bool $symfonyErrorHandlerWasRegistered = false; + protected function setUp(): void { self::bootKernel(); @@ -46,6 +51,34 @@ protected function setUp(): void $this->repository = $this->managerRegistry->getManagerForClass(Dummy::class)->getRepository(Dummy::class); } + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test so we only pop the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + #[DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $factory = null, ?string $resourceClass = null): void { diff --git a/src/State/composer.json b/src/State/composer.json index 7f8e425033..9e383a3b0a 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -30,7 +30,7 @@ "php": ">=8.2", "api-platform/metadata": "^4.3", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/translation-contracts": "^3.0", "symfony/deprecation-contracts": "^3.1" diff --git a/src/Symfony/Bundle/Test/ApiTestCase.php b/src/Symfony/Bundle/Test/ApiTestCase.php index 211ed63152..891b5f09ab 100644 --- a/src/Symfony/Bundle/Test/ApiTestCase.php +++ b/src/Symfony/Bundle/Test/ApiTestCase.php @@ -14,9 +14,12 @@ namespace ApiPlatform\Symfony\Bundle\Test; use ApiPlatform\Metadata\IriConverterInterface; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\ErrorHandler\ErrorHandler; use Symfony\Component\HttpClient\HttpClientTrait; /** @@ -38,6 +41,37 @@ abstract class ApiTestCase extends KernelTestCase */ protected static ?bool $alwaysBootKernel = null; + private bool $symfonyErrorHandlerWasRegistered = false; + + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test (e.g. the kernel was booted from setUpBeforeClass) so we only pop + * the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + /** * Creates a Client. * diff --git a/src/Symfony/Tests/EventListener/ErrorListenerTest.php b/src/Symfony/Tests/EventListener/ErrorListenerTest.php index a5069d36b1..d9e0a97fff 100644 --- a/src/Symfony/Tests/EventListener/ErrorListenerTest.php +++ b/src/Symfony/Tests/EventListener/ErrorListenerTest.php @@ -21,7 +21,6 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ApiResource\Error; use ApiPlatform\Symfony\EventListener\ErrorListener; -use Composer\InstalledVersions; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,15 +30,6 @@ class ErrorListenerTest extends TestCase { - protected function tearDown(): void - { - // symfony/http-kernel < 6.4.13 leaks an exception handler when no previous handler was set: - // restore_exception_handler() was only called when set_exception_handler() returned a non-null value. - if (version_compare(InstalledVersions::getVersion('symfony/http-kernel'), '6.4.13', '<')) { - restore_exception_handler(); - } - } - public function testDuplicateException(): void { $exception = new \Exception(); diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 53e3a93988..820533edcb 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -41,6 +41,7 @@ "api-platform/openapi": "^4.3", "symfony/asset": "^6.4 || ^7.0 || ^8.0", "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 136b82b419..392e1f5a8b 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -25,7 +25,7 @@ "php": ">=8.2", "api-platform/metadata": "^4.3", "symfony/type-info": "^7.3 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.1 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", "symfony/web-link": "^6.4 || ^7.1 || ^8.0" diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 0f0a235c46..92ec2c4411 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -98,12 +98,6 @@ public function registerBundles(): array return $bundles; } - public function shutdown(): void - { - parent::shutdown(); - restore_exception_handler(); - } - public function getProjectDir(): string { return __DIR__;