diff --git a/src/error/PhutilErrorHandler.php b/src/error/PhutilErrorHandler.php index f854c4b..98b1486 100644 --- a/src/error/PhutilErrorHandler.php +++ b/src/error/PhutilErrorHandler.php @@ -1,520 +1,596 @@ getPrevious(); } if (method_exists($ex, 'getPreviousException')) { return $ex->getPreviousException(); } return null; } /** * Find the most deeply nested exception from a possibly-nested exception. * * @param Exception A possibly-nested exception. * @return Exception Deepest exception in the nest. * @task exutil */ public static function getRootException(Exception $ex) { $root = $ex; while (self::getPreviousException($root)) { $root = self::getPreviousException($root); } return $root; } /* -( Trapping Errors )---------------------------------------------------- */ /** * Adds an error trap. Normally you should not invoke this directly; * @{class:PhutilErrorTrap} registers itself on construction. * * @param PhutilErrorTrap Trap to add. * @return void * @task trap */ public static function addErrorTrap(PhutilErrorTrap $trap) { $key = $trap->getTrapKey(); self::$traps[$key] = $trap; } /** * Removes an error trap. Normally you should not invoke this directly; * @{class:PhutilErrorTrap} deregisters itself on destruction. * * @param PhutilErrorTrap Trap to remove. * @return void * @task trap */ public static function removeErrorTrap(PhutilErrorTrap $trap) { $key = $trap->getTrapKey(); unset(self::$traps[$key]); } /* -( Internals )---------------------------------------------------------- */ /** * Determine if PhutilErrorHandler has been initialized. * * @return bool True if initialized. * @task internal */ public static function hasInitialized() { return self::$initialized; } /** * Handles PHP errors and dispatches them forward. This is a callback for * ##set_error_handler()##. You should not call this function directly; use * @{function:phlog} to print debugging messages or ##trigger_error()## to * trigger PHP errors. * * This handler converts E_RECOVERABLE_ERROR messages from violated typehints * into @{class:InvalidArgumentException}s. * * This handler converts other E_RECOVERABLE_ERRORs into * @{class:RuntimeException}s. * * This handler converts E_NOTICE messages from uses of undefined variables * into @{class:RuntimeException}s. * * @param int Error code. * @param string Error message. * @param string File where the error occurred. * @param int Line on which the error occurred. * @param wild Error context information. * @return void * @task internal */ public static function handleError($num, $str, $file, $line, $ctx) { foreach (self::$traps as $trap) { $trap->addError($num, $str, $file, $line, $ctx); } if ((error_reporting() & $num) == 0) { // Respect the use of "@" to silence warnings: if this error was // emitted from a context where "@" was in effect, the // value returned by error_reporting() will be 0. This is the // recommended way to check for this, see set_error_handler() docs // on php.net. return false; } // Convert typehint failures into exceptions. if (preg_match('/^Argument (\d+) passed to (\S+) must be/', $str)) { throw new InvalidArgumentException($str); } // Convert other E_RECOVERABLE_ERRORs into generic runtime exceptions. if ($num == E_RECOVERABLE_ERROR) { throw new RuntimeException($str); } // Convert uses of undefined variables into exceptions. if (preg_match('/^Undefined variable: /', $str)) { throw new RuntimeException($str); } // Convert uses of undefined properties into exceptions. if (preg_match('/^Undefined property: /', $str)) { throw new RuntimeException($str); } // Convert undefined constants into exceptions. Usually this means there // is a missing `$` and the program is horribly broken. if (preg_match('/^Use of undefined constant /', $str)) { throw new RuntimeException($str); } $trace = debug_backtrace(); array_shift($trace); self::dispatchErrorMessage( self::ERROR, $str, array( 'file' => $file, 'line' => $line, 'context' => $ctx, 'error_code' => $num, 'trace' => $trace, )); } /** * Handles PHP exceptions and dispatches them forward. This is a callback for * ##set_exception_handler()##. You should not call this function directly; * to print exceptions, pass the exception object to @{function:phlog}. * * @param Exception Uncaught exception object. * @return void * @task internal */ public static function handleException(Exception $ex) { self::dispatchErrorMessage( self::EXCEPTION, $ex, array( 'file' => $ex->getFile(), 'line' => $ex->getLine(), - 'trace' => self::getRootException($ex)->getTrace(), + 'trace' => self::getExceptionTrace($ex), 'catch_trace' => debug_backtrace(), )); // Normally, PHP exits with code 255 after an uncaught exception is thrown. // However, if we install an exception handler (as we have here), it exits // with code 0 instead. Script execution terminates after this function // exits in either case, so exit explicitly with the correct exit code. exit(255); } /** * Output a stacktrace to the PHP error log. * * @param trace A stacktrace, e.g. from debug_backtrace(); * @return void * @task internal */ public static function outputStacktrace($trace) { $lines = explode("\n", self::formatStacktrace($trace)); foreach ($lines as $line) { error_log($line); } } /** * Format a stacktrace for output. * * @param trace A stacktrace, e.g. from debug_backtrace(); * @return string Human-readable trace. * @task internal */ public static function formatStacktrace($trace) { $result = array(); $libinfo = self::getLibraryVersions(); if ($libinfo) { foreach ($libinfo as $key => $dict) { $info = array(); foreach ($dict as $dkey => $dval) { $info[] = $dkey.'='.$dval; } $libinfo[$key] = $key.'('.implode(', ', $info).')'; } $result[] = implode(', ', $libinfo); } foreach ($trace as $key => $entry) { $line = ' #'.$key.' '; + if (!empty($entry['xid'])) { + if ($entry['xid'] != 1) { + $line .= '<#'.$entry['xid'].'> '; + } + } if (isset($entry['class'])) { $line .= $entry['class'].'::'; } $line .= idx($entry, 'function', ''); if (isset($entry['args'])) { $args = array(); foreach ($entry['args'] as $arg) { // NOTE: Print out object types, not values. Values sometimes contain // sensitive information and are usually not particularly helpful // for debugging. $type = (gettype($arg) == 'object') ? get_class($arg) : gettype($arg); $args[] = $type; } $line .= '('.implode(', ', $args).')'; } if (isset($entry['file'])) { $file = self::adjustFilePath($entry['file']); $line .= ' called at ['.$file.':'.$entry['line'].']'; } $result[] = $line; } return implode("\n", $result); } /** * All different types of error messages come here before they are * dispatched to the listener; this method also prints them to the PHP error * log. * * @param const Event type constant. * @param wild Event value. * @param dict Event metadata. * @return void * @task internal */ public static function dispatchErrorMessage($event, $value, $metadata) { $timestamp = strftime('%Y-%m-%d %H:%M:%S'); switch ($event) { case self::ERROR: $default_message = sprintf( '[%s] ERROR %d: %s at [%s:%d]', $timestamp, $metadata['error_code'], $value, $metadata['file'], $metadata['line']); $metadata['default_message'] = $default_message; error_log($default_message); self::outputStacktrace($metadata['trace']); break; case self::EXCEPTION: $messages = array(); $current = $value; do { $messages[] = '('.get_class($current).') '.$current->getMessage(); } while ($current = self::getPreviousException($current)); $messages = implode(' {>} ', $messages); if (strlen($messages) > 4096) { $messages = substr($messages, 0, 4096).'...'; } $default_message = sprintf( '[%s] EXCEPTION: %s at [%s:%d]', $timestamp, $messages, self::adjustFilePath(self::getRootException($value)->getFile()), self::getRootException($value)->getLine()); $metadata['default_message'] = $default_message; error_log($default_message); - self::outputStacktrace(self::getRootException($value)->getTrace()); + self::outputStacktrace($metadata['trace']); break; case self::PHLOG: $default_message = sprintf( '[%s] PHLOG: %s at [%s:%d]', $timestamp, PhutilReadableSerializer::printShort($value), $metadata['file'], $metadata['line']); $metadata['default_message'] = $default_message; error_log($default_message); break; case self::DEPRECATED: $default_message = sprintf( '[%s] DEPRECATED: %s is deprecated; %s', $timestamp, $value, $metadata['why']); $metadata['default_message'] = $default_message; error_log($default_message); break; default: error_log(pht('Unknown event %s', $event)); break; } if (self::$errorListener) { static $handling_error; if ($handling_error) { error_log( 'Error handler was reentered, some errors were not passed to the '. 'listener.'); return; } $handling_error = true; call_user_func(self::$errorListener, $event, $value, $metadata); $handling_error = false; } } public static function adjustFilePath($path) { // Compute known library locations so we can emit relative paths if the // file resides inside a known library. This is a little cleaner to read, // and limits the number of false positives we get about full path // disclosure via HackerOne. $bootloader = PhutilBootloader::getInstance(); $libraries = $bootloader->getAllLibraries(); $roots = array(); foreach ($libraries as $library) { $root = $bootloader->getLibraryRoot($library); // For these libraries, the effective root is one level up. switch ($library) { case 'phutil': case 'arcanist': case 'phabricator': $root = dirname($root); break; } if (!strncmp($root, $path, strlen($root))) { return '<'.$library.'>'.substr($path, strlen($root)); } } return $path; } public static function getLibraryVersions() { $libinfo = array(); $bootloader = PhutilBootloader::getInstance(); foreach ($bootloader->getAllLibraries() as $library) { $root = phutil_get_library_root($library); $try_paths = array( $root, dirname($root), ); $libinfo[$library] = array(); $get_refs = array('master'); foreach ($try_paths as $try_path) { // Try to read what the HEAD of the repository is pointed at. This is // normally the name of a branch ("ref"). $try_file = $try_path.'/.git/HEAD'; if (@file_exists($try_file)) { $head = @file_get_contents($try_file); $matches = null; if (preg_match('(^ref: refs/heads/(.*)$)', trim($head), $matches)) { $libinfo[$library]['head'] = trim($matches[1]); $get_refs[] = trim($matches[1]); } else { $libinfo[$library]['head'] = trim($head); } break; } } // Try to read which commit relevant branch heads are at. foreach (array_unique($get_refs) as $ref) { foreach ($try_paths as $try_path) { $try_file = $try_path.'/.git/refs/heads/'.$ref; if (@file_exists($try_file)) { $hash = @file_get_contents($try_file); if ($hash) { $libinfo[$library]['ref.'.$ref] = substr(trim($hash), 0, 12); break; } } } } // Look for extension files. $custom = @scandir($root.'/extensions/'); if ($custom) { $count = 0; foreach ($custom as $custom_path) { if (preg_match('/\.php$/', $custom_path)) { $count++; } } if ($count) { $libinfo[$library]['custom'] = $count; } } } ksort($libinfo); return $libinfo; } + /** + * Get a full trace across all proxied and aggregated exceptions. + * + * This attempts to build a set of stack frames which completely represent + * all of the places an exception came from, even if it came from multiple + * origins and has been aggregated or proxied. + * + * @param Exception Exception to retrieve a trace for. + * @return list List of stack frames. + */ + public static function getExceptionTrace(Exception $ex) { + $id = 1; + + // Keep track of discovered exceptions which we need to build traces for. + $stack = array( + array($id, $ex), + ); + + $frames = array(); + while ($info = array_shift($stack)) { + list($xid, $ex) = $info; + + // We're going from top-level exception down in bredth-first order, but + // want to build a trace in approximately standard order (deepest part of + // the call stack to most shallow) so we need to reverse each list of + // frames and then reverse everything at the end. + + $ex_frames = array_reverse($ex->getTrace()); + $ex_frames = array_values($ex_frames); + $last_key = (count($ex_frames) - 1); + foreach ($ex_frames as $frame_key => $frame) { + $frame['xid'] = $xid; + + // If this is a child/previous exception and we're on the deepest frame + // and missing file/line data, fill it in from the exception itself. + if ($xid > 1 && ($frame_key == $last_key)) { + if (empty($frame['file'])) { + $frame['file'] = $ex->getFile(); + $frame['line'] = $ex->getLine(); + } + } + + // Since the exceptions are likely to share the most shallow frames, + // try to add those to the trace only once. + if (isset($frame['file']) && isset($frame['line'])) { + $signature = $frame['file'].':'.$frame['line']; + if (empty($frames[$signature])) { + $frames[$signature] = $frame; + } + } else { + $frames[] = $frame; + } + } + + // If this is a proxy exception, add the proxied exception. + $prev = self::getPreviousException($ex); + if ($prev) { + $stack[] = array(++$id, $prev); + } + + // If this is an aggregate exception, add the child exceptions. + if ($ex instanceof PhutilAggregateException) { + foreach ($ex->getExceptions() as $child) { + $stack[] = array(++$id, $child); + } + } + } + + return array_values(array_reverse($frames)); + } + }