diff --git a/packages/core/src/FrameworkKernel.php b/packages/core/src/FrameworkKernel.php index 6a5202438..527ea6855 100644 --- a/packages/core/src/FrameworkKernel.php +++ b/packages/core/src/FrameworkKernel.php @@ -9,6 +9,7 @@ use RuntimeException; use Tempest\Container\Container; use Tempest\Container\GenericContainer; +use Tempest\Core\Exceptions\ExceptionProcessor; use Tempest\Core\Kernel\FinishDeferredTasks; use Tempest\Core\Kernel\LoadConfig; use Tempest\Core\Kernel\RegisterEmergencyExceptionHandler; @@ -273,16 +274,31 @@ public function registerExceptionHandler(): self ini_set('display_errors', 'Off'); // @mago-expect lint:no-ini-set set_exception_handler($handler->handle(...)); - set_error_handler(function (int $code, string $message, string $filename, int $line) use ($handler): bool { - $handler->handle(new ErrorException( - message: $message, - code: $code, - filename: $filename, - line: $line, - )); - - return true; - }); + set_error_handler( + callback: function (int $code, string $message, string $filename, int $line): bool { + // if error_reporting is 0, the error was silenced with @ + if ((error_reporting() & $code) === 0) { + return false; + } + + $exception = new ErrorException( + message: $message, + code: 0, + severity: $code, + filename: $filename, + line: $line, + ); + + // warnings/notices/deprecations get reported but don't throw + if (($code & (E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED)) !== 0) { + $this->container->get(ExceptionProcessor::class)->process($exception); + return true; + } + + throw $exception; + }, + error_levels: E_ALL, + ); return $this; } diff --git a/tests/Integration/Core/FrameworkKernelExceptionHandlerTest.php b/tests/Integration/Core/FrameworkKernelExceptionHandlerTest.php new file mode 100644 index 000000000..25de2f31b --- /dev/null +++ b/tests/Integration/Core/FrameworkKernelExceptionHandlerTest.php @@ -0,0 +1,150 @@ + $this->container ??= new GenericContainer(); + } + + private FrameworkKernel $kernel { + get => $this->kernel ??= new FrameworkKernel(root: __DIR__, container: $this->container); + } + + private int $previousErrorReporting; + + #[PreCondition] + protected function configure(): void + { + $this->previousErrorReporting = error_reporting(E_ALL); + } + + #[PostCondition] + protected function cleanup(): void + { + error_reporting($this->previousErrorReporting); + putenv('ENVIRONMENT=testing'); + + restore_error_handler(); + restore_exception_handler(); + + GenericContainer::setInstance(null); + } + + #[Test] + public function warnings_notices_and_deprecations_are_processed_without_throwing(): void + { + putenv('ENVIRONMENT=local'); + + $this->container->singleton(ExceptionHandler::class, new TestingExceptionHandler()); + $this->container->singleton(ExceptionProcessor::class, $processor = new TestingExceptionProcessor()); + $this->kernel->registerExceptionHandler(); + + try { + trigger_error('report warning', E_USER_WARNING); + trigger_error('report deprecation', E_USER_DEPRECATED); + trigger_error('report notice', E_USER_NOTICE); + } catch (Throwable $throwable) { + $this->fail(sprintf('Expected no exception to be thrown, but got %s: %s', get_class($throwable), $throwable->getMessage())); + } + + $this->assertCount(3, $processor->processed); + + /** @var ErrorException */ + $warning = $processor->processed[0]; + $this->assertInstanceOf(ErrorException::class, $warning); + $this->assertSame(E_USER_WARNING, $warning->getSeverity()); + $this->assertSame('report warning', $warning->getMessage()); + + /** @var ErrorException */ + $deprecation = $processor->processed[1]; + $this->assertInstanceOf(ErrorException::class, $deprecation); + $this->assertSame(E_USER_DEPRECATED, $deprecation->getSeverity()); + $this->assertSame('report deprecation', $deprecation->getMessage()); + + /** @var ErrorException */ + $notice = $processor->processed[2]; + $this->assertInstanceOf(ErrorException::class, $notice); + $this->assertSame(E_USER_NOTICE, $notice->getSeverity()); + $this->assertSame('report notice', $notice->getMessage()); + } + + #[Test] + public function suppressions_are_not_processed_or_thrown(): void + { + putenv('ENVIRONMENT=local'); + + $this->container->singleton(ExceptionHandler::class, new TestingExceptionHandler()); + $this->container->singleton(ExceptionProcessor::class, $processor = new TestingExceptionProcessor()); + $this->kernel->registerExceptionHandler(); + + @trigger_error('suppressed warning', E_USER_WARNING); + @trigger_error('suppressed deprecation', E_USER_DEPRECATED); + @trigger_error('suppressed notice', E_USER_NOTICE); + + $this->assertCount(0, $processor->processed); + } + + #[Test] + public function uncaught_exceptions_are_forwarded_to_the_registered_exception_handler(): void + { + putenv('ENVIRONMENT=local'); + + $handler = new TestingExceptionHandler(); + + $this->container->singleton(ExceptionHandler::class, $handler); + $this->container->singleton(ExceptionProcessor::class, new TestingExceptionProcessor()); + $this->kernel->registerExceptionHandler(); + + $kernelExceptionHandler = set_exception_handler(static function (Throwable $throwable): void {}); + + if (! is_callable($kernelExceptionHandler)) { + $this->fail('Expected the kernel to register an exception handler callback.'); + } + + $exception = new RuntimeException('uncaught'); + $kernelExceptionHandler($exception); + + restore_exception_handler(); + + $this->assertCount(1, $handler->handled); + $this->assertSame($exception, $handler->handled[0]); + } +} + +final class TestingExceptionProcessor implements ExceptionProcessor +{ + /** @var list */ + private(set) array $processed = []; + + public function process(Throwable $throwable): void + { + $this->processed[] = $throwable; + } +} + +final class TestingExceptionHandler implements ExceptionHandler +{ + /** @var list */ + private(set) array $handled = []; + + public function handle(Throwable $throwable): void + { + $this->handled[] = $throwable; + } +}