diff --git a/src/error/PhutilErrorHandler.php b/src/error/PhutilErrorHandler.php index deb6ee16..95af88b6 100644 --- a/src/error/PhutilErrorHandler.php +++ b/src/error/PhutilErrorHandler.php @@ -1,623 +1,622 @@ 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 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 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) { + public static function handleError($num, $str, $file, $line, $ctx = null) { foreach (self::$traps as $trap) { - $trap->addError($num, $str, $file, $line, $ctx); + $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)) { $trace = debug_backtrace(); array_shift($trace); self::dispatchErrorMessage( self::ERROR, $str, array( 'file' => $file, 'line' => $line, - 'context' => $ctx, '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 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 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($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 '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|Throwable Exception to retrieve a trace for. * @return list 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)); } } diff --git a/src/error/PhutilErrorTrap.php b/src/error/PhutilErrorTrap.php index 94d291d0..252a37f9 100644 --- a/src/error/PhutilErrorTrap.php +++ b/src/error/PhutilErrorTrap.php @@ -1,83 +1,82 @@ getErrorsAsString(); * $trap->destroy(); * * if (!$res) { * throw new Exception('proc_open() failed: '.$err); * } * * IMPORTANT: You must explicitly destroy traps because they register * themselves with @{class:PhutilErrorHandler}, and thus will not be destroyed * when `unset()`. * * Some notes on traps: * * - Traps catch all errors, including those silenced by `@`. * - Traps do not prevent errors from reaching other standard handlers. You * can use `@` to keep errors out of the logs while still trapping them. * - Traps capture all errors until they are explicitly destroyed. This means * that you should not create long-lived traps, or they may consume * unbounded amounts of memory to hold the error log. */ final class PhutilErrorTrap extends Phobject { private $destroyed; private $errors = array(); - public function addError($num, $str, $file, $line, $ctx) { + public function addError($num, $str, $file, $line) { $this->errors[] = array( 'num' => $num, 'str' => $str, 'file' => $file, 'line' => $line, - 'ctx' => $ctx, ); return $this; } public function getErrorsAsString() { $out = array(); foreach ($this->errors as $error) { $out[] = $error['str']; } return implode("\n", $out); } public function destroy() { if (!$this->destroyed) { PhutilErrorHandler::removeErrorTrap($this); $this->errors = array(); $this->destroyed = true; } } public function getTrapKey() { return spl_object_hash($this); } public function __construct() { PhutilErrorHandler::addErrorTrap($this); } public function __toString() { return $this->getErrorsAsString(); } }