feature #10725 [Debug] Handled errors (nicolas-grekas)

This PR was squashed before being merged into the 2.5-dev branch (closes #10725).

Discussion
----------

[Debug] Handled errors

| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| BC breaks?    | no
| Deprecations? | yes
| Tests pass?   | yes
| Fixed tickets | none
| License       | MIT
| Doc PR        | none

Enhance error handling and thus displaying for catchable PHP errors.
Code is tricky thanks to https://bugs.php.net/54275

Before:

![capture du 2014-04-17 10 03 40](https://cloud.githubusercontent.com/assets/243674/2729324/4de3eedc-c607-11e3-8b23-c88657bbecd0.png)

After:

![capture du 2014-04-17 10 03 14](https://cloud.githubusercontent.com/assets/243674/2729326/534ec504-c607-11e3-82dd-49b7770b1e75.png)

Commits
-------

0279fbf [Debug] Handled errors
This commit is contained in:
Fabien Potencier 2014-04-19 10:12:20 +02:00
commit 27e1a893a0
22 changed files with 297 additions and 90 deletions

View File

@ -9,6 +9,7 @@
<parameter key="debug.stopwatch.class">Symfony\Component\Stopwatch\Stopwatch</parameter>
<parameter key="debug.container.dump">%kernel.cache_dir%/%kernel.container_class%.xml</parameter>
<parameter key="debug.controller_resolver.class">Symfony\Component\HttpKernel\Controller\TraceableControllerResolver</parameter>
<parameter key="debug.fatal_error_exceptions_listener.class">Symfony\Component\HttpKernel\EventListener\FatalErrorExceptionsListener</parameter>
</parameters>
<services>
@ -39,5 +40,13 @@
<argument>scream</argument>
<argument type="service" id="logger" on-invalid="null" />
</service>
<service id="debug.fatal_error_exceptions_listener" class="%debug.fatal_error_exceptions_listener.class%">
<tag name="kernel.event_subscriber" />
<argument type="collection">
<argument type="service" id="http_kernel" on-invalid="null" />
<argument>handleFatalErrorException</argument>
</argument>
</service>
</services>
</container>

View File

@ -21,7 +21,7 @@
"symfony/config" : "~2.4",
"symfony/event-dispatcher": "~2.5",
"symfony/http-foundation": "~2.4",
"symfony/http-kernel": "~2.4",
"symfony/http-kernel": "~2.5",
"symfony/filesystem": "~2.3",
"symfony/routing": "~2.2",
"symfony/security-core": "~2.4",

View File

@ -4,7 +4,12 @@ CHANGELOG
2.5.0
-----
* added HandledErrorException
* added ErrorHandler::setFatalErrorExceptionHandler()
* added UndefinedMethodFatalErrorHandler
* deprecated ExceptionHandlerInterface
* deprecated ContextErrorException
* deprecated DummyException
2.4.0
-----

View File

@ -13,9 +13,9 @@ namespace Symfony\Component\Debug;
use Psr\Log\LogLevel;
use Psr\Log\LoggerInterface;
use Symfony\Component\Debug\Exception\ContextErrorException;
use Symfony\Component\Debug\Exception\FatalErrorException;
use Symfony\Component\Debug\Exception\DummyException;
use Symfony\Component\Debug\Exception\HandledErrorException;
use Symfony\Component\Debug\FatalErrorHandler\UndefinedFunctionFatalErrorHandler;
use Symfony\Component\Debug\FatalErrorHandler\UndefinedMethodFatalErrorHandler;
use Symfony\Component\Debug\FatalErrorHandler\ClassNotFoundFatalErrorHandler;
@ -63,6 +63,8 @@ class ErrorHandler
private static $stackedErrorLevels = array();
private static $fatalHandler = false;
/**
* Registers the error handler.
*
@ -117,7 +119,17 @@ class ErrorHandler
}
/**
* @throws ContextErrorException When error_reporting returns error
* Sets a fatal error exception handler.
*
* @param callable $handler An handler that will be called on FatalErrorException
*/
public static function setFatalErrorExceptionHandler($handler)
{
self::$fatalHandler = $handler;
}
/**
* @throws HandledErrorException When error_reporting returns error
*/
public function handle($level, $message, $file = 'unknown', $line = 0, $context = array())
{
@ -152,7 +164,7 @@ class ErrorHandler
$exceptionHandler = set_exception_handler('var_dump');
restore_exception_handler();
if (is_array($exceptionHandler) && $exceptionHandler[0] instanceof ExceptionHandlerInterface) {
if ($exceptionHandler) {
if (self::$stackedErrorLevels) {
self::$stackedErrors[] = func_get_args();
@ -160,22 +172,22 @@ class ErrorHandler
}
$exception = sprintf('%s: %s in %s line %d', isset($this->levels[$level]) ? $this->levels[$level] : $level, $message, $file, $line);
$exception = new ContextErrorException($exception, 0, $level, $file, $line, $context);
$exceptionHandler[0]->handle($exception);
$exception = new HandledErrorException($exception, 0, $level, $file, $line, $context);
$exception->handleWith($exceptionHandler);
// we must stop the PHP script execution, as the exception has
// already been dealt with, so, let's throw an exception that
// will be caught by a dummy exception handler
set_exception_handler(function (\Exception $e) use ($exceptionHandler) {
if (!$e instanceof DummyException) {
// happens if our dummy exception is caught by a
// catch-all from user code, in which case, let's the
if (!$e instanceof HandledErrorException && !$e instanceof DummyException) {
// happens if our handled exception is caught by a
// catch-all from user code, in which case, let the
// current handler handle this "new" exception
call_user_func($exceptionHandler, $e);
}
});
throw new DummyException();
throw $exception;
}
}
@ -256,6 +268,7 @@ class ErrorHandler
public function handleFatal()
{
$this->reservedMemory = '';
gc_collect_cycles();
$error = error_get_last();
while (self::$stackedErrorLevels) {
@ -281,16 +294,14 @@ class ErrorHandler
self::$loggers['emergency']->emergency($error['message'], $fatal);
}
if (!$this->displayErrors) {
return;
}
if ($this->displayErrors) {
// get current exception handler
$exceptionHandler = set_exception_handler('var_dump');
restore_exception_handler();
// get current exception handler
$exceptionHandler = set_exception_handler('var_dump');
restore_exception_handler();
if (is_array($exceptionHandler) && $exceptionHandler[0] instanceof ExceptionHandlerInterface) {
$this->handleFatalError($exceptionHandler[0], $error);
if ($exceptionHandler || self::$fatalHandler) {
$this->handleFatalError($exceptionHandler, $error);
}
}
}
@ -310,18 +321,25 @@ class ErrorHandler
);
}
private function handleFatalError(ExceptionHandlerInterface $exceptionHandler, array $error)
private function handleFatalError($exceptionHandler, array $error)
{
$level = isset($this->levels[$error['type']]) ? $this->levels[$error['type']] : $error['type'];
$message = sprintf('%s: %s in %s line %d', $level, $error['message'], $error['file'], $error['line']);
$exception = new FatalErrorException($message, 0, $error['type'], $error['file'], $error['line']);
$exception = new FatalErrorException($message, 0, $error['type'], $error['file'], $error['line'], 3);
foreach ($this->getFatalErrorHandlers() as $handler) {
if ($ex = $handler->handleError($error, $exception)) {
return $exceptionHandler->handle($ex);
$exception = $ex;
break;
}
}
$exceptionHandler->handle($exception);
if ($exceptionHandler) {
$exception->handleWith($exceptionHandler);
}
if (self::$fatalHandler) {
call_user_func(self::$fatalHandler, $exception);
}
}
}

View File

@ -28,5 +28,6 @@ class ClassNotFoundException extends FatalErrorException
$previous->getLine(),
$previous->getPrevious()
);
$this->setTrace($previous->getTrace());
}
}

