commit e6e99a1d5c4e13cd3f1251f5c70a023d44d4e063 Author: Dustin Wilson Date: Sat Sep 24 12:05:33 2022 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7aa609 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Catcher-specific +/test*.html +/test*.php +/test/* + +# General +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +/vendor/ +/vendor-bin/*/vendor +/tests/html5lib-tests +/tests/.phpunit.result.cache +/tests/coverage +cachegrind.out.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..979ed23 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Catcher + +Catcher is a Throwable catcher and error handling library for PHP. + +## Example + +```php +$catcher = new Catcher(new PlainTextHandler([ + 'outputBacktrace' => true, + 'backtraceArgFrameLimit' => 2 +])); +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..477bd24 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "mensbeam/catcher", + "description": "Catches exceptions, errors, and shutdowns", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Mensbeam\\Framework\\": "lib/", + "Mensbeam\\Framework\\Test\\": "test/" + } + }, + "authors": [ + { + "name": "Dustin Wilson", + "email": "dustin@dustinwilson.com" + } + ], + "require": { + "php": ">=8.1", + "psr/log": "^3.0" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..7fc2812 --- /dev/null +++ b/composer.lock @@ -0,0 +1,71 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8998cce6e05b3ccc6aca7c736416f570", + "packages": [ + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/lib/Catcher.php b/lib/Catcher.php new file mode 100644 index 0000000..1616416 --- /dev/null +++ b/lib/Catcher.php @@ -0,0 +1,101 @@ +handlers = $handlers; + $prev = set_error_handler([ $this, 'handleError' ]); + $prev = set_exception_handler([ $this, 'handleThrowable' ]); + register_shutdown_function([ $this, 'handleShutdown' ]); + } + + + + /** + * Gets the list of handlers + * + * @return ThrowableHandler[] + */ + public function getHandlers(): array { + return $this->handlers; + } + + /** + * Converts regular errors into throwable Errors for easier handling; meant to be + * used with set_error_handler. + * + * @internal + */ + public function handleError(int $code, string $message, ?string $file = null, ?int $line = null): bool { + if ($code !== 0 && error_reporting()) { + $error = new Error($message, $code, $file, $line); + if ($this->isShuttingDown) { + throw $error; + } else { + $this->handleThrowable($error); + } + + return true; + } + + return false; + } + + /** + * Handles both Exceptions and Errors; meant to be used with set_exception_handler. + * + * @internal + */ + public function handleThrowable(\Throwable $throwable): void { + $controller = new ThrowableController($throwable); + foreach ($this->handlers as $h) { + // If the handler returns true it means the handler handled the exception properly, + // and there's no reason to pass it off to another. + if ($h->handle($throwable, $controller)) { + break; + } + } + + if ($this->isShuttingDown || $throwable instanceof \Exception || in_array($throwable->getCode(), [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_COMPILE_ERROR, \E_USER_ERROR ])) { + exit($throwable->getCode()); + } + } + + /** + * Handles shutdowns, passes all possible built-in error codes to the error handler. + * + * @internal + */ + public function handleShutdown() { + $this->isShuttingDown = true; + + $error = error_get_last(); + if ($error && in_array($error['type'], [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_CORE_WARNING, \E_COMPILE_ERROR, \E_COMPILE_WARNING ])) { + $this->handleError($error['type'], $error['message'], $error['file'], $error['line']); + } + } +} \ No newline at end of file diff --git a/lib/Catcher/PlainTextHandler.php b/lib/Catcher/PlainTextHandler.php new file mode 100644 index 0000000..323d31e --- /dev/null +++ b/lib/Catcher/PlainTextHandler.php @@ -0,0 +1,221 @@ +_getBacktraceArgFrameLimit; + } + + public function getLogger(): ?LoggerInterface { + return $this->_logger; + } + + public function getOutputBacktrace(): bool { + return $this->_outputBacktrace; + } + + public function getOutputPrevious(): bool { + return $this->_outputPrevious; + } + + public function getOutputTime(): bool { + return $this->_outputTime; + } + + public function getTimeFormat(): bool { + return $this->_timeFormat; + } + + public function handle(\Throwable $throwable, ThrowableController $controller): bool { + // If this can't output and there's no logger to log to then there's nothing to do + // here. Continue on to the next handler. + if (!$this->_output && $this->_logger === null) { + return false; + } + + $message = $this->serializeThrowable($throwable, $controller); + if ($this->_outputPrevious) { + $prev = $throwable->getPrevious(); + $prevController = $controller->getPrevious(); + while ($prev) { + $message .= sprintf("\n\nCaused by ↴\n%s", $this->serializeThrowable($prev, $prevController)); + $prev = $prev->getPrevious(); + $prevController = $prevController->getPrevious(); + } + } + + if ($this->_outputBacktrace) { + $frames = $controller->getFrames(); + $message .= "\nStack trace:"; + + $num = 1; + foreach ($frames as $frame) { + $class = (!empty($frame['error'])) ? "{$frame['error']} ({$frame['class']})" : $frame['class'] ?? ''; + $function = $frame['function'] ?? ''; + + $args = ''; + if (!empty($frame['args']) && $this->_backtraceArgFrameLimit >= $num) { + $args = "\n" . preg_replace('/^/m', str_repeat(' ', strlen((string)$num) + 2) . '| ', var_export($frame['args'], true)); + } + + $template = "\n%3d. %s"; + if ($class && $function) { + $template .= '::'; + } + $template .= ($function) ? '%s()' : '%s'; + $template .= ' %s:%d%s'; + + $message .= sprintf( + "$template\n", + $num++, + $class, + $function, + $frame['file'], + $frame['line'], + $args + ); + } + } + + if ($this->_logger !== null) { + $this->log($throwable, $message); + } + + if ($this->_output) { + // Logger handles its own timestamps + if ($this->_outputTime && $this->_timeFormat !== '') { + $time = (new \DateTime())->format($this->_timeFormat) . ' '; + $timeStrlen = strlen($time); + + $message = preg_replace('/^/m', str_repeat(' ', $timeStrlen), $message); + $message = preg_replace('/^ {' . $timeStrlen . '}/', $time, $message); + } + + if (\PHP_SAPI === 'cli') { + fprintf(\STDERR, "$message\n"); + } else { + $this->sendContentTypeHeader(); + http_response_code(500); + echo "$message\n"; + } + + return (!$this->_passthrough); + } + + return false; + } + + public function setBacktraceArgFrameLimit(int $value): void { + $this->_getBacktraceArgFrameLimit = $value; + } + + public function setLogger(?LoggerInterface $value): void { + $this->_logger = $value; + } + + public function setOutputBacktrace(bool $value): void { + $this->_outputBacktrace = $value; + } + + public function setOutputPrevious(bool $value): void { + $this->_outputPrevious = $value; + } + + public function setOutputTime(bool $value): void { + $this->_outputTime = $value; + } + + public function setTimeFormat(bool $value): void { + $this->_timeFormat = $value; + } + + + protected function log(\Throwable $throwable, string $message): void { + if ($throwable instanceof \Error) { + switch ($throwable->getCode()) { + case \E_NOTICE: + case \E_USER_NOTICE: + case \E_STRICT: + $this->_logger->notice($message); + break; + case \E_WARNING: + case \E_COMPILE_WARNING: + case \E_USER_WARNING: + case \E_DEPRECATED: + case \E_USER_DEPRECATED: + $this->_logger->warning($message); + break; + case \E_RECOVERABLE_ERROR: + $this->_logger->error($message); + break; + case \E_PARSE: + case \E_CORE_ERROR: + case \E_COMPILE_ERROR: + $this->_logger->alert($message); + break; + } + } elseif ($throwable instanceof \Exception) { + if ($throwable instanceof \PharException || $throwable instanceof \RuntimeException) { + $this->_logger->alert($message); + } + } + + $this->_logger->critical($message); + } + + protected function serializeThrowable(\Throwable $throwable, ThrowableController $controller): string { + $class = $throwable::class; + if ($throwable instanceof \Error) { + $type = $controller->getErrorType(); + $class = ($type !== null) ? "$type (" . $throwable::class . ")" : $throwable::class; + } + + return sprintf( + '%s: %s in file %s on line %d', + $class, + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine() + ); + } +} \ No newline at end of file diff --git a/lib/Catcher/ThrowableController.php b/lib/Catcher/ThrowableController.php new file mode 100644 index 0000000..9a1e85c --- /dev/null +++ b/lib/Catcher/ThrowableController.php @@ -0,0 +1,202 @@ +throwable = $throwable; + } + + + + /** Gets the type name for an Error object */ + public function getErrorType(): ?string { + if ($this->errorType !== false) { + return $this->errorType; + } + + if (!$this->throwable instanceof \Error) { + $this->errorType = null; + return null; + } + + switch ($this->throwable->getCode()) { + case \E_ERROR: + $this->errorType = 'PHP Fatal Error'; + break; + case \E_WARNING: + $this->errorType = 'PHP Warning'; + break; + case \E_PARSE: + $this->errorType = 'PHP Parsing Error'; + break; + case \E_NOTICE: + $this->errorType = 'PHP Notice'; + break; + case \E_CORE_ERROR: + $this->errorType = 'PHP Core Error'; + break; + case \E_CORE_WARNING: + $this->errorType = 'PHP Core Warning'; + break; + case \E_COMPILE_ERROR: + $this->errorType = 'Compile Error'; + break; + case \E_COMPILE_WARNING: + $this->errorType = 'Compile Warning'; + break; + case \E_STRICT: + $this->errorType = 'Runtime Notice'; + break; + case \E_RECOVERABLE_ERROR: + $this->errorType = 'Recoverable Error'; + break; + case \E_DEPRECATED: + case \E_USER_DEPRECATED: + $this->errorType = 'Deprecated'; + break; + case \E_USER_ERROR: + $this->errorType = 'Fatal Error'; + break; + case \E_USER_WARNING: + $this->errorType = 'Warning'; + break; + case \E_USER_NOTICE: + $this->errorType = 'Notice'; + break; + default: + $this->errorType = null; + } + + return $this->errorType; + } + + /** Gets backtrace frames */ + public function getFrames(): array { + if ($this->frames !== null) { + return $this->frames; + } + + if ( + !$this->throwable instanceof \Error || + !in_array($this->throwable->getCode(), [ E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING ]) || + !extension_loaded('xdebug') || + !function_exists('xdebug_info') || + sizeof(xdebug_info('mode')) === 0 + ) { + $frames = $this->throwable->getTrace(); + } else { + $frames = array_diff_key(array_reverse(xdebug_get_function_stack()), debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS)); + } + + // PHP for some stupid reason thinks it's okay not to provide line numbers and file + // names when using call_user_func_array; this fixes that. + // (https://bugs.php.net/bug.php?id=44428) + foreach ($frames as $key => $frame) { + if (empty($frame['file'])) { + $file = '[INTERNAL]'; + $line = 0; + + $next = $frames[$key + 1] ?? []; + + if ( + !empty($frame['file']) && + !empty($frame['function']) && + !empty($frame['line']) && + str_contains($frame['function'], 'call_user_func') + ) { + $file = $next['file']; + $line = $next['line']; + } + + $frames[$key]['file'] = $file; + $frames[$key]['line'] = $line; + } + + $frames[$key]['line'] = (int)$frames[$key]['line']; + } + + // Delete everything that has anything to do with userland error handling + for ($frameCount = count($frames), $i = $frameCount - 1; $i >= 0; $i--) { + $frame = $frames[$i]; + if ($frame['file'] === $this->throwable->getFile() && $frame['line'] === $this->throwable->getLine()) { + array_splice($frames, 0, $i); + break; + } + } + + // Add a frame for the throwable to the beginning of the array + $f = [ + 'file' => $this->throwable->getFile(), + 'line' => (int)$this->throwable->getLine(), + 'class' => $this->throwable::class, + 'args' => [ + $this->throwable->getMessage() + ] + ]; + + // Add the error name if it is an Error. + if ($this->throwable instanceof \Error) { + $error = $this->getErrorType(); + if ($error !== null) { + $f['error'] = $error; + } + } + + array_unshift($frames, $f); + + // Go through previous throwables and merge in their frames + if ($prev = $this->getPrevious()) { + $a = $frames; + $b = $prev->getFrames(); + $prevThrowable = $prev->getThrowable(); + + $diff = $a; + for ($i = count($a) - 1, $j = count($b) - 1; $i >= 0 && $j >= 0; $i--, $j--) { + $af = $diff[$i]['file']; + $bf = $b[$j]['file']; + if ($af && $bf && $af === $bf && $diff[$i]['line'] === $b[$j]['line']) { + unset($diff[$i]); + } + } + + $frames = [ ...$diff, ...$b ]; + } + + $this->frames = $frames; + return $frames; + } + + public function getPrevious(): ?ThrowableController { + if ($this->previousThrowableController !== false) { + return $this->previousThrowableController; + } + + if ($prev = $this->throwable->getPrevious()) { + $prev = new ThrowableController($prev); + } + + $this->previousThrowableController = $prev; + return $prev; + } + + public function getThrowable(): \Throwable { + return $this->throwable; + } +} \ No newline at end of file diff --git a/lib/Catcher/ThrowableHandler.php b/lib/Catcher/ThrowableHandler.php new file mode 100644 index 0000000..0b0332b --- /dev/null +++ b/lib/Catcher/ThrowableHandler.php @@ -0,0 +1,66 @@ + $value) { + $key = "_$key"; + $this->$key = $value; + } + } + + + + + public function getContentType(): ?string { + return static::$contentType; + } + + public function getOutput(): bool { + return $this->_output; + } + + public function getPassthrough(): bool { + return $this->_passthrough; + } + + abstract public function handle(\Throwable $throwable, ThrowableController $controller): bool; + + public function setOutput(bool $value): void { + $this->_output = $value; + } + + public function setPassthrough(bool $value): void { + $this->_passthrough = $value; + } + + protected function sendContentTypeHeader(): void { + if (!isset($_SERVER['REQUEST_URI']) || headers_sent()) { + return; + } + + header('Content-Type: ' . static::$contentType); + } +} \ No newline at end of file diff --git a/lib/Error.php b/lib/Error.php new file mode 100644 index 0000000..a69d753 --- /dev/null +++ b/lib/Error.php @@ -0,0 +1,17 @@ +file = $file; + $this->line = $line; + } +} \ No newline at end of file