From 74a7373ac4377cf86b91400f065eb98b36cd2d90 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Sun, 1 Nov 2026 16:26:42 +0300 Subject: [PATCH 1/4] Prevent reporting failures from interrupting applications --- src/Handler.php | 155 ++++++++++++-------- tests/Unit/HandlerFailureTest.php | 231 ++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 tests/Unit/HandlerFailureTest.php diff --git a/src/Handler.php b/src/Handler.php index e3b26ff..a7dec43 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -51,6 +51,7 @@ class Handler private $isExceptionHandlerRegistered = false; private $isFatalHandlerRegistered = false; private $disableFatalErrorHandler = false; + private $isCapturing = false; /** * Previous handlers to restore later if needed. @@ -71,7 +72,7 @@ class Handler \E_USER_DEPRECATED => 'User Deprecated', \E_NOTICE => 'Notice', \E_USER_NOTICE => 'User Notice', - \E_STRICT => 'Runtime Notice', + 2048 => 'Runtime Notice', // E_STRICT is deprecated in PHP 8.4. \E_WARNING => 'Warning', \E_USER_WARNING => 'User Warning', \E_COMPILE_WARNING => 'Compile Warning', @@ -184,24 +185,28 @@ public function handleError(int $level, string $message, string $file, int $line } } - if ($isSilencedError) { - $exception = new SilencedErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); - } else { - $exception = new \ErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); - } - - $data = [ - 'exception' => $exception, - 'context' => $this->context, - 'user' => $this->user, - 'type' => $exception->getSeverity() - ]; - - $eventPayload = $this->eventPayloadBuilder->create($data); - $event = $this->buildEvent($eventPayload); - - if ($event !== null && $this->shouldHandleError($level, $isSilencedError)) { - $this->send($event); + if ($this->shouldHandleError($level, $isSilencedError)) { + $this->captureSafely(function () use ($level, $message, $file, $line, $isSilencedError): void { + if ($isSilencedError) { + $exception = new SilencedErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); + } else { + $exception = new \ErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); + } + + $data = [ + 'exception' => $exception, + 'context' => $this->context, + 'user' => $this->user, + 'type' => $exception->getSeverity() + ]; + + $eventPayload = $this->eventPayloadBuilder->create($data); + $event = $this->buildEvent($eventPayload); + + if ($event !== null) { + $this->send($event); + } + }); } if (null !== $this->previousErrorHandler) { @@ -218,18 +223,20 @@ public function handleError(int $level, string $message, string $file, int $line */ public function handleException(\Throwable $exception, array $context = []): void { - $data = [ - 'exception' => $exception, - 'context' => array_merge($this->context, $context), - 'user' => $this->user - ]; - - $eventPayload = $this->eventPayloadBuilder->create($data); - $event = $this->buildEvent($eventPayload); - - if ($event !== null) { - $this->send($event); - } + $this->captureSafely(function () use ($exception, $context): void { + $data = [ + 'exception' => $exception, + 'context' => array_merge($this->context, $context), + 'user' => $this->user + ]; + + $eventPayload = $this->eventPayloadBuilder->create($data); + $event = $this->buildEvent($eventPayload); + + if ($event !== null) { + $this->send($event); + } + }); $previousExceptionHandlerException = $exception; @@ -278,24 +285,26 @@ public function handleFatal(): void return; } - $payload = [ - 'exception' => new \ErrorException( - $error['message'], - 0, - $error['type'], - $error['file'], - $error['line'] - ), - 'context' => $this->context, - 'user' => $this->user - ]; - - $eventPayload = $this->eventPayloadBuilder->create($payload); - $event = $this->buildEvent($eventPayload); - - if ($event !== null) { - $this->send($event); - } + $this->captureSafely(function () use ($error): void { + $payload = [ + 'exception' => new \ErrorException( + $error['message'], + 0, + $error['type'], + $error['file'], + $error['line'] + ), + 'context' => $this->context, + 'user' => $this->user + ]; + + $eventPayload = $this->eventPayloadBuilder->create($payload); + $event = $this->buildEvent($eventPayload); + + if ($event !== null) { + $this->send($event); + } + }); } /** @@ -303,14 +312,24 @@ public function handleFatal(): void */ public function sendEvent(array $payload): void { - $payload['context'] = array_merge($this->context, $payload['context'] ?? []); - $payload['user'] = $this->user; + if ($this->isCapturing) { + return; + } + + $this->isCapturing = true; + + try { + $payload['context'] = array_merge($this->context, $payload['context'] ?? []); + $payload['user'] = $this->user; - $eventPayload = $this->eventPayloadBuilder->create($payload); - $event = $this->buildEvent($eventPayload); + $eventPayload = $this->eventPayloadBuilder->create($payload); + $event = $this->buildEvent($eventPayload); - if ($event !== null) { - $this->send($event); + if ($event !== null) { + $this->send($event); + } + } finally { + $this->isCapturing = false; } } @@ -352,6 +371,30 @@ private function shouldHandleError(int $level, bool $silenced): bool */ private function send(Event $event): void { - $this->transport->send($event); + try { + $this->transport->send($event); + } catch (\Throwable $exception) { + // Transport failures must not interrupt the application. + } + } + + /** + * Reporting must not interrupt the application or report its own failures. + */ + private function captureSafely(callable $capture): void + { + if ($this->isCapturing) { + return; + } + + $this->isCapturing = true; + + try { + $capture(); + } catch (\Throwable $exception) { + // A failed report must not replace the original application error. + } finally { + $this->isCapturing = false; + } } } diff --git a/tests/Unit/HandlerFailureTest.php b/tests/Unit/HandlerFailureTest.php new file mode 100644 index 0000000..6cda4a2 --- /dev/null +++ b/tests/Unit/HandlerFailureTest.php @@ -0,0 +1,231 @@ +createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->willThrowException(new \RuntimeException('Hawk rate limit')); + + $handler = $this->createHandler($transport); + $previousHandlerCalled = false; + $previousReporting = error_reporting(E_ALL); + $registered = false; + + set_error_handler(function () use (&$previousHandlerCalled): bool { + $previousHandlerCalled = true; + + return true; + }); + + try { + $handler->registerErrorHandler(); + $registered = true; + trigger_error('deprecated behavior', E_USER_DEPRECATED); + + $this->assertTrue($previousHandlerCalled); + } finally { + if ($registered) { + restore_error_handler(); + } + restore_error_handler(); + error_reporting($previousReporting); + } + } + + public function testTransportFailureDoesNotReplaceOriginalException(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willThrowException(new \RuntimeException('Hawk unavailable')); + + $handler = $this->createHandler($transport); + $original = new \RuntimeException('Application failure'); + + try { + $handler->handleException($original); + $this->fail('The original exception should be rethrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame($original, $exception); + } + } + + public function testFailureInPreviousErrorHandlerStillReachesApplication(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willThrowException(new \RuntimeException('Hawk unavailable')); + + $handler = $this->createHandler($transport); + $previousFailure = new \RuntimeException('Application error handler failed'); + $previousReporting = error_reporting(E_ALL); + $registered = false; + + set_error_handler(function () use ($previousFailure): void { + throw $previousFailure; + }); + + try { + $handler->registerErrorHandler(); + $registered = true; + + try { + trigger_error('deprecated behavior', E_USER_DEPRECATED); + $this->fail('The previous handler exception should be rethrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame($previousFailure, $exception); + } + } finally { + if ($registered) { + restore_error_handler(); + } + restore_error_handler(); + error_reporting($previousReporting); + } + } + + public function testTransportFailureStillInvokesPreviousExceptionHandler(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willThrowException(new \RuntimeException('Hawk unavailable')); + + $handler = $this->createHandler($transport); + $original = new \RuntimeException('Application failure'); + $handledException = null; + $registered = false; + + set_exception_handler(function (\Throwable $exception) use (&$handledException): void { + $handledException = $exception; + }); + + try { + $handler->registerExceptionHandler(); + $registered = true; + $handler->handleException($original); + + $this->assertSame($original, $handledException); + } finally { + if ($registered) { + restore_exception_handler(); + } + restore_exception_handler(); + } + } + + public function testSendEventCanRecoverAfterTransportFailure(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->exactly(2)) + ->method('send') + ->willThrowException(new \RuntimeException('Hawk unavailable')); + + $handler = $this->createHandler($transport); + $handler->sendEvent(['title' => 'first']); + $handler->sendEvent(['title' => 'second']); + } + + public function testReportingFailureCannotRecursivelyReportItself(): void + { + $transport = $this->createMock(TransportInterface::class); + $handler = $this->createHandler($transport); + + $transport->expects($this->once()) + ->method('send') + ->willReturnCallback(function () use ($handler): void { + $handler->sendEvent(['title' => 'nested']); + }); + + $handler->sendEvent(['title' => 'outer']); + } + + public function testBeforeSendCannotRecursivelyCapture(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once())->method('send'); + + $handler = null; + $beforeSendCalls = 0; + $handler = $this->createHandler($transport, [ + 'beforeSend' => function ($event) use (&$handler, &$beforeSendCalls) { + ++$beforeSendCalls; + $handler->sendEvent(['title' => 'nested']); + + return $event; + } + ]); + + $handler->sendEvent(['title' => 'outer']); + $this->assertSame(1, $beforeSendCalls); + } + + public function testBeforeSendFailureFromManualCaptureIsNotHidden(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->never())->method('send'); + + $failure = new \RuntimeException('Invalid event data'); + $handler = $this->createHandler($transport, [ + 'beforeSend' => function () use ($failure): void { + throw $failure; + } + ]); + + $this->expectExceptionObject($failure); + $handler->sendEvent(['title' => 'event']); + } + + public function testBeforeSendFailureFromAutomaticCaptureIsSuppressed(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->never())->method('send'); + + $handler = $this->createHandler($transport, [ + 'beforeSend' => function (): void { + throw new \RuntimeException('Invalid event data'); + } + ]); + + $previousReporting = error_reporting(E_ALL); + + try { + $this->assertFalse($handler->handleError(E_USER_DEPRECATED, 'deprecated behavior', __FILE__, __LINE__)); + } finally { + error_reporting($previousReporting); + } + } + + public function testTransportErrorDoesNotInterruptApplication(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->willThrowException(new \TypeError('Invalid transport response')); + + $handler = $this->createHandler($transport); + $handler->sendEvent(['title' => 'event']); + } + + private function createHandler(TransportInterface $transport, array $options = []): Handler + { + return new Handler( + new Options($options + ['integrationToken' => 'test-token']), + $transport, + new EventPayloadBuilder(new StacktraceFrameBuilder(new Serializer())) + ); + } +} From c1b5d3835cb252b6a304b8054ef573a5d1cc5599 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Sun, 1 Nov 2026 16:34:53 +0300 Subject: [PATCH 2/4] Report fatal errors during shutdown --- src/Handler.php | 2 +- tests/Fixtures/fatal-handler.php | 45 ++++++++++++++++++++++++ tests/Unit/FatalHandlerTest.php | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/fatal-handler.php create mode 100644 tests/Unit/FatalHandlerTest.php diff --git a/src/Handler.php b/src/Handler.php index a7dec43..d7bf298 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -280,7 +280,7 @@ public function handleFatal(): void if ( $error === null - || is_array($error) && $error['type'] && (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING) + || 0 === ($error['type'] & (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING)) ) { return; } diff --git a/tests/Fixtures/fatal-handler.php b/tests/Fixtures/fatal-handler.php new file mode 100644 index 0000000..215cdbd --- /dev/null +++ b/tests/Fixtures/fatal-handler.php @@ -0,0 +1,45 @@ + 'test-token']), + $transport, + new EventPayloadBuilder(new StacktraceFrameBuilder(new Serializer())) +); +$handler->registerFatalHandler(); + +if ($argv[1] === 'exception') { + $handler->registerExceptionHandler(); + throw new RuntimeException('Unhandled application exception'); +} + +if ($argv[1] === 'fatal') { + $handler->registerErrorHandler(); + $handler->registerExceptionHandler(); + eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); +} + +trigger_error('Non-fatal warning', E_USER_WARNING); diff --git a/tests/Unit/FatalHandlerTest.php b/tests/Unit/FatalHandlerTest.php new file mode 100644 index 0000000..a3f1fa5 --- /dev/null +++ b/tests/Unit/FatalHandlerTest.php @@ -0,0 +1,59 @@ +runFixture('fatal'); + + $this->assertNotSame(0, $exitCode); + $this->assertSame(1, substr_count($output, "HAWK_EVENT\n")); + } + + public function testNonFatalErrorIsNotReportedDuringShutdown(): void + { + list($exitCode, $output) = $this->runFixture('warning'); + + $this->assertSame(0, $exitCode); + $this->assertSame(0, substr_count($output, "HAWK_EVENT\n")); + } + + public function testUncaughtExceptionIsNotReportedTwice(): void + { + list($exitCode, $output) = $this->runFixture('exception'); + + $this->assertNotSame(0, $exitCode); + $this->assertSame(1, substr_count($output, "HAWK_EVENT\n")); + } + + private function runFixture(string $scenario): array + { + if (!function_exists('proc_open')) { + $this->markTestSkipped('proc_open is required to test shutdown errors.'); + } + + $command = escapeshellarg(PHP_BINARY) + . ' -d display_errors=stderr -d log_errors=0 ' + . escapeshellarg(__DIR__ . '/../Fixtures/fatal-handler.php') + . ' ' . escapeshellarg($scenario); + $process = proc_open($command, [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + + $this->assertIsResource($process); + + $output = stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $output]; + } +} From 381d5f3f49dbaa5f92ad8f6193e2ab6427fd2174 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Sun, 1 Nov 2026 16:43:28 +0300 Subject: [PATCH 3/4] Fix fatal handler fixture formatting --- tests/Fixtures/fatal-handler.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Fixtures/fatal-handler.php b/tests/Fixtures/fatal-handler.php index 215cdbd..da6a4a2 100644 --- a/tests/Fixtures/fatal-handler.php +++ b/tests/Fixtures/fatal-handler.php @@ -33,6 +33,7 @@ public function send(Event $event) if ($argv[1] === 'exception') { $handler->registerExceptionHandler(); + throw new RuntimeException('Unhandled application exception'); } From a10f022116e7c1a8354a2008cfeacb1d18ce474d Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Sun, 1 Nov 2026 17:04:49 +0300 Subject: [PATCH 4/4] Preserve fatal reporting after nested and manual exceptions --- src/Handler.php | 22 ++++++++++--- tests/Fixtures/fatal-handler.php | 33 ++++++++++++++++++- tests/Unit/FatalHandlerTest.php | 16 +++++++++ tests/Unit/HandlerFailureTest.php | 54 +++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/Handler.php b/src/Handler.php index d7bf298..568f70d 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -146,7 +146,7 @@ public function registerExceptionHandler(): self return $this; } - $exceptionHandlerCallback = \Closure::fromCallable([$this, 'handleException']); + $exceptionHandlerCallback = \Closure::fromCallable([$this, 'handleUncaughtException']); $this->previousExceptionHandler = set_exception_handler($exceptionHandlerCallback); $this->isExceptionHandlerRegistered = true; @@ -221,8 +221,12 @@ public function handleError(int $level, string $message, string $file, int $line * * @throws \Throwable */ - public function handleException(\Throwable $exception, array $context = []): void + public function handleException(\Throwable $exception, array $context = [], bool $uncaught = false): void { + if ($this->isCapturing) { + return; + } + $this->captureSafely(function () use ($exception, $context): void { $data = [ 'exception' => $exception, @@ -259,12 +263,14 @@ public function handleException(\Throwable $exception, array $context = []): voi // native PHP handler to avoid an infinite loop. if ($exception === $previousExceptionHandlerException) { // Disable the fatal error handler to prevent the error from being reported twice. - $this->disableFatalErrorHandler = true; + if ($uncaught) { + $this->disableFatalErrorHandler = true; + } throw $exception; } - $this->handleException($previousExceptionHandlerException); + $this->handleException($previousExceptionHandlerException, [], $uncaught); } /** @@ -285,6 +291,9 @@ public function handleFatal(): void return; } + // A fatal error can stop the active capture before its finally block runs. + $this->isCapturing = false; + $this->captureSafely(function () use ($error): void { $payload = [ 'exception' => new \ErrorException( @@ -397,4 +406,9 @@ private function captureSafely(callable $capture): void $this->isCapturing = false; } } + + private function handleUncaughtException(\Throwable $exception): void + { + $this->handleException($exception, [], true); + } } diff --git a/tests/Fixtures/fatal-handler.php b/tests/Fixtures/fatal-handler.php index da6a4a2..d061325 100644 --- a/tests/Fixtures/fatal-handler.php +++ b/tests/Fixtures/fatal-handler.php @@ -24,13 +24,40 @@ public function send(Event $event) } }; +$options = ['integrationToken' => 'test-token']; +if ($argv[1] === 'fatal-during-capture') { + $options['beforeSend'] = function ($event) { + static $triggerFatal = true; + + if ($triggerFatal) { + $triggerFatal = false; + eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); + } + + return $event; + }; +} + $handler = new Handler( - new Options(['integrationToken' => 'test-token']), + new Options($options), $transport, new EventPayloadBuilder(new StacktraceFrameBuilder(new Serializer())) ); $handler->registerFatalHandler(); +if ($argv[1] === 'manual-then-fatal') { + $handler->registerErrorHandler(); + $handler->registerExceptionHandler(); + + try { + $handler->handleException(new RuntimeException('Manually reported exception')); + } catch (RuntimeException $exception) { + // Continue to the fatal error after the application handles the exception. + } + + eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); +} + if ($argv[1] === 'exception') { $handler->registerExceptionHandler(); @@ -43,4 +70,8 @@ public function send(Event $event) eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); } +if ($argv[1] === 'fatal-during-capture') { + $handler->sendEvent(['title' => 'Before fatal']); +} + trigger_error('Non-fatal warning', E_USER_WARNING); diff --git a/tests/Unit/FatalHandlerTest.php b/tests/Unit/FatalHandlerTest.php index a3f1fa5..ff9d9c5 100644 --- a/tests/Unit/FatalHandlerTest.php +++ b/tests/Unit/FatalHandlerTest.php @@ -32,6 +32,22 @@ public function testUncaughtExceptionIsNotReportedTwice(): void $this->assertSame(1, substr_count($output, "HAWK_EVENT\n")); } + public function testManualExceptionDoesNotHideLaterFatalError(): void + { + list($exitCode, $output) = $this->runFixture('manual-then-fatal'); + + $this->assertNotSame(0, $exitCode); + $this->assertSame(2, substr_count($output, "HAWK_EVENT\n")); + } + + public function testFatalErrorDuringCaptureIsReported(): void + { + list($exitCode, $output) = $this->runFixture('fatal-during-capture'); + + $this->assertNotSame(0, $exitCode); + $this->assertSame(1, substr_count($output, "HAWK_EVENT\n")); + } + private function runFixture(string $scenario): array { if (!function_exists('proc_open')) { diff --git a/tests/Unit/HandlerFailureTest.php b/tests/Unit/HandlerFailureTest.php index 6cda4a2..1b8498c 100644 --- a/tests/Unit/HandlerFailureTest.php +++ b/tests/Unit/HandlerFailureTest.php @@ -173,6 +173,60 @@ public function testBeforeSendCannotRecursivelyCapture(): void $this->assertSame(1, $beforeSendCalls); } + public function testBeforeSendCannotRecursivelyCaptureException(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once())->method('send'); + + $handler = null; + $handler = $this->createHandler($transport, [ + 'beforeSend' => function ($event) use (&$handler) { + $handler->handleException(new \RuntimeException('Nested exception')); + + return $event; + } + ]); + + $handler->sendEvent(['title' => 'outer']); + } + + public function testNestedExceptionPreservesPreviousExceptionHandler(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->exactly(2))->method('send'); + + $handler = null; + $handler = $this->createHandler($transport, [ + 'beforeSend' => function ($event) use (&$handler) { + $handler->handleException(new \RuntimeException('Nested exception')); + + return $event; + } + ]); + + $previousHandled = []; + $registered = false; + set_exception_handler(function (\Throwable $exception) use (&$previousHandled): void { + $previousHandled[] = $exception; + }); + + try { + $handler->registerExceptionHandler(); + $registered = true; + $handler->sendEvent(['title' => 'outer']); + $this->assertSame([], $previousHandled); + + $later = new \RuntimeException('Later exception'); + $handler->handleException($later); + $this->assertSame([$later], $previousHandled); + } finally { + if ($registered) { + restore_exception_handler(); + } + restore_exception_handler(); + } + } + public function testBeforeSendFailureFromManualCaptureIsNotHidden(): void { $transport = $this->createMock(TransportInterface::class);