View File

@ -15,22 +15,9 @@ namespace Symfony\Component\Debug\Exception;
* Error Exception with Variable Context.
*
* @author Christian Sciberras <uuf6429@gmail.com>
*
* @deprecated since version 2.5, to be removed in 3.0.
*/
class ContextErrorException extends \ErrorException
class ContextErrorException extends HandledErrorException
{
private $context = array();
public function __construct($message, $code, $severity, $filename, $lineno, $context = array())
{
parent::__construct($message, $code, $severity, $filename, $lineno);
$this->context = $context;
}
/**
* @return array Array of variables that existed when the exception occurred
*/
public function getContext()
{
return $this->context;
}
}

View File

@ -12,9 +12,9 @@
namespace Symfony\Component\Debug\Exception;
/**
* Used to stop execution of a PHP script after handling a fatal error.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @deprecated since version 2.5, to be removed in 3.0.
*/
class DummyException extends \ErrorException
{

View File

@ -14,8 +14,54 @@ namespace Symfony\Component\Debug\Exception;
/**
* Fatal Error Exception.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Konstanton Myakshin <koc-dp@yandex.ru>
* @author Nicolas Grekas <p@tchwork.com>
*/
class FatalErrorException extends \ErrorException
class FatalErrorException extends HandledErrorException
{
public function __construct($message, $code, $severity, $filename, $lineno, $traceOffset = null)
{
parent::__construct($message, $code, $severity, $filename, $lineno);
if (null !== $traceOffset) {
if (function_exists('xdebug_get_function_stack')) {
$trace = xdebug_get_function_stack();
if (0 < $traceOffset) {
$trace = array_slice($trace, 0, -$traceOffset);
}
$trace = array_reverse($trace);
foreach ($trace as $i => $frame) {
if (!isset($frame['type'])) {
// XDebug pre 2.1.1 doesn't currently set the call type key http://bugs.xdebug.org/view.php?id=695
if (isset($frame['class'])) {
$trace[$i]['type'] = '::';
}
} elseif ('dynamic' === $frame['type']) {
$trace[$i]['type'] = '->';
} elseif ('static' === $frame['type']) {
$trace[$i]['type'] = '::';
}
// XDebug also has a different name for the parameters array
if (isset($frame['params']) && !isset($frame['args'])) {
$trace[$i]['args'] = $frame['params'];
unset($trace[$i]['params']);
}
}
} else {
$trace = array();
}
$this->setTrace($trace);
}
}
protected function setTrace($trace)
{
$traceReflector = new \ReflectionProperty('Exception', 'trace');
$traceReflector->setAccessible(true);
$traceReflector->setValue($this, $trace);
}
}

View File

@ -172,36 +172,7 @@ class FlattenException
public function setTraceFromException(\Exception $exception)
{
$trace = $exception->getTrace();
if ($exception instanceof FatalErrorException) {
if (function_exists('xdebug_get_function_stack')) {
$trace = array_slice(array_reverse(xdebug_get_function_stack()), 4);
foreach ($trace as $i => $frame) {
// XDebug pre 2.1.1 doesn't currently set the call type key http://bugs.xdebug.org/view.php?id=695
if (!isset($frame['type'])) {
$trace[$i]['type'] = '??';
}
if ('dynamic' === $trace[$i]['type']) {
$trace[$i]['type'] = '->';
} elseif ('static' === $trace[$i]['type']) {
$trace[$i]['type'] = '::';
}
// XDebug also has a different name for the parameters array
if (isset($frame['params']) && !isset($frame['args'])) {
$trace[$i]['args'] = $frame['params'];
unset($trace[$i]['params']);
}
}
} else {
$trace = array_slice(array_reverse($trace), 1);
}
}
$this->setTrace($trace, $exception->getFile(), $exception->getLine());
$this->setTrace($exception->getTrace(), $exception->getFile(), $exception->getLine());
}
public function setTrace($trace, $file, $line)

View File

@ -0,0 +1,86 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Debug\Exception;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class HandledErrorException extends \ErrorException
{
private $handlerOutput = false;
private $context = array();
public function __construct($message, $code, $severity, $filename, $lineno, $context = array())
{
parent::__construct($message, $code, $severity, $filename, $lineno);
$this->context = $context;
}
/**
* @return array Array of variables that existed when the exception occurred
*/
public function getContext()
{
return $this->context;
}
public function handleWith($exceptionHandler)
{
$this->handlerOutput = false;
ob_start(array($this, 'catchOutput'));
call_user_func($exceptionHandler, $this);
if (false === $this->handlerOutput) {
ob_end_clean();
}
ob_start(array(__CLASS__, 'flushOutput'));
echo $this->handlerOutput;
$this->handlerOutput = ob_get_length();
}
/**
* @internal
*/
public function catchOutput($buffer)
{
$this->handlerOutput = $buffer;
return '';
}
/**
* @internal
*/
public static function flushOutput($buffer)
{
return $buffer;
}
public function cleanOutput()
{
$status = ob_get_status();
if (isset($status['name']) && __CLASS__.'::flushOutput' === $status['name']) {
if ($this->handlerOutput) {
// use substr_replace() instead of substr() for mbstring overloading resistance
echo substr_replace(ob_get_clean(), '', 0, $this->handlerOutput);
} else {
ob_end_flush();
}
}
}
public function __destruct()
{
$this->handlerOutput = 0;
$this->cleanOutput();
}
}

View File

@ -28,5 +28,6 @@ class UndefinedFunctionException extends FatalErrorException
$previous->getLine(),
$previous->getPrevious()
);
$this->setTrace($previous->getTrace());
}
}

