more recovery manage fixes

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-10-19 18:36:50 -06:00
parent e82a0ce8bd
commit 1982717272
2 changed files with 117 additions and 19 deletions

View File

@@ -10,7 +10,9 @@
namespace Grav\Common\Recovery; namespace Grav\Common\Recovery;
use Grav\Common\Filesystem\Folder; use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Yaml; use Grav\Common\Yaml;
use RocketTheme\Toolbox\Event\Event;
use function bin2hex; use function bin2hex;
use function dirname; use function dirname;
use function file_get_contents; use function file_get_contents;
@@ -32,6 +34,7 @@ use const E_COMPILE_ERROR;
use const E_CORE_ERROR; use const E_CORE_ERROR;
use const E_ERROR; use const E_ERROR;
use const E_PARSE; use const E_PARSE;
use const E_USER_ERROR;
use const GRAV_ROOT; use const GRAV_ROOT;
use const JSON_PRETTY_PRINT; use const JSON_PRETTY_PRINT;
use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_SLASHES;
@@ -47,6 +50,8 @@ class RecoveryManager
private $rootPath; private $rootPath;
/** @var string */ /** @var string */
private $userPath; private $userPath;
/** @var bool */
private $failureCaptured = false;
/** /**
* @param mixed $context Container or root path. * @param mixed $context Container or root path.
@@ -77,6 +82,15 @@ class RecoveryManager
} }
register_shutdown_function([$this, 'handleShutdown']); 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; $this->registered = true;
} }
@@ -103,6 +117,7 @@ class RecoveryManager
} }
$this->closeUpgradeWindow(); $this->closeUpgradeWindow();
$this->failureCaptured = false;
} }
/** /**
@@ -112,35 +127,49 @@ class RecoveryManager
*/ */
public function handleShutdown(): void public function handleShutdown(): void
{ {
if ($this->failureCaptured) {
return;
}
$error = $this->resolveLastError(); $error = $this->resolveLastError();
if (!$error) { if (!$error) {
return; return;
} }
$type = $error['type'] ?? 0; $this->processFailure($error);
if (!$this->isFatal($type)) { }
/**
* Handle uncaught exceptions bubbled to the top-level handler.
*
* @param \Throwable $exception
* @return void
*/
public function handleException(\Throwable $exception): void
{
if ($this->failureCaptured) {
return; return;
} }
$file = $error['file'] ?? ''; $error = [
$plugin = $this->detectPluginFromPath($file); 'type' => E_ERROR,
'message' => $exception->getMessage(),
$context = [ 'file' => $exception->getFile(),
'created_at' => time(), 'line' => $exception->getLine(),
'message' => $error['message'] ?? '',
'file' => $file,
'line' => $error['line'] ?? null,
'type' => $type,
'plugin' => $plugin,
]; ];
if (!$this->shouldEnterRecovery($context)) { $this->processFailure($error);
return; }
}
$this->activate($context); /**
if ($plugin) { * @param Event $event
$this->quarantinePlugin($plugin, $context); * @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. * Return last recorded recovery context.
* *
@@ -265,7 +329,7 @@ class RecoveryManager
*/ */
private function isFatal(int $type): bool 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(); $path = $this->windowPath();
Folder::create(dirname($path)); Folder::create(dirname($path));
file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
$this->failureCaptured = false;
} }
/** /**

View File

@@ -2,6 +2,7 @@
use Grav\Common\Filesystem\Folder; use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager; use Grav\Common\Recovery\RecoveryManager;
use RocketTheme\Toolbox\Event\Event;
class RecoveryManagerTest extends \Codeception\TestCase\Test class RecoveryManagerTest extends \Codeception\TestCase\Test
{ {
@@ -120,6 +121,38 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test
self::assertFileDoesNotExist($quarantine); 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 public function testHandleShutdownIgnoresNonFatalErrors(): void
{ {
$manager = new class($this->tmpDir) extends RecoveryManager { $manager = new class($this->tmpDir) extends RecoveryManager {