rfc:engine_exceptions

This is an old revision of the document!


PHP RFC: Exceptions in the engine

Introduction

This RFC proposes to allow the use of exceptions in the engine and to allow the replacement of existing fatal or recoverable fatal errors with exceptions.

As an example of this change, consider the following the following code-snippet:

<?php
 
function call_method($obj) {
    $obj->method();
}
 
call_method(null); // oops!

Currently the above code will throw a fatal error:

Fatal error: Call to a member function method() on a non-object in /path/file.php on line 4

This RFC replaces the fatal error with an EngineException. Unless the exception is caught this will still result in a fatal error:

Fatal error: Uncaught exception 'EngineException' with message 'Call to a member function method() on a non-object' in /path/file.php:4
Stack trace:
#0 /path/file.php(7): call_method(NULL)
#1 {main}
  thrown in /path/file.php on line 4

Of course it is also possible to catch this exception:

try {
    call_method(null); // oops!
} catch (EngineException $e) {
    echo "Exception: {$e->getMessage()}\n";
}
 
// Exception: Call to a member function method() on a non-object

Motivation

Summary of current error model

PHP currently supports 16 different error types which are listed below, grouped by severity:

// Fatal errors
E_ERROR
E_CORE_ERROR
E_COMPILE_ERROR
E_PARSE
E_USER_ERROR

// Recoverable fatal errors
E_RECOVERABLE_ERROR

// Warnings
E_WARNING
E_CORE_WARNING
E_COMPILE_WARNING
E_USER_WARNING

// Notices etc.
E_DEPRECATED
E_USER_DEPRECATED
E_NOTICE
E_USER_NOTICE
E_STRICT

The first five errors are fatal, i.e. they will not invoke the error handler, abort execution in the current context and directly jump (bailout) to the shutdown procedure. After a fatal error shutdown functions and destructors are still run.

The E_RECOVERABLE_ERROR error type behaves like a fatal error by default, but it will invoke the error handler, which can instruct the engine to ignore the error and continue execution in the context where the error was raised.

The remaining errors are all non-fatal, i.e. execution continues normally after they occur. The error handler is invoked for all error types apart from E_CORE_WARNING and E_COMPILE_WARNING.

Issues with fatal errors

Cannot be gracefully handled

The most obvious issue with fatal errors is that they immidiately abort execution and as such cannot be gracefully recovered from, which is very problematic in some situations.

As an example consider a server or daemon written in PHP. If a fatal error occurs during the handling of a request it will abort not only that individual request but kill the entire server/daemon. It would be much preferable to catch the fatal error and abort the request it originated from, but continue to handle other requests.

Another example is running tests in PHPUnit: If a test throws a fatal error this will abort the whole test-run. It would be more desirable to mark the individual test as failed, but continue running the rest of the testsuite.

Error handler is not called

Fatal errors do not invoke the error handler and as such it is hard to apply custom error handling procedures (for display, logging, mailing, ...) to them. The only way to handle a fatal error is through a shutdown function:

register_shutdown_function(function() { var_dump(error_get_last()); });
 
$null = null;
$null->foo();
 
// shutdown function output:
array(4) {
  ["type"]=> int(1)
  ["message"]=> string(47) "Call to a member function foo() on a non-object"
  ["file"]=> ...
  ["line"]=> ...
}

This allows rudimentary handling of fatal errors, but the available information is very limited. In particular the shutdown function is not able to retreive a stacktrace for the error (which is possible for other error types going through the error handler.)

Finally blocks will not be invoked

If a fatal error occurs finally blocks will not be invoked:

$lock->aquire();
try {
    doSomething();
} finally {
    $lock->release();
}

If doSomething() in the above example results in a fatal error the finally block will not be run and the lock is not released.

Destructors are not called

When a fatal error occurs destructors are not invoked. This means that anything relying on the RAII (Resource Aquisition Is Initialization) will break. Using the lock example again:

class LockManager {
    private $lock;
    public function __construct(Lock $lock) {
        $this->lock = $lock;
        $this->lock->aquire();
    }
    public function __destruct() {
        $this->lock->release();
    }
}
 
function test($lock) {
    $manager = new LockManager($lock); // aquire lock
 
    doSomething();
 
    // automatically release lock via dtor
}

If doSomething() in the above example throws a fatal error the destructor of LockManager is not called and as such the lock is not released.

As both finally blocks and destructors fail in face of fatal errors the only reasonably robust way of releasing critical resources is to use a global registry combined with a shutdown function.

Issues with recoverable fatal errors

After acknowledging that the use of fatal errors is problematic, one might suggest to convert fatal errors to recoverable fatal errors where possible. Sadly this also has several issues:

Execution is continued in same context

When a recoverable fatal error is dismissed by a custom error handler, execution is continued as if the error never happened. From a core developer perspective this means that a recoverable fatal error needs to be implemented in the same way as a warning is, with the assumption that the following code will still be run.

This makes it technically complicated to convert fatal errors into recoverable errors, because fatal errors are typically thrown in situation where continuing execution in the current codepath is not possible. For example the use of recoverable errors in argument sending would likely require manual stack and call slot cleanup as well as figuring out which code to run after the error.

Hard to catch

While E_RECOVERABLE_ERROR is presented as a “Catchable fatal error” to the end user the error is actually rather hard to catch. In particular the familiar try/catch structure cannot be used and instead an error handler needs to be employed.

To catch a recoverable fatal error non-intrusively code along the following lines is necessary:

$oldErrorHandler = set_error_handler(function($errno, $errstr, $errfile, $errline) {
	if ($errno === E_RECOVERABLE_ERROR) {
		throw new ErrorException($errstr, $errno, 0, $errfile, $errline);
	}
	return false;
});
 
try {
    new Closure;
} catch (Exception $e) {
	echo "Caught: {$e->getMessage()}\n";
}
 
set_error_handler($oldErrorHandler);

Solution: Exceptions

Exceptions provide an approach to error handling that does not suffer from the problems of fatal errors and recoverable errors. In particular exceptions can be gracefully handled, they will invoke finally blocks and destructors and are easily caught using catch blocks.

From an implementational point of view they also form a middle group between fatal errors (abort execution) and recoverable fatal errors (continue in the same codepath). Exception typically leave the current codepath right away and make use of automatic cleanup mechanisms (e.g. there is no need to manually clean up the stack). In order to throw an exception from the VM you usually only need to free the opcode operands and invoke HANDLE_EXCEPTION().

Exceptions have the additional advantage of providing a stack trace.

Proposal

This proposal consists of two parts: Several general policy changes, as well as particular technical changes.

Policy changes

The RFC proposes the following policy changes:

  • It is now allowed to use exceptions in the engine.
  • Exceptions originating from the engine should be of type EngineException, but can also use a different type in justifiable exceptional cases (e.g. ExpectationException in expectations).
  • Existing errors of type E_ERROR or E_RECOVERABLE_ERROR can be converted to exceptions.
  • It is discouraged to introduce new errors of type E_ERROR or E_RECOVERABLE_ERROR. Within limits of technical feasibility the use of exceptions is preferred.

Technical changes

A new class EngineException extends Exception is introduced. It exhibits the same behavior as the ordinary Exception class, but skips one stack frame (this is necessary to produce correct stack traces when throwing directly from the VM).

Internally the following APIs are added:

// Returns the class_entry for EngineException
ZEND_API zend_class_entry *zend_get_engine_exception(TSRMLS_D);

// Throws an EngineException with a simple message
ZEND_API void zend_throw_engine_exception(const char *message TSRMLS_DC);

// Throws an EngineException with a printf-style message
ZEND_API void zend_throw_engine_exception_ex(const char *format TSRMLS_DC, ...);

// Example of the last API:
zend_throw_engine_exception_ex("Undefined function '%s'" TSRMLS_CC, function_name);

Exceptions sometimes need to be thrown before all opcode operands are fetched. In this case the operands still need to freed, but the ordinary FREE_OP* VM pseudo-macros cannot be used. To solve several VM-macros/functions are introduced:

// optype-specialized pseudo-macros
FREE_UNFETCHED_OP1();
FREE_UNFETCHED_OP2();

// Used for frees in two-op instructions
static zend_always_inline void _free_unfetched_op(int op_type, znode_op *op, const zend_execute_data *execute_data TSRMLS_DC);

Furthermore the patch accompanying this RFC also contains initial work for moving off E_ERROR/E_RECOVERABLE_ERROR. In particular it removes all uses of E_ERROR in zend_vm_def.h.

Potential issues

E_RECOVERABLE_ERROR compatibility

Currently it is possible to silently ignore recoverable fatal errors with a custom error handler. By replacing them with exceptions this capability is removed, thus breaking compatibility.

I have never seen this possibility used in practice outside some weird hacks (which use ignored recoverable type constraint errors to implement scalar typehints). In most cases custom error handlers throw an ErrorException, in which case it emulates the proposed behavior but using a different exception type.

If these concerns are considered significant this RFC might be restricted to E_ERROR conversions only. Personally I doubt that this will result in any significant breakage, but I can't claim extensive knowledge in this area.

catch-all blocks in existing code

As EngineException extends Exception it will be caught by catch-blocks of type catch (Exception). This may cause to cause existing code to inadvertantly catch engine exceptions.

If this is considered to be an issue a solution is to introduce a BaseException with Exception extends BaseException, which will be the new base of the exception hierarchy. This exception type would be used only for exception types that are “unlikely to require catching” in anything save top-level handlers. Both Python (BaseException) and Java (Throwable) make use of this concept.

EngineException could then extend BaseException rather than Exception.

Cluttered error messages

Going back to the code-sample from the introduction, this is the fatal error that is currently thrown:

Fatal error: Call to a member function method() on a non-object in /path/file.php on line 4

With this RFC the error changes into an uncaught exception:

Fatal error: Uncaught exception 'EngineException' with message 'Call to a member function method() on a non-object' in /path/file.php:4
Stack trace:
#0 /path/file.php(7): call_method(NULL)
#1 {main}
  thrown in /path/file.php on line 4

The uncaught exception message provides more information, e.g. it includes a stack-trace which is helpful when debugging the error, but it is also rather cluttered. Especially when working on the terminal the long Fatal error: Uncaught exception 'EngineException' with message prefix pushes the actual message so far to the right that it has to wrap. Things also become quite confusing when the exception message contains quotes itself.

I think it would be nice to make those messages a bit cleaner (for all exceptions). The following adjustment is simple to do and seems more readable to me:

Fatal error: Uncaught EngineException: Call to a member function method() on a non-object in /path/file.php on line 4
Stack trace:
#0 /path/file.php(7): call_method(NULL)
#1 {main}
  thrown in /path/file.php on line 4

Additional improvement (like removing the Fatal error: prefix and the duplicate file/line information would require special handling in zend_error:

Uncaught EngineException: Call to a member function method() on a non-object in /path/file.php on line 4
Stack trace:
#0 /path/file.php(7): call_method(NULL)
#1 {main}

Patch

A preliminary patch for this RFC is available at https://github.com/nikic/php-src/compare/engineExceptions.

The patch introduces basic infrastructure for this change and removes all E_ERROR uses from zend_vm_def.h (as well as some other errors). This is just preliminary work and hopefully more errors can be covered by the time this is merged.

rfc/engine_exceptions.1382552616.txt.gz · Last modified: 2017/09/22 13:28 (external edit)