mirror of
https://we.phorge.it/source/arcanist.git
synced 2024-11-22 06:42:41 +01:00
04e3e250f7
Summary: Add variable names (`$varname` in `@param type $varname explanation`) to PHPDoc method headers, for fun and profit. Closes T15923 Test Plan: * Read the method signatures and their corresponding PHPDoc headers at your fireplace * Still run `./bin/diviner generate` without explosions (though it is very lenient anyway?) Reviewers: O1 Blessed Committers, valerio.bozzolan Reviewed By: O1 Blessed Committers, valerio.bozzolan Subscribers: tobiaswiese, valerio.bozzolan, Matthew, Cigaryno Maniphest Tasks: T15923 Differential Revision: https://we.phorge.it/D25799
627 lines
19 KiB
PHP
627 lines
19 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Improve PHP error logs and optionally route errors, exceptions and debugging
|
|
* information to a central listener.
|
|
*
|
|
* This class takes over the PHP error and exception handlers when you call
|
|
* ##PhutilErrorHandler::initialize()## and forwards all debugging information
|
|
* to a listener you install with ##PhutilErrorHandler::addErrorListener()##.
|
|
*
|
|
* To use PhutilErrorHandler, which will enhance the messages printed to the
|
|
* PHP error log, just initialize it:
|
|
*
|
|
* PhutilErrorHandler::initialize();
|
|
*
|
|
* To additionally install a custom listener which can print error information
|
|
* to some other file or console, register a listener:
|
|
*
|
|
* PhutilErrorHandler::addErrorListener($some_callback);
|
|
*
|
|
* For information on writing an error listener, see
|
|
* @{function:phutil_error_listener_example}. Providing a listener is optional,
|
|
* you will benefit from improved error logs even without one.
|
|
*
|
|
* Phabricator uses this class to drive the DarkConsole "Error Log" plugin.
|
|
*
|
|
* @task config Configuring Error Dispatch
|
|
* @task exutil Exception Utilities
|
|
* @task trap Error Traps
|
|
* @task internal Internals
|
|
*/
|
|
final class PhutilErrorHandler extends Phobject {
|
|
|
|
private static $errorListeners = array();
|
|
private static $initialized = false;
|
|
private static $traps = array();
|
|
|
|
const EXCEPTION = 'exception';
|
|
const ERROR = 'error';
|
|
const PHLOG = 'phlog';
|
|
const DEPRECATED = 'deprecated';
|
|
|
|
|
|
/* -( Configuring Error Dispatch )----------------------------------------- */
|
|
|
|
|
|
/**
|
|
* Registers this class as the PHP error and exception handler. This will
|
|
* overwrite any previous handlers!
|
|
*
|
|
* @return void
|
|
* @task config
|
|
*/
|
|
public static function initialize() {
|
|
self::$initialized = true;
|
|
set_error_handler(array(__CLASS__, 'handleError'));
|
|
set_exception_handler(array(__CLASS__, 'handleException'));
|
|
}
|
|
|
|
/**
|
|
* Provide an optional listener callback which will receive all errors,
|
|
* exceptions and debugging messages. It can then print them to a web console,
|
|
* for example.
|
|
*
|
|
* See @{function:phutil_error_listener_example} for details about the
|
|
* callback parameters and operation.
|
|
*
|
|
* @return void
|
|
* @task config
|
|
*/
|
|
public static function addErrorListener($listener) {
|
|
self::$errorListeners[] = $listener;
|
|
}
|
|
|
|
/**
|
|
* Deprecated - use `addErrorListener`.
|
|
*/
|
|
public static function setErrorListener($listener) {
|
|
self::addErrorListener($listener);
|
|
}
|
|
|
|
|
|
/* -( Exception Utilities )------------------------------------------------ */
|
|
|
|
|
|
/**
|
|
* Gets the previous exception of a nested exception. Prior to PHP 5.3 you
|
|
* can use @{class:PhutilProxyException} to nest exceptions; after PHP 5.3
|
|
* all exceptions are nestable.
|
|
*
|
|
* @param Exception|Throwable $ex Exception to unnest.
|
|
* @return Exception|Throwable|null Previous exception, if one exists.
|
|
* @task exutil
|
|
*/
|
|
public static function getPreviousException($ex) {
|
|
if (method_exists($ex, 'getPrevious')) {
|
|
return $ex->getPrevious();
|
|
}
|
|
if (method_exists($ex, 'getPreviousException')) {
|
|
return $ex->getPreviousException();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
/**
|
|
* Find the most deeply nested exception from a possibly-nested exception.
|
|
*
|
|
* @param Exception|Throwable $ex A possibly-nested exception.
|
|
* @return Exception|Throwable Deepest exception in the nest.
|
|
* @task exutil
|
|
*/
|
|
public static function getRootException($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 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 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 $num Error code.
|
|
* @param string $str Error message.
|
|
* @param string $file File where the error occurred.
|
|
* @param int $line Line on which the error occurred.
|
|
* @param wild $ctx (optional) Error context information.
|
|
* @return void
|
|
* @task internal
|
|
*/
|
|
public static function handleError($num, $str, $file, $line, $ctx = null) {
|
|
foreach (self::$traps as $trap) {
|
|
$trap->addError($num, $str, $file, $line);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// See T13499. If this is a user error arising from "trigger_error()" or
|
|
// similar, route it through normal error handling: this is probably the
|
|
// best match to authorial intent, since the code could choose to throw
|
|
// an exception instead if it wanted that behavior. Phabricator does not
|
|
// use "trigger_error()" so we never normally expect to reach this
|
|
// block in first-party code.
|
|
|
|
if (($num === E_USER_ERROR) ||
|
|
($num === E_USER_WARNING) ||
|
|
($num === E_USER_NOTICE) ||
|
|
($num === E_DEPRECATED)) {
|
|
|
|
// See T15554 - we special-case E_DEPRECATED because we don't want them
|
|
// to kill the process.
|
|
$level = ($num === E_DEPRECATED) ? self::DEPRECATED : self::ERROR;
|
|
|
|
$trace = debug_backtrace();
|
|
array_shift($trace);
|
|
self::dispatchErrorMessage(
|
|
$level,
|
|
$str,
|
|
array(
|
|
'file' => $file,
|
|
'line' => $line,
|
|
'error_code' => $num,
|
|
'trace' => $trace,
|
|
));
|
|
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Convert undefined indexes into exceptions.
|
|
if (preg_match('/^Undefined index: /', $str)) {
|
|
throw new RuntimeException($str);
|
|
}
|
|
|
|
// Convert undefined offsets into exceptions.
|
|
if (preg_match('/^Undefined offset: /', $str)) {
|
|
throw new RuntimeException($str);
|
|
}
|
|
|
|
// See T13499. Convert all other runtime errors not handled in a more
|
|
// specific way into runtime exceptions.
|
|
throw new RuntimeException($str);
|
|
}
|
|
|
|
/**
|
|
* 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|Throwable $ex Uncaught exception object.
|
|
* @return void
|
|
* @task internal
|
|
*/
|
|
public static function handleException($ex) {
|
|
self::dispatchErrorMessage(
|
|
self::EXCEPTION,
|
|
$ex,
|
|
array(
|
|
'file' => $ex->getFile(),
|
|
'line' => $ex->getLine(),
|
|
'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 $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 $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 Event type constant.
|
|
* @param wild $value Event value.
|
|
* @param dict $metadata Event metadata.
|
|
* @return void
|
|
* @task internal
|
|
*/
|
|
public static function dispatchErrorMessage($event, $value, $metadata) {
|
|
$timestamp = date('Y-m-d H:i:s');
|
|
|
|
switch ($event) {
|
|
case self::DEPRECATED:
|
|
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($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;
|
|
default:
|
|
error_log(pht('Unknown event %s', $event));
|
|
break;
|
|
}
|
|
|
|
if (self::$errorListeners) {
|
|
static $handling_error;
|
|
if ($handling_error) {
|
|
error_log(
|
|
'Error handler was reentered, some errors were not passed to the '.
|
|
'listener.');
|
|
return;
|
|
}
|
|
$handling_error = true;
|
|
foreach (self::$errorListeners as $error_listener) {
|
|
call_user_func($error_listener, $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 'arcanist':
|
|
case 'phorge':
|
|
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|Throwable $ex Exception to retrieve a trace for.
|
|
* @return list<wild> List of stack frames.
|
|
*/
|
|
public static function getExceptionTrace($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));
|
|
}
|
|
|
|
}
|