mirror of
https://github.com/getgrav/grav.git
synced 2025-10-26 00:46:07 +02:00
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user