diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index e1da99d53..d1033410d 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -10,7 +10,9 @@ namespace Grav\Common\Recovery; use Grav\Common\Filesystem\Folder; +use Grav\Common\Grav; use Grav\Common\Yaml; +use RocketTheme\Toolbox\Event\Event; use function bin2hex; use function dirname; use function file_get_contents; @@ -32,6 +34,7 @@ use const E_COMPILE_ERROR; use const E_CORE_ERROR; use const E_ERROR; use const E_PARSE; +use const E_USER_ERROR; use const GRAV_ROOT; use const JSON_PRETTY_PRINT; use const JSON_UNESCAPED_SLASHES; @@ -47,6 +50,8 @@ class RecoveryManager private $rootPath; /** @var string */ private $userPath; + /** @var bool */ + private $failureCaptured = false; /** * @param mixed $context Container or root path. @@ -77,6 +82,15 @@ class RecoveryManager } register_shutdown_function([$this, 'handleShutdown']); + $events = null; + try { + $events = Grav::instance()['events'] ?? null; + } catch (\Throwable $e) { + $events = null; + } + if ($events && method_exists($events, 'addListener')) { + $events->addListener('onFatalException', [$this, 'onFatalException']); + } $this->registered = true; } @@ -103,6 +117,7 @@ class RecoveryManager } $this->closeUpgradeWindow(); + $this->failureCaptured = false; } /** @@ -112,35 +127,49 @@ class RecoveryManager */ public function handleShutdown(): void { + if ($this->failureCaptured) { + return; + } + $error = $this->resolveLastError(); if (!$error) { return; } - $type = $error['type'] ?? 0; - if (!$this->isFatal($type)) { + $this->processFailure($error); + } + + /** + * Handle uncaught exceptions bubbled to the top-level handler. + * + * @param \Throwable $exception + * @return void + */ + public function handleException(\Throwable $exception): void + { + if ($this->failureCaptured) { return; } - $file = $error['file'] ?? ''; - $plugin = $this->detectPluginFromPath($file); - - $context = [ - 'created_at' => time(), - 'message' => $error['message'] ?? '', - 'file' => $file, - 'line' => $error['line'] ?? null, - 'type' => $type, - 'plugin' => $plugin, + $error = [ + 'type' => E_ERROR, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), ]; - if (!$this->shouldEnterRecovery($context)) { - return; - } + $this->processFailure($error); + } - $this->activate($context); - if ($plugin) { - $this->quarantinePlugin($plugin, $context); + /** + * @param Event $event + * @return void + */ + public function onFatalException(Event $event): void + { + $exception = $event['exception'] ?? null; + if ($exception instanceof \Throwable) { + $this->handleException($exception); } } @@ -172,6 +201,41 @@ class RecoveryManager } } + /** + * @param array $error + * @return void + */ + private function processFailure(array $error): void + { + $type = (int)($error['type'] ?? 0); + if (!$this->isFatal($type)) { + return; + } + + $file = $error['file'] ?? ''; + $plugin = $this->detectPluginFromPath($file); + + $context = [ + 'created_at' => time(), + 'message' => $error['message'] ?? '', + 'file' => $file, + 'line' => $error['line'] ?? null, + 'type' => $type, + 'plugin' => $plugin, + ]; + + if (!$this->shouldEnterRecovery($context)) { + return; + } + + $this->activate($context); + if ($plugin) { + $this->quarantinePlugin($plugin, $context); + } + + $this->failureCaptured = true; + } + /** * Return last recorded recovery context. * @@ -265,7 +329,7 @@ class RecoveryManager */ private function isFatal(int $type): bool { - return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true); + return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_USER_ERROR], true); } /** @@ -403,6 +467,7 @@ class RecoveryManager $path = $this->windowPath(); Folder::create(dirname($path)); file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + $this->failureCaptured = false; } /** diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index fb76dfc44..51312b834 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -2,6 +2,7 @@ use Grav\Common\Filesystem\Folder; use Grav\Common\Recovery\RecoveryManager; +use RocketTheme\Toolbox\Event\Event; class RecoveryManagerTest extends \Codeception\TestCase\Test { @@ -120,6 +121,38 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test self::assertFileDoesNotExist($quarantine); } + public function testHandleExceptionCreatesFlag(): void + { + $manager = new RecoveryManager($this->tmpDir); + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); + + $manager->handleException(new \RuntimeException('Unhandled failure')); + + $flag = $this->tmpDir . '/user/data/recovery.flag'; + self::assertFileExists($flag); + $context = json_decode(file_get_contents($flag), true); + self::assertSame('Unhandled failure', $context['message']); + self::assertArrayHasKey('plugin', $context); + self::assertNull($context['plugin']); + + $manager->clear(); + } + + public function testOnFatalExceptionDispatchesToHandler(): void + { + $manager = new RecoveryManager($this->tmpDir); + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); + + $manager->onFatalException(new Event(['exception' => new \RuntimeException('Event failure')])); + + $flag = $this->tmpDir . '/user/data/recovery.flag'; + self::assertFileExists($flag); + $context = json_decode(file_get_contents($flag), true); + self::assertSame('Event failure', $context['message']); + + $manager->clear(); + } + public function testHandleShutdownIgnoresNonFatalErrors(): void { $manager = new class($this->tmpDir) extends RecoveryManager {