Skip to content

Commit 55926cf

Browse files
authored
Merge pull request #43 from async-interop/error-handler
Ensure error handler does NEVER bubble up
2 parents 32da0a5 + e9fd621 commit 55926cf

File tree

7 files changed

+109
-29
lines changed

7 files changed

+109
-29
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,14 @@ interface Promise
6767

6868
All callbacks registered before the `Promise` is resolved MUST be executed in the order they were registered after the `Promise` has been resolved. Callbacks registered after the resolution MUST be executed immediately.
6969

70-
The invocation of `Promise::when()` MUST NOT throw exceptions bubbling up from a `$onResolved` invocation. If one of the callbacks throws an `Exception` or `Throwable`, it MUST be forwarded to `AsyncInterop\Promise\ErrorHandler::notify`. The `Promise` implementation MUST then continue to call the remaining callbacks with the original parameters.
70+
The invocation of `Promise::when()` MUST NOT throw exceptions bubbling up from an `$onResolved` invocation. If one of the callbacks throws an `Exception` or `Throwable`, the `Promise` implementation MUST catch it and call `AsyncInterop\Promise\ErrorHandler::notify()` with the `Exception` or `Throwable` as first argument. The `Promise` implementation MUST then continue to call the remaining callbacks with the original parameters.
7171

7272
Registered callbacks MUST NOT be called from a file with strict types enabled (`declare(strict_types=1)`).
7373

74+
## Error Handling
75+
76+
Uncaught exceptions thrown from callbacks registered to `Promise::when()` are forwarded to the `ErrorHandler` by `Promise` implementations. `ErrorHandler::set()` can be used to register a callable to handle these exceptions gracefully, e.g. by logging them. In case the handler throws again or is not set, an `E_USER_ERROR` is triggered. If a PHP error handler is set using `set_error_handler` and it throws, a short message is written to STDERR and the program exits with code `255`. Thus, it's RECOMMENDED to set an error handler and ensure it doesn't throw, especially if the PHP error handler is set up to convert errors to exceptions.
77+
7478
## Contributors
7579

7680
* [Aaron Piotrowski](https://github.com/trowski)

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"php": ">=5.4.0"
88
},
99
"require-dev": {
10-
"phpunit/phpunit": "^4|^5",
10+
"phpunit/phpunit": "^4.8.25|^5.3.3",
1111
"jakub-onderka/php-parallel-lint": "^0.9.2",
1212
"jakub-onderka/php-console-highlighter": "^0.3.2"
1313
},

src/Promise/ErrorHandler.php

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,22 @@ public static function notify($error)
5555
{
5656
// No type declaration, because of PHP 5 + PHP 7 support.
5757
if (!$error instanceof \Exception && !$error instanceof \Throwable) {
58-
// We have this error handler specifically so we never throw from Promise::when, so it doesn't make sense to
59-
// throw here. We just forward a generic exception to the registered handlers.
60-
$error = new \Exception(sprintf(
61-
"Promise implementation called %s with an invalid argument of type '%s'",
58+
// We have this error handler specifically so we never throw from Promise::when(), so it doesn't make sense
59+
// to throw here. We just forward a generic exception to the registered handlers.
60+
$error = new \Exception(\sprintf(
61+
"Promise implementation called %s() with an invalid argument of type '%s'",
6262
__METHOD__,
63-
is_object($error) ? get_class($error) : gettype($error)
63+
\is_object($error) ? \get_class($error) : \gettype($error)
6464
));
6565
}
6666

6767
if (self::$callback === null) {
68-
trigger_error(
69-
"An exception has been thrown from an AsyncInterop\\Promise::when handler, but no handler has been"
70-
. " registered via AsyncInterop\\Promise\\ErrorHandler::set. A handler has to be registered to"
71-
. " prevent exceptions from going unnoticed. Do NOT install an empty handler that just"
72-
. " does nothing. If the handler is called, there is ALWAYS something wrong.\n\n" . (string) $error,
73-
E_USER_ERROR
68+
self::triggerErrorHandler(
69+
"An exception has been thrown from an AsyncInterop\\Promise::when() handler, but no handler has been"
70+
. " registered via AsyncInterop\\Promise\\ErrorHandler::set(). A handler has to be registered to"
71+
. " prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing."
72+
. " If the handler is called, there is ALWAYS something wrong.",
73+
$error
7474
);
7575

7676
return;
@@ -79,19 +79,61 @@ public static function notify($error)
7979
try {
8080
\call_user_func(self::$callback, $error);
8181
} catch (\Exception $e) {
82-
// We're already a last chance handler, throwing doesn't make sense, so use a real fatal
83-
trigger_error(sprintf(
84-
"An exception has been thrown from the promise error handler registered to %s::set().\n\n%s",
85-
__CLASS__,
86-
(string) $e
87-
), E_USER_ERROR);
82+
self::triggerErrorHandler(
83+
"An exception has been thrown from the promise error handler registered to"
84+
. " AsyncInterop\\Promise\\ErrorHandler::set().",
85+
$e
86+
);
87+
} catch (\Throwable $e) {
88+
self::triggerErrorHandler(
89+
"An exception has been thrown from the promise error handler registered to"
90+
. " AsyncInterop\\Promise\\ErrorHandler::set().",
91+
$e
92+
);
93+
}
94+
}
95+
96+
private static function triggerErrorHandler($message, $error) {
97+
// We're already a last chance handler, throwing doesn't make sense, so use E_USER_ERROR.
98+
// E_USER_ERROR is recoverable by a handler set via set_error_handler, which might throw, too.
99+
100+
try {
101+
\trigger_error(
102+
$message . "\n\n" . (string) $error,
103+
E_USER_ERROR
104+
);
105+
} catch (\Exception $e) {
106+
self::panic($e);
107+
} catch (\Throwable $e) {
108+
self::panic($e);
109+
}
110+
}
111+
112+
private static function panic($error) {
113+
// The set error handler did throw or not exist, PHP's error handler threw, no chance to handle the error
114+
// gracefully at this time. PANIC!
115+
116+
// Print error information to STDERR so the reason for the program abortion can be found, but do not expose
117+
// exception message and trace, as they might contain sensitive information and we do not know whether STDERR
118+
// might be available to an untrusted user.
119+
120+
// Exit with the same code as if PHP exits because of an uncaught exception.
121+
122+
try {
123+
// fputs might fail due to a closed pipe
124+
// no STDERR, because it doesn't exist on piped STDIN
125+
// no finally, because PHP 5.4
126+
\fputs(\fopen("php://stderr", "w"), \sprintf(
127+
"Fatal error: Uncaught exception '%s' while trying to report a throwing AsyncInterop\\Promise::when()"
128+
. " handler gracefully." . \PHP_EOL,
129+
\get_class($error)
130+
));
131+
132+
exit(255);
133+
} catch (\Exception $e) {
134+
exit(255);
88135
} catch (\Throwable $e) {
89-
// We're already a last chance handler, throwing doesn't make sense, so use a real fatal
90-
trigger_error(sprintf(
91-
"An exception has been thrown from the promise error handler registered to %s::set().\n\n%s",
92-
__CLASS__,
93-
(string) $e
94-
), E_USER_ERROR);
136+
exit(255);
95137
}
96138
}
97139
}

test/phpt/error_handler_001.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ AsyncInterop\Promise\ErrorHandler::notify(new Exception);
99

1010
?>
1111
--EXPECTF--
12-
Fatal error: An exception has been thrown from an AsyncInterop\Promise::when handler, but no handler has been registered via AsyncInterop\Promise\ErrorHandler::set. A handler has to be registered to prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing. If the handler is called, there is ALWAYS something wrong.
12+
Fatal error: An exception has been thrown from an AsyncInterop\Promise::when() handler, but no handler has been registered via AsyncInterop\Promise\ErrorHandler::set(). A handler has to be registered to prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing. If the handler is called, there is ALWAYS something wrong.
1313

1414
%s in %s:%d
1515
Stack trace:

test/phpt/error_handler_003.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ AsyncInterop\Promise\ErrorHandler::notify(new Exception);
1111

1212
?>
1313
--EXPECTF--
14-
Fatal error: An exception has been thrown from an AsyncInterop\Promise::when handler, but no handler has been registered via AsyncInterop\Promise\ErrorHandler::set. A handler has to be registered to prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing. If the handler is called, there is ALWAYS something wrong.
14+
Fatal error: An exception has been thrown from an AsyncInterop\Promise::when() handler, but no handler has been registered via AsyncInterop\Promise\ErrorHandler::set(). A handler has to be registered to prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing. If the handler is called, there is ALWAYS something wrong.
1515

1616
%s in %s:%d
1717
Stack trace:

test/phpt/error_handler_004.phpt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ AsyncInterop\Promise\ErrorHandler::notify(42);
99

1010
?>
1111
--EXPECTF--
12-
Fatal error: An exception has been thrown from an AsyncInterop\Promise::when handler, but no handler has been registered via AsyncInterop\Promise\ErrorHandler::set. A handler has to be registered to prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing. If the handler is called, there is ALWAYS something wrong.
12+
Fatal error: An exception has been thrown from an AsyncInterop\Promise::when() handler, but no handler has been registered via AsyncInterop\Promise\ErrorHandler::set(). A handler has to be registered to prevent exceptions from going unnoticed. Do NOT install an empty handler that just does nothing. If the handler is called, there is ALWAYS something wrong.
1313

14-
%SException%SPromise implementation called AsyncInterop\Promise\ErrorHandler::notify with an invalid argument of type 'integer'%S in %s:%d
14+
%SException%SPromise implementation called AsyncInterop\Promise\ErrorHandler::notify() with an invalid argument of type 'integer'%S in %s:%d
1515
Stack trace:
1616
#0 %s(%d): AsyncInterop\Promise\ErrorHandler::notify(%S)
1717
#1 {main} in %s on line %d

test/phpt/error_handler_007.phpt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
--TEST--
2+
ErrorHandler::notify() fatals with a throwing error handler
3+
--SKIPIF--
4+
<?php
5+
6+
if (!file_exists(__DIR__ . "/vendor/autoload.php")) {
7+
die("Skipped: Cannot detect PHPUnit version, run tests from project root.");
8+
}
9+
10+
// If the directory is not the root of the project, this will silently fail and execute the test below.
11+
require __DIR__ . "/vendor/autoload.php";
12+
13+
$series = PHPUnit_Runner_Version::series();
14+
list($major, $minor) = explode(".", $series);
15+
16+
if ($major < 5 || $major == 5 && $minor < 1) {
17+
die("Skipped: PHPUnit 5.1 required to test for STDERR output.");
18+
}
19+
20+
?>
21+
--FILE--
22+
<?php
23+
24+
require __DIR__ . "/../../vendor/autoload.php";
25+
26+
set_error_handler(function () {
27+
throw new RuntimeException;
28+
});
29+
30+
AsyncInterop\Promise\ErrorHandler::notify(new Exception);
31+
32+
?>
33+
--EXPECT--
34+
Fatal error: Uncaught exception 'RuntimeException' while trying to report a throwing AsyncInterop\Promise::when() handler gracefully.

0 commit comments

Comments
 (0)