View File

@ -20,6 +20,14 @@ class UndefinedMethodException extends FatalErrorException
{
public function __construct($message, \ErrorException $previous)
{
parent::__construct($message, $previous->getCode(), $previous->getSeverity(), $previous->getFile(), $previous->getLine(), $previous->getPrevious());
parent::__construct(
$message,
$previous->getCode(),
$previous->getSeverity(),
$previous->getFile(),
$previous->getLine(),
$previous->getPrevious()
);
$this->setTrace($previous->getTrace());
}
}

View File

@ -71,7 +71,9 @@ class ExceptionHandler implements ExceptionHandlerInterface
public function handle(\Exception $exception)
{
if (class_exists('Symfony\Component\HttpFoundation\Response')) {
$this->createResponse($exception)->send();
$response = $this->createResponse($exception);
$response->sendHeaders();
$response->sendContent();
} else {
$this->sendPhpResponse($exception);
}

View File

@ -15,6 +15,8 @@ namespace Symfony\Component\Debug;
* An ExceptionHandler does something useful with an exception.
*
* @author Andrew Moore <me@andrewmoore.ca>
*
* @deprecated since version 2.5, to be removed in 3.0.
*/
interface ExceptionHandlerInterface
{

View File

@ -78,14 +78,14 @@ class DebugClassLoaderTest extends \PHPUnit_Framework_TestCase
}
/**
* @expectedException \Symfony\Component\Debug\Exception\DummyException
* @expectedException \Symfony\Component\Debug\Exception\HandledErrorException
*/
public function testStacking()
{
// the ContextErrorException must not be loaded to test the workaround
// the HandledErrorException must not be loaded to test the workaround
// for https://bugs.php.net/65322.
if (class_exists('Symfony\Component\Debug\Exception\ContextErrorException', false)) {
$this->markTestSkipped('The ContextErrorException class is already loaded.');
if (class_exists('Symfony\Component\Debug\Exception\HandledErrorException', false)) {
$this->markTestSkipped('The HandledErrorException class is already loaded.');
}
$exceptionHandler = $this->getMock('Symfony\Component\Debug\ExceptionHandler', array('handle'));
@ -93,7 +93,7 @@ class DebugClassLoaderTest extends \PHPUnit_Framework_TestCase
$that = $this;
$exceptionCheck = function ($exception) use ($that) {
$that->assertInstanceOf('Symfony\Component\Debug\Exception\ContextErrorException', $exception);
$that->assertInstanceOf('Symfony\Component\Debug\Exception\HandledErrorException', $exception);
$that->assertEquals(E_STRICT, $exception->getSeverity());
$that->assertStringStartsWith(__FILE__, $exception->getFile());
$that->assertRegexp('/^Runtime Notice: Declaration/', $exception->getMessage());
@ -107,7 +107,7 @@ class DebugClassLoaderTest extends \PHPUnit_Framework_TestCase
try {
// Trigger autoloading + E_STRICT at compile time
// which in turn triggers $errorHandler->handle()
// that again triggers autoloading for ContextErrorException.
// that again triggers autoloading for HandledErrorException.
// Error stacking works around the bug above and everything is fine.
eval('

View File

@ -12,7 +12,7 @@
namespace Symfony\Component\Debug\Tests;
use Symfony\Component\Debug\ErrorHandler;
use Symfony\Component\Debug\Exception\DummyException;
use Symfony\Component\Debug\Exception\HandledErrorException;
/**
* ErrorHandlerTest
@ -51,7 +51,7 @@ class ErrorHandlerTest extends \PHPUnit_Framework_TestCase
$that = $this;
$exceptionCheck = function ($exception) use ($that) {
$that->assertInstanceOf('Symfony\Component\Debug\Exception\ContextErrorException', $exception);
$that->assertInstanceOf('Symfony\Component\Debug\Exception\HandledErrorException', $exception);
$that->assertEquals(E_NOTICE, $exception->getSeverity());
$that->assertEquals(__FILE__, $exception->getFile());
$that->assertRegexp('/^Notice: Undefined variable: (foo|bar)/', $exception->getMessage());
@ -80,7 +80,7 @@ class ErrorHandlerTest extends \PHPUnit_Framework_TestCase
try {
self::triggerNotice($this);
} catch (DummyException $e) {
} catch (HandledErrorException $e) {
// if an exception is thrown, the test passed
} catch (\Exception $e) {
restore_error_handler();
@ -213,7 +213,7 @@ class ErrorHandlerTest extends \PHPUnit_Framework_TestCase
$m = new \ReflectionMethod($handler, 'handleFatalError');
$m->setAccessible(true);
$m->invoke($handler, $exceptionHandler, $error);
$m->invoke($handler, array($exceptionHandler, 'handle'), $error);
$this->assertInstanceof($class, $exceptionHandler->e);
// class names are case insensitive and PHP/HHVM do not return the same

View File

@ -11,8 +11,8 @@
namespace Symfony\Component\HttpKernel\DataCollector;
use Symfony\Component\Debug\ErrorHandler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Debug\ErrorHandler;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Log\DebugLoggerInterface;

View File

@ -12,7 +12,7 @@
namespace Symfony\Component\HttpKernel\EventListener;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Debug\ErrorHandler;
use Symfony\Component\Debug\ErrorHandler;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\EventListener;
use Symfony\Component\Debug\ErrorHandler;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Injects a fatal error exceptions handler into the ErrorHandler.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class FatalErrorExceptionsListener implements EventSubscriberInterface
{
private $handler = null;
public function __construct($handler)
{
if (is_callable($handler)) {
$this->handler = $handler;
}
}
public function injectHandler()
{
if ($this->handler) {
ErrorHandler::setFatalErrorExceptionHandler($this->handler);
}
}
public static function getSubscribedEvents()
{
return array(KernelEvents::REQUEST => 'injectHandler');
}
}

View File

@ -18,6 +18,7 @@ use Symfony\Component\HttpKernel\Controller\ControllerReference;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Debug\Exception\HandledErrorException;
/**
* Implements the inline rendering strategy where the Request is rendered by the current HTTP kernel.
@ -86,10 +87,15 @@ class InlineFragmentRenderer extends RoutableFragmentRenderer
} catch (\Exception $e) {
// we dispatch the exception event to trigger the logging
// the response that comes back is simply ignored
if (isset($options['ignore_errors']) && $options['ignore_errors'] && $this->dispatcher) {
$event = new GetResponseForExceptionEvent($this->kernel, $request, HttpKernelInterface::SUB_REQUEST, $e);
if (isset($options['ignore_errors']) && $options['ignore_errors']) {
if ($e instanceof HandledErrorException) {
$e->cleanOutput();
}
if ($this->dispatcher) {
$event = new GetResponseForExceptionEvent($this->kernel, $request, HttpKernelInterface::SUB_REQUEST, $e);
$this->dispatcher->dispatch(KernelEvents::EXCEPTION, $event);
$this->dispatcher->dispatch(KernelEvents::EXCEPTION, $event);
}
}
// let's clean up the output buffers that were created by the sub-request

View File

@ -25,6 +25,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Debug\Exception\HandledErrorException;
use Symfony\Component\Debug\Exception\FatalErrorException;
/**
* HttpKernel notifies events to convert a Request object to a Response one.
@ -70,6 +72,9 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
throw $e;
}
if ($e instanceof HandledErrorException) {
$e->cleanOutput();
}
return $this->handleException($e, $request, $type);
}
@ -85,6 +90,21 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
$this->dispatcher->dispatch(KernelEvents::TERMINATE, new PostResponseEvent($this, $request, $response));
}
/**
* @internal
*/
public function handleFatalErrorException(FatalErrorException $exception)
{
$request = $this->requestStack->getMasterRequest();
$response = $this->handleException($exception, $request, self::MASTER_REQUEST);
$response->sendHeaders();
$response->sendContent();
$this->terminate($request, $response);
$exception->cleanOutput();
}
/**
* Handles a request to convert it to a response.
*

View File

@ -19,7 +19,7 @@
"php": ">=5.3.3",
"symfony/event-dispatcher": "~2.1",
"symfony/http-foundation": "~2.4",
"symfony/debug": "~2.3",
"symfony/debug": "~2.5",
"psr/log": "~1.0"
},
"require-dev": {