diff --git a/.gitignore b/.gitignore index a7aa609..6335909 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Catcher-specific /test*.html /test*.php +/test/ /test/* # General diff --git a/README.md b/README.md index b7f0ed1..b8a05b8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [a]: https://php.net/manual/en/book.dom.php [b]: https://github.com/Seldaek/monolog [c]: https://github.com/symfony/yaml +[d]: https://www.php.net/manual/en/function.pcntl-fork.php # Catcher @@ -59,6 +60,26 @@ Catcher comes built-in with the following handlers: * `JSONHandler` – Outputs errors in a JSON format mostly representative of how errors are stored internally by Catcher handlers; it is provided as an example. The decision to make it like this was made because errors often need to be represented according to particular requirements or even a specification, and we cannot possibly support them all. `JSONHandler`, however, can be easily extended to suit individual project needs. * `PlainTextHandler` – Outputs errors cleanly in plain text meant mostly for command line use and also provides for logging +### A Note About Warnings & Notices ### + +As described in the summary paragraph at the beginning of this document, Catcher by default converts all warnings, notices, etc. to `Throwable`s and then proceeds to throw them. Normally, when throwing that halts execution no matter what, but with Catcher that is not the case. + +```php +$catcher = new Catcher(); + +try { + trigger_error(\E_USER_WARNING, 'Ook!'); +} catch (\Throwable $t) { + echo $t->message(); +} +``` + +Output: +``` +Ook! +``` + +This is accomplished internally because of [`pcntl_fork`][d]. The throw is done in a separate fork which causes that fork to exit after the `Throwable` is handled while the main process is allowed to continue. `pcntl_fork` is a POSIX function and therefore is only available for use in CLI UNIX environments; this means that it will work neither in Windows nor in Web environments. We also understand this might be undesirable behavior to some, so turning this off is as simple as setting `Catcher::$forking` to false. ## Documentation @@ -71,6 +92,7 @@ namespace MensBeam\Foundation; use Mensbeam\Foundation\Catcher\Handler; class Catcher { + public bool $forking = true; public bool $preventExit = false; public bool $throwErrors = true; @@ -91,6 +113,7 @@ class Catcher { #### Properties +_forking_: When set to true Catcher will throw converted notices, warnings, etc. in a fork, allowing for execution to continue afterwards _preventExit_: When set to true Catcher won't exit at all even after fatal errors or exceptions _throwErrors_: When set to true Catcher will convert errors to throwables @@ -347,7 +370,7 @@ The default handlers, especially `PlainTextHandler`, are set up to handle most t ```php namespace Your\Namespace\Goes\Here; -use Mensbeam\Foundation\Catcher\Handler, +use MensBeam\Foundation\Catcher\Handler, Symfony\Component\Yaml\Yaml; @@ -389,7 +412,7 @@ class YamlHandler extends Handler { This theoretical class uses the [`symfony/yaml`][c] package in Composer and exposes a few of its options as options for the Handler. Using this class would be very similar to the examples provided at the beginning of this document: ```php -use Mensbeam\Foundation\Catcher, +use MensBeam\Foundation\Catcher, Your\Namespace\Goes\Here\YamlHandler; $catcher = new Catcher(new YamlHandler([ diff --git a/composer.json b/composer.json index c05a8ba..0e26cf6 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "psr/log": "^3.0" }, "suggest": { - "ext-dom": "For HTMLHandler" + "ext-dom": "For HTMLHandler", + "ext-pcntl": "For allowing catching of notices, warnings, etc." }, "require-dev": { "ext-dom": "*", diff --git a/composer.lock b/composer.lock index f195439..c4ae596 100644 --- a/composer.lock +++ b/composer.lock @@ -60,30 +60,30 @@ "packages-dev": [ { "name": "doctrine/instantiator", - "version": "1.4.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", + "doctrine/coding-standard": "^9 || ^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^0.16 || ^1", "phpstan/phpstan": "^1.4", "phpstan/phpstan-phpunit": "^1", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.22" + "vimeo/psalm": "^4.30 || ^5.4" }, "type": "library", "autoload": { @@ -110,7 +110,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" }, "funding": [ { @@ -126,7 +126,7 @@ "type": "tidelift" } ], - "time": "2022-03-03T08:28:38+00:00" + "time": "2022-12-30T00:15:36+00:00" }, { "name": "eloquent/phony", @@ -617,16 +617,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.15.1", + "version": "v4.15.2", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900" + "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", - "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", + "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", "shasum": "" }, "require": { @@ -667,9 +667,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.2" }, - "time": "2022-09-04T07:30:47+00:00" + "time": "2022-11-12T15:38:23+00:00" }, { "name": "phar-io/manifest", @@ -784,16 +784,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.18", + "version": "9.2.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a" + "reference": "9f1f0f9a2fbb680b26d1cf9b61b6eac43a6e4e9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/12fddc491826940cf9b7e88ad9664cf51f0f6d0a", - "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/9f1f0f9a2fbb680b26d1cf9b61b6eac43a6e4e9c", + "reference": "9f1f0f9a2fbb680b26d1cf9b61b6eac43a6e4e9c", "shasum": "" }, "require": { @@ -849,7 +849,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.18" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.23" }, "funding": [ { @@ -857,7 +857,7 @@ "type": "github" } ], - "time": "2022-10-27T13:35:33+00:00" + "time": "2022-12-28T12:41:10+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1102,16 +1102,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.26", + "version": "9.5.27", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2" + "reference": "a2bc7ffdca99f92d959b3f2270529334030bba38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/851867efcbb6a1b992ec515c71cdcf20d895e9d2", - "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a2bc7ffdca99f92d959b3f2270529334030bba38", + "reference": "a2bc7ffdca99f92d959b3f2270529334030bba38", "shasum": "" }, "require": { @@ -1184,7 +1184,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.26" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.27" }, "funding": [ { @@ -1200,7 +1200,7 @@ "type": "tidelift" } ], - "time": "2022-10-28T06:00:21+00:00" + "time": "2022-12-09T07:31:23+00:00" }, { "name": "psr/http-message", @@ -2221,16 +2221,16 @@ }, { "name": "symfony/css-selector", - "version": "v5.4.11", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "c1681789f059ab756001052164726ae88512ae3d" + "reference": "052ef49b660f9ad2a3adb311c555c9bc11ba61f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/c1681789f059ab756001052164726ae88512ae3d", - "reference": "c1681789f059ab756001052164726ae88512ae3d", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/052ef49b660f9ad2a3adb311c555c9bc11ba61f4", + "reference": "052ef49b660f9ad2a3adb311c555c9bc11ba61f4", "shasum": "" }, "require": { @@ -2267,7 +2267,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v5.4.11" + "source": "https://github.com/symfony/css-selector/tree/v5.4.17" }, "funding": [ { @@ -2283,7 +2283,7 @@ "type": "tidelift" } ], - "time": "2022-06-27T16:58:25+00:00" + "time": "2022-12-23T11:40:44+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2369,16 +2369,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.26.0", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" + "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", - "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", "shasum": "" }, "require": { @@ -2387,7 +2387,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.26-dev" + "dev-main": "1.27-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2432,7 +2432,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" }, "funding": [ { @@ -2448,7 +2448,7 @@ "type": "tidelift" } ], - "time": "2022-05-10T07:21:04+00:00" + "time": "2022-11-03T14:55:06+00:00" }, { "name": "symfony/yaml", diff --git a/lib/Catcher.php b/lib/Catcher.php index ebecaad..c218117 100644 --- a/lib/Catcher.php +++ b/lib/Catcher.php @@ -16,6 +16,8 @@ use MensBeam\Foundation\Catcher\{ class Catcher { + /** Fork when throwing non-exiting errors, if available */ + public bool $forking = true; /** When set to true Catcher won't exit when instructed */ public bool $preventExit = false; /** When set to true Catcher will throw errors as throwables */ @@ -158,11 +160,35 @@ class Catcher { if ($code !== 0 && error_reporting()) { $error = new Error($message, $code, $file, $line); if ($this->throwErrors) { - throw $error; - } else { - $this->handleThrowable($error); + // The point of this library is to allow treating of errors as if they were + // exceptions but instead have things like warnings, notices, etc. not stop + // execution. You normally can't have it both ways. So, what's going on here is + // that if the error wouldn't normally stop execution the newly-created Error + // throwable is thrown in a fork instead, allowing execution to resume in the + // parent process. + if ( + $this->forking && + \PHP_SAPI === 'cli' && + function_exists('pcntl_fork') && + !in_array($code, [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_COMPILE_ERROR, \E_USER_ERROR ]) + ) { + $pid = pcntl_fork(); + if ($pid === -1) { + // This can't be covered unless I can somehow fake a misconfigured system + throw new \Exception(message: 'Could not create fork to throw Error', previous: $error); // @codeCoverageIgnore + } elseif (!$pid) { + // This can't be covered because it happens in the fork + throw $error; // @codeCoverageIgnore + } + + pcntl_wait($status); + return true; + } else { + throw $error; + } } + $this->handleThrowable($error); return true; } diff --git a/tests/cases/TestCatcher.php b/tests/cases/TestCatcher.php index 55644a5..30342ba 100644 --- a/tests/cases/TestCatcher.php +++ b/tests/cases/TestCatcher.php @@ -378,6 +378,13 @@ class TestCatcher extends \PHPUnit\Framework\TestCase { $h3->dispatch->called(); $c->throwErrors = true; + try { + trigger_error('Ook!', \E_USER_WARNING); + } catch (\Throwable $t) { + $this->assertInstanceOf(Error::class, $t); + $this->assertSame(\E_USER_WARNING, $t->getCode()); + } + try { trigger_error('Ook!', \E_USER_ERROR); } catch (\Throwable $t) { diff --git a/tests/cases/TestPlainTextHandler.php b/tests/cases/TestPlainTextHandler.php index 1e16626..7846a67 100644 --- a/tests/cases/TestPlainTextHandler.php +++ b/tests/cases/TestPlainTextHandler.php @@ -53,6 +53,23 @@ class TestPlainTextHandler extends \PHPUnit\Framework\TestCase { ob_end_clean(); $l->critical->called(); + + $c = new ThrowableController(new \Exception(message: 'Ook!', previous: new \Error(message: 'Eek!', previous: new Error(message: 'Ack!', code: \E_USER_ERROR)))); + $l = Phony::mock(LoggerInterface::class); + $h = new PlainTextHandler([ + 'logger' => $l->get(), + 'silent' => true + ]); + $o = $h->handle($c); + $this->assertSame(Handler::CONTINUE, $o['controlCode']); + $this->assertSame(Handler::SILENT | Handler::NOW, $o['outputCode']); + $this->assertTrue(isset($o['previous'])); + + ob_start(); + $h->dispatch(); + ob_end_clean(); + + $l->critical->called(); }