#304924: extends drupal_error_handler to manage exceptions.

From: Damien Tournoud <damien@tournoud.net>

- Implement a _drupal_exception_handler, with a reimplementation of the "skip internal database functions" we had pre DB:TNG
- Now honors the PHP error_reporting parameter (a sane default is set in settings.php, but could be changed by the user), and clears the html_errors parameter.
- Mark the handlers as private.
- Add supports for displaying functions and method names in the error message.
- General lifting of that key part of Drupal.
- Now with an unit test.
---

 includes/bootstrap.inc                      |    4 +
 includes/common.inc                         |  146 ++++++++++++++++++++-------
 includes/database/database.inc              |    7 -
 modules/simpletest/drupal_web_test_case.php |    3 -
 modules/simpletest/tests/common.test        |  122 +++++++++++++++++++++++
 5 files changed, 237 insertions(+), 45 deletions(-)


diff --git includes/bootstrap.inc includes/bootstrap.inc
index 5d6c523..1c4d80f 100644
--- includes/bootstrap.inc
+++ includes/bootstrap.inc
@@ -316,6 +316,10 @@ function drupal_initialize_variables() {
   if (!isset($_SERVER['SERVER_PROTOCOL']) || ($_SERVER['SERVER_PROTOCOL'] != 'HTTP/1.0' && $_SERVER['SERVER_PROTOCOL'] != 'HTTP/1.1')) {
     $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.0';
   }
+  // Enforce E_ALL, but allow users to set levels not part of E_ALL.
+  error_reporting(E_ALL | error_reporting());
+  // Prevent PHP from generating HTML errors messages.
+  ini_set('html_errors', 0);
 }
 
 /**
diff --git includes/common.inc includes/common.inc
index 3e593c7..33631e9 100644
--- includes/common.inc
+++ includes/common.inc
@@ -583,55 +583,127 @@ function drupal_http_request($url, $headers = array(), $method = 'GET', $data =
  */
 
 /**
- * Log errors as defined by administrator.
+ * Custom PHP error handler.
  *
- * Error levels:
- * - 0 = Log errors to database.
- * - 1 = Log errors to database and to screen.
+ * @param $error_level
+ *   The level of the error raised.
+ * @param $message
+ *   The error message.
+ * @param $filename
+ *   The filename that the error was raised in.
+ * @param $line
+ *   The line number the error was raised at.
+ * @param $context
+ *   An array that points to the active symbol table at the point the error occurred.
+ */
+function _drupal_error_handler($error_level, $message, $filename, $line, $context) {
+  if ($error_level & error_reporting()) {
+    // All these constants are documented at http://php.net/manual/en/errorfunc.constants.php
+    $types = array(
+      E_ERROR => 'Error',
+      E_WARNING => 'Warning',
+      E_PARSE => 'Parse error',
+      E_NOTICE => 'Notice',
+      E_CORE_ERROR => 'Core error',
+      E_CORE_WARNING => 'Core warning',
+      E_COMPILE_ERROR => 'Compile error',
+      E_COMPILE_WARNING => 'Compile warning',
+      E_USER_ERROR => 'User error',
+      E_USER_WARNING => 'User warning',
+      E_USER_NOTICE => 'User notice',
+      E_STRICT => 'Strict warning',
+      E_RECOVERABLE_ERROR => 'Recoverable fatal error'
+    );
+    $backtrace = debug_backtrace();
+    // We treat recoverable errors as fatal.
+    _drupal_log_error(isset($types[$error_level]) ? $types[$error_level] : 'Unknown error', $message, $backtrace, $error_level == E_RECOVERABLE_ERROR);
+  }
+}
+
+/**
+ * Custom PHP exception handler.
+ *
+ * Uncaught exceptions are those not enclosed in a try/catch block. They are
+ * always fatal: the execution of the script will stop as soon as the exception
+ * handler exits.
+ *
+ * @param $exception
+ *   The exception object that was thrown.
  */
-function drupal_error_handler($errno, $message, $filename, $line, $context) {
-  // If the @ error suppression operator was used, error_reporting will have
-  // been temporarily set to 0.
-  if (error_reporting() == 0) {
-    return;
+function _drupal_exception_handler($exception) {
+  $backtrace = $exception->getTrace();
+  // Add the line throwing the exception to the backtrace.
+  array_unshift($backtrace, array('line' => $exception->getLine(), 'file' => $exception->getFile()));
+
+  // For PDOException errors, we try to return the initial caller,
+  // skipping internal functions of the database layer.
+  if ($exception instanceof PDOException) {
+    // The first element in the stack is the call, the second element gives us the caller.
+    // We skip calls that occured in one of the classes of the database layer
+    // or in one of its global functions.
+    $db_functions = array('db_query', 'pager_query', 'db_query_range', 'db_query_temporary', 'update_sql');
+    while (($caller = $backtrace[1]) &&
+         ((isset($caller['class']) && (strpos($caller['class'], 'Query') !== FALSE || strpos($caller['class'], 'Database') !== FALSE)) ||
+         in_array($caller['function'], $db_functions))) {
+      // We remove that call.
+      array_shift($backtrace);
+    }
   }
 
-  if ($errno & (E_ALL)) {
-    $types = array(1 => 'error', 2 => 'warning', 4 => 'parse error', 8 => 'notice', 16 => 'core error', 32 => 'core warning', 64 => 'compile error', 128 => 'compile warning', 256 => 'user error', 512 => 'user warning', 1024 => 'user notice', 2048 => 'strict warning', 4096 => 'recoverable fatal error');
+  // Log the message to the watchdog and return an error page to the user.
+  _drupal_log_error(get_class($exception), $exception->getMessage(), $backtrace, TRUE);
+}
+
+/**
+ * Log a PHP error or exception, display an error page in fatal cases.
+ *
+ * @param $type
+ *   The type of the error (Error, Warning, ...).
+ * @param $message
+ *   The message associated to the error.
+ * @param $backtrace
+ *   The backtrace of function calls that led to this error.
+ * @param $fatal
+ *   TRUE if the error is fatal.
+ */
+function _drupal_log_error($type, $message, $backtrace, $fatal) {
+  $caller = _drupal_get_last_caller($backtrace);
 
-    // For database errors, we want the line number/file name of the place that
-    // the query was originally called, not _db_query().
-    if (isset($context[DB_ERROR])) {
-      $backtrace = array_reverse(debug_backtrace());
+  if (class_exists('DrupalErrorHandlerUnitTest', FALSE) && DrupalErrorHandlerUnitTest::divertErrors()) {
+    DrupalErrorHandlerUnitTest::logErrors($type, $message, $backtrace, $fatal);
+    return;
+  }
 
-      // List of functions where SQL queries can originate.
-      $query_functions = array('db_query', 'pager_query', 'db_query_range', 'db_query_temporary', 'update_sql');
+  // Initialize a sane theme early if the boostrap was not complete.
+  // Do it early because drupal_set_message() triggers an init_theme().
+  if ($fatal && (drupal_get_bootstrap_phase() != DRUPAL_BOOTSTRAP_FULL)) {
+    unset($GLOBALS['theme']);
+    define('MAINTENANCE_MODE', 'error');
+    drupal_maintenance_theme();
+  }
 
-      // Determine where query function was called, and adjust line/file
-      // accordingly.
-      foreach ($backtrace as $index => $function) {
-        if (in_array($function['function'], $query_functions)) {
-          $line = $backtrace[$index]['line'];
-          $filename = $backtrace[$index]['file'];
-          break;
-        }
-      }
-    }
+  // Force display of error messages in update.php.
+  if (variable_get('error_level', 1) == 1 || (defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'update')) {
+    drupal_set_message(t('@type: %message in %function (line %line of %file).', array('@type' => $type, '%message' => $message, '%function' => $caller['function'], '%line' => $caller['line'], '%file' => $caller['file'])), 'error');
+  }
 
-    $entry = $types[$errno] . ': ' . $message . ' in ' . $filename . ' on line ' . $line . '.';
+  watchdog('php', '%type: %message in %function (line %line of %file).', array('%type' => $type, '%message' => $message, '%function' => $caller['function'], '%file' => $caller['file'], '%line' => $caller['line']), WATCHDOG_ERROR);  
 
-    // Force display of error messages in update.php.
-    if (variable_get('error_level', 1) == 1 || strstr($_SERVER['SCRIPT_NAME'], 'update.php')) {
-      drupal_set_message($entry, 'error');
+  if ($fatal) {
+    drupal_set_header($_SERVER['SERVER_PROTOCOL'] . ' Service unavailable');
+    drupal_set_title(t('Error'));
+    if (drupal_get_bootstrap_phase() == DRUPAL_BOOTSTRAP_FULL) {
+      print theme('page', t('The website encountered an unexpected error. Please try again later.'), FALSE);
     }
-
-    watchdog('php', '%message in %file on line %line.', array('%error' => $types[$errno], '%message' => $message, '%file' => $filename, '%line' => $line), WATCHDOG_ERROR);
+    else {
+      print theme('maintenance_page', t('The website encountered an unexpected error. Please try again later.'), FALSE);      
+    }
+    exit;
   }
 }
 
 /**
- * Gets the last caller (file name and line of the call, function in which the
- * call originated) from a backtrace.
+ * Gets the last caller from a backtrace.
  *
  * @param $backtrace
  *   A standard PHP backtrace.
@@ -2514,7 +2586,9 @@ function _drupal_bootstrap_full() {
   require_once DRUPAL_ROOT . '/includes/mail.inc';
   require_once DRUPAL_ROOT . '/includes/actions.inc';
   // Set the Drupal custom error handler.
-  set_error_handler('drupal_error_handler');
+  set_error_handler('_drupal_error_handler');
+  set_exception_handler('_drupal_exception_handler');
+
   // Emit the correct charset HTTP header.
   drupal_set_header('Content-Type: text/html; charset=utf-8');
   // Detect string handling method
diff --git includes/database/database.inc includes/database/database.inc
index f0525e8..91cb9d7 100644
--- includes/database/database.inc
+++ includes/database/database.inc
@@ -7,13 +7,6 @@
  */
 
 /**
- * A hash value to check when outputting database errors, md5('DB_ERROR').
- *
- * @see drupal_error_handler()
- */
-define('DB_ERROR', 'a515ac9c2796ca0e23adbe92c68fc9fc');
-
-/**
  * @defgroup database Database abstraction layer
  * @{
  * Allow the use of different database servers using the same code base.
diff --git modules/simpletest/drupal_web_test_case.php modules/simpletest/drupal_web_test_case.php
index 332cbcc..3fc4634 100644
--- modules/simpletest/drupal_web_test_case.php
+++ modules/simpletest/drupal_web_test_case.php
@@ -322,8 +322,7 @@ class DrupalWebTestCase {
    * @see set_error_handler
    */
   function errorHandler($severity, $message, $file = NULL, $line = NULL) {
-    $severity = $severity & error_reporting();
-    if ($severity) {
+    if ($severity & error_reporting()) {
       $error_map = array(
         E_STRICT => 'Run-time notice',
         E_WARNING => 'Warning',
diff --git modules/simpletest/tests/common.test modules/simpletest/tests/common.test
index 43e7b02..30b4ca7 100644
--- modules/simpletest/tests/common.test
+++ modules/simpletest/tests/common.test
@@ -251,3 +251,125 @@ class DrupalSetContentTestCase extends DrupalWebTestCase {
     }
   }
 }
+
+/**
+ * Testing Drupal error and exception handlers.
+ */
+class DrupalErrorHandlerUnitTest extends DrupalWebTestCase {
+  /**
+   * Implementation of getInfo().
+   */
+  function getInfo() {
+    return array(
+      'name' => t('Drupal error handlers'),
+      'description' => t("Performs tests on the Drupal error and exception handler."),
+      'group' => t('System'),
+    );
+  }
+
+  /**
+   * Test the error handler.
+   */
+  function testErrorHandler() {
+    $this->startLogging();
+    // This will generate a notice.
+    $monkey_love = $bananas;
+    // This will generate a warning.
+    $awesomely_big = 1/0;
+    // This will generate a user error.
+    trigger_error("Drupal is awesome", E_USER_NOTICE);
+
+    $this->assertErrorLogged('Notice', 'common.test', 'DrupalErrorHandlerUnitTest->testErrorHandler()', 'Undefined variable');
+    $this->assertErrorLogged('Warning', 'common.test', 'DrupalErrorHandlerUnitTest->testErrorHandler()', 'Division by zero');
+    $this->assertErrorLogged('User notice', 'common.test', 'DrupalErrorHandlerUnitTest->testErrorHandler()', 'Drupal is awesome');
+  }
+
+  /**
+   * Test the exception handler.
+   */
+  function testExceptionHandler() {
+    $this->startLogging();
+    try {
+      db_query("SELECT * FROM " . $this->randomName());
+    }
+    catch (Exception $e) {
+      _drupal_exception_handler($e);
+    }
+    try {
+      throw new Exception("Drupal is awesome");
+    }
+    catch (Exception $e) {
+      _drupal_exception_handler($e);
+    }
+    $this->stopLogging();
+    $this->assertErrorLogged('PDOException', 'common.test', 'DrupalErrorHandlerUnitTest->testExceptionHandler()', 'Base table or view not found');
+    $this->assertErrorLogged('Exception', 'common.test', 'DrupalErrorHandlerUnitTest->testExceptionHandler()', 'Drupal is awesome');
+  }
+
+  private static $divert_errors = FALSE;
+  private static $error_log = NULL;
+  private $_previous_handler = NULL;
+  private $_old_error_reporting = NULL;
+
+  /**
+   * Helper function: start diverting error and exception logging.
+   */
+  function startLogging() {
+    $this->_previous_handler = set_error_handler('_drupal_error_handler');
+    $this->_old_error_reporting = error_reporting(E_ALL | E_NOTICE);
+    self::$error_log = array();
+    self::$divert_errors = TRUE;
+  }
+
+  /**
+   * Helper method: stop diverting error and exception logging.
+   */
+  function stopLogging() {
+    self::$divert_errors = FALSE;
+    error_reporting($this->_old_error_reporting);
+    set_error_handler($this->_previous_handler);
+  }
+
+  /**
+   * Helper method for _drupal_log_error().
+   *
+   * @return
+   *   TRUE if the error should be logged here instead of normal Drupal ways.
+   */
+  static function divertErrors() {
+    return self::$divert_errors;
+  }
+
+  /**
+   * Helper function: grab message logged by the error handler.
+   */
+  static function logErrors($type, $message, $backtrace, $fatal) {
+    self::$error_log[] = array(
+      'type' => $type,
+      'message' => $message,
+      'backtrace' => $backtrace,
+      'fatal' => $fatal
+    );
+  }
+
+  /**
+   * Helper function: assert that the logged message is correct.
+   */
+  function assertErrorLogged($type, $file, $function, $message = NULL) {
+    $this->stopLogging();
+
+    $found = FALSE;
+    foreach (self::$error_log as $log) {
+      if ($log['type'] === $type
+        && strpos($log['message'], $message) !== FALSE
+        && ($caller = _drupal_get_last_caller($log['backtrace']))
+        && basename($caller['file']) === $file
+        && $caller['function'] == $function) {
+          $found = TRUE;
+          break;
+      }
+    }
+
+    $this->assertTrue($found, t("Found logged @type, with message containing '@message', with the correct caller.", array('@type' => $type, '@message' => $message)));
+  }
+}
