From 3cf616e609afb23576652b7848023e5a24a3c53e Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 17:32:55 -0600 Subject: [PATCH 01/10] more fixes for recovery.window and recovery.flag Signed-off-by: Andy Miller --- system/src/Grav/Common/Recovery/RecoveryManager.php | 6 ++++-- .../src/Grav/Common/Upgrade/SafeUpgradeService.php | 12 +++++++++--- system/src/Grav/Console/Gpm/UpdateCommand.php | 2 ++ .../Grav/Common/Upgrade/SafeUpgradeServiceTest.php | 4 ++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index e75f7267e..2e59ab2af 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -303,7 +303,7 @@ class RecoveryManager */ private function windowPath(): string { - return $this->rootPath . '/system/recovery.window'; + return $this->userPath . '/data/recovery.window'; } /** @@ -403,7 +403,9 @@ class RecoveryManager 'expires_at' => $createdAt + $ttl, ]; - file_put_contents($this->windowPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + $path = $this->windowPath(); + Folder::create(dirname($path)); + file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); } /** diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index b8cab0a8a..a0670750b 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -418,9 +418,15 @@ class SafeUpgradeService */ public function clearRecoveryFlag(): void { - $flag = $this->rootPath . '/user/data/recovery.flag'; - if (is_file($flag)) { - @unlink($flag); + $paths = [ + $this->rootPath . '/user/data/recovery.flag', + $this->rootPath . '/user/data/recovery.window', + ]; + + foreach ($paths as $path) { + if (is_file($path)) { + @unlink($path); + } } } diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 897026f67..b4d8df7b7 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -235,6 +235,8 @@ class UpdateCommand extends GpmCommand return 1; } + $recovery->closeUpgradeWindow(); + return 0; } diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index 31c4882b9..c3f0082bc 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -187,8 +187,11 @@ PHP; { [$root] = $this->prepareLiveEnvironment(); $flag = $root . '/user/data/recovery.flag'; + $window = $root . '/user/data/recovery.window'; Folder::create(dirname($flag)); file_put_contents($flag, 'flag'); + Folder::create(dirname($window)); + file_put_contents($window, json_encode(['expires_at' => time() + 120])); $service = new SafeUpgradeService([ 'root' => $root, @@ -196,6 +199,7 @@ PHP; $service->clearRecoveryFlag(); self::assertFileDoesNotExist($flag); + self::assertFileDoesNotExist($window); } /** From 38840ff08079578164e876f64461fe0d34412abe Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 17:54:15 -0600 Subject: [PATCH 02/10] recovery/command fixes Signed-off-by: Andy Miller --- .../src/Grav/Common/Upgrade/SafeUpgradeService.php | 12 +++--------- system/src/Grav/Console/Gpm/UpdateCommand.php | 2 -- .../Grav/Common/Upgrade/SafeUpgradeServiceTest.php | 2 +- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a0670750b..b8cab0a8a 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -418,15 +418,9 @@ class SafeUpgradeService */ public function clearRecoveryFlag(): void { - $paths = [ - $this->rootPath . '/user/data/recovery.flag', - $this->rootPath . '/user/data/recovery.window', - ]; - - foreach ($paths as $path) { - if (is_file($path)) { - @unlink($path); - } + $flag = $this->rootPath . '/user/data/recovery.flag'; + if (is_file($flag)) { + @unlink($flag); } } diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index b4d8df7b7..897026f67 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -235,8 +235,6 @@ class UpdateCommand extends GpmCommand return 1; } - $recovery->closeUpgradeWindow(); - return 0; } diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index c3f0082bc..2569683df 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -199,7 +199,7 @@ PHP; $service->clearRecoveryFlag(); self::assertFileDoesNotExist($flag); - self::assertFileDoesNotExist($window); + self::assertFileExists($window); } /** From e82a0ce8bd2b516b4fd7728f127cae7ff3c24c42 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 18:17:59 -0600 Subject: [PATCH 03/10] more recovery fixes Signed-off-by: Andy Miller --- .../Grav/Common/Recovery/RecoveryManager.php | 3 -- .../Common/Recovery/RecoveryManagerTest.php | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index 2e59ab2af..e1da99d53 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -124,9 +124,6 @@ class RecoveryManager $file = $error['file'] ?? ''; $plugin = $this->detectPluginFromPath($file); - if (!$plugin) { - return; - } $context = [ 'created_at' => time(), diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index 24f8f3884..fb76dfc44 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -77,6 +77,49 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test self::assertArrayHasKey('bad', $decoded); } + public function testHandleShutdownCreatesFlagWithoutPlugin(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected $error; + public function __construct(string $rootPath) + { + parent::__construct($rootPath); + $this->error = [ + 'type' => E_ERROR, + 'file' => $this->getRootPathValue() . '/system/index.php', + 'message' => 'Core failure', + 'line' => 13, + ]; + } + + protected function resolveLastError(): ?array + { + return $this->error; + } + + private function getRootPathValue(): string + { + $prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath'); + $prop->setAccessible(true); + + return $prop->getValue($this); + } + }; + + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); + $manager->handleShutdown(); + + $flag = $this->tmpDir . '/user/data/recovery.flag'; + self::assertFileExists($flag); + $context = json_decode(file_get_contents($flag), true); + self::assertArrayHasKey('plugin', $context); + self::assertNull($context['plugin']); + self::assertSame('Core failure', $context['message']); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileDoesNotExist($quarantine); + } + public function testHandleShutdownIgnoresNonFatalErrors(): void { $manager = new class($this->tmpDir) extends RecoveryManager { From 198271727288cfa24478bd9c81662a01c299b290 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 18:36:50 -0600 Subject: [PATCH 04/10] more recovery manage fixes Signed-off-by: Andy Miller --- .../Grav/Common/Recovery/RecoveryManager.php | 103 ++++++++++++++---- .../Common/Recovery/RecoveryManagerTest.php | 33 ++++++ 2 files changed, 117 insertions(+), 19 deletions(-) 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 { From 23d92e6a41e5e087d9f598b8fbe8e974e106db35 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 20:47:34 -0600 Subject: [PATCH 05/10] jump into recovery mode Signed-off-by: Andy Miller --- index.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/index.php b/index.php index efb32572a..becef1a73 100644 --- a/index.php +++ b/index.php @@ -97,6 +97,13 @@ $grav = Grav::instance(array('loader' => $loader)); try { $grav->process(); } catch (\Error|\Exception $e) { - $grav->fireEvent('onFatalException', new Event(array('exception' => $e))); + $grav->fireEvent('onFatalException', new Event(['exception' => $e])); + + if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) { + require __DIR__ . '/system/recovery.php'; + return 0; + } + throw $e; } + From 65689101ab32fbc6921b91aabc7979fb7cb7af98 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Sun, 19 Oct 2025 21:09:16 -0600 Subject: [PATCH 06/10] fix recovery mode Signed-off-by: Andy Miller --- system/recovery.php | 52 ++++++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/system/recovery.php b/system/recovery.php index 9c62903dd..6427a8091 100644 --- a/system/recovery.php +++ b/system/recovery.php @@ -63,21 +63,37 @@ if (is_file($quarantineFile)) { } $manifestDir = GRAV_ROOT . '/user/data/upgrades'; -$manifests = []; +$snapshots = []; if (is_dir($manifestDir)) { $files = glob($manifestDir . '/*.json'); if ($files) { - rsort($files); foreach ($files as $file) { $decoded = json_decode(file_get_contents($file), true); - if (is_array($decoded)) { - $decoded['file'] = basename($file); - $manifests[] = $decoded; + if (!is_array($decoded)) { + continue; } + + $id = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME); + if (!is_string($id) || $id === '' || strncmp($id, 'snapshot-', 9) !== 0) { + continue; + } + + $decoded['id'] = $id; + $decoded['file'] = basename($file); + $decoded['created_at'] = (int)($decoded['created_at'] ?? filemtime($file) ?: 0); + $snapshots[] = $decoded; + } + + if ($snapshots) { + usort($snapshots, static function (array $a, array $b): int { + return ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0); + }); } } } +$latestSnapshot = $snapshots[0] ?? null; + header('Content-Type: text/html; charset=utf-8'); ?> @@ -89,7 +105,8 @@ header('Content-Type: text/html; charset=utf-8');