From 3acf3a58517948c469ec098cb087375f68bacbce Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Fri, 10 Nov 2023 10:14:38 +0100 Subject: [PATCH 1/4] Fix capturing OOM errors when very memory constrained (#1633) --- .github/workflows/ci.yml | 7 +- phpstan-baseline.neon | 15 ++++ phpunit.xml.dist | 6 +- src/ErrorHandler.php | 63 ++++++++++++-- ...ry_fatal_error_increases_memory_limit.phpt | 87 +++++++++++++++++++ ...er_captures_out_of_memory_fatal_error.phpt | 22 +++-- ...r_handler_handles_exception_only_once.phpt | 36 ++++++++ 7 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt create mode 100644 tests/phpt/error_handler_handles_exception_only_once.phpt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7b150bed..e2d2d8a0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,11 @@ jobs: run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest if: ${{ matrix.dependencies == 'lowest' }} - - name: Run tests - run: vendor/bin/phpunit --coverage-clover=coverage.xml + - name: Run unit tests + run: vendor/bin/phpunit --testsuite unit --coverage-clover=coverage.xml + # The reason for running some OOM tests without coverage is that because the coverage information collector can cause another OOM event invalidating the test + - name: Run out of memory tests (without coverage) + run: vendor/bin/phpunit --testsuite oom --no-coverage - name: Upload code coverage uses: codecov/codecov-action@v3 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 39aa31fc7..b3c57673c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -80,6 +80,21 @@ parameters: count: 1 path: src/HttpClient/HttpClientFactory.php + - + message: "#^Method Sentry\\\\HttpClient\\\\Plugin\\\\GzipEncoderPlugin\\:\\:handleRequest\\(\\) has parameter \\$first with generic interface Http\\\\Promise\\\\Promise but does not specify its types\\: T$#" + count: 1 + path: src/HttpClient/Plugin/GzipEncoderPlugin.php + + - + message: "#^Method Sentry\\\\HttpClient\\\\Plugin\\\\GzipEncoderPlugin\\:\\:handleRequest\\(\\) has parameter \\$next with generic interface Http\\\\Promise\\\\Promise but does not specify its types\\: T$#" + count: 1 + path: src/HttpClient/Plugin/GzipEncoderPlugin.php + + - + message: "#^Method Sentry\\\\HttpClient\\\\Plugin\\\\GzipEncoderPlugin\\:\\:handleRequest\\(\\) return type with generic interface Http\\\\Promise\\\\Promise does not specify its types\\: T$#" + count: 1 + path: src/HttpClient/Plugin/GzipEncoderPlugin.php + - message: "#^Property Sentry\\\\Integration\\\\IgnoreErrorsIntegration\\:\\:\\$options \\(array\\{ignore_exceptions\\: array\\\\>, ignore_tags\\: array\\\\}\\) does not accept array\\.$#" count: 1 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f5d14742..d064f8775 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,10 +12,14 @@ - + tests tests/phpt + + + tests/phpt-oom + diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index 1fee7d01c..3db52bd8d 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -22,7 +22,14 @@ final class ErrorHandler * * @internal */ - public const DEFAULT_RESERVED_MEMORY_SIZE = 10240; + public const DEFAULT_RESERVED_MEMORY_SIZE = 16 * 1024; // 16 KiB + + /** + * The regular expression used to match the message of an out of memory error. + * + * Regex inspired by https://github.com/php/php-src/blob/524b13460752fba908f88e3c4428b91fa66c083a/Zend/tests/new_oom.phpt#L15 + */ + private const OOM_MESSAGE_MATCHER = '/^Allowed memory size of (?\d+) bytes exhausted[^\r\n]* \(tried to allocate \d+ bytes\)/'; /** * The fatal error types that cannot be silenced using the @ operator in PHP 8+. @@ -89,11 +96,27 @@ final class ErrorHandler private $isFatalErrorHandlerRegistered = false; /** - * @var string|null A portion of pre-allocated memory data that will be reclaimed - * in case a fatal error occurs to handle it + * @var int|null the amount of bytes of memory to increase the memory limit by when we are capturing a out of memory error, set to null to not increase the memory limit + */ + private $memoryLimitIncreaseOnOutOfMemoryErrorValue = 5 * 1024 * 1024; // 5 MiB + + /** + * @var bool Whether the memory limit has been increased + */ + private static $didIncreaseMemoryLimit = false; + + /** + * @var string|null A portion of pre-allocated memory data that will be reclaimed in case a fatal error occurs to handle it + * + * @phpstan-ignore-next-line This property is used to reserve memory for the fatal error handler and is thus never read */ private static $reservedMemory; + /** + * @var bool Whether the fatal error handler should be disabled + */ + private static $disableFatalErrorHandler = false; + /** * @var string[] List of error levels and their description */ @@ -254,6 +277,20 @@ public function addExceptionHandlerListener(callable $listener): void $this->exceptionListeners[] = $listener; } + /** + * Sets the amount of memory to increase the memory limit by when we are capturing a out of memory error. + * + * @param int|null $valueInBytes the number of bytes to increase the memory limit by, or null to not increase the memory limit + */ + public function setMemoryLimitIncreaseOnOutOfMemoryErrorInBytes(?int $valueInBytes): void + { + if (null !== $valueInBytes && $valueInBytes <= 0) { + throw new \InvalidArgumentException('The $valueInBytes argument must be greater than 0 or null.'); + } + + $this->memoryLimitIncreaseOnOutOfMemoryErrorValue = $valueInBytes; + } + /** * Handles errors by capturing them through the client according to the * configured bit field. @@ -315,16 +352,28 @@ private function handleError(int $level, string $message, string $file, int $lin */ private function handleFatalError(): void { - // If there is not enough memory that can be used to handle the error - // do nothing - if (null === self::$reservedMemory) { + if (self::$disableFatalErrorHandler) { return; } + // Free the reserved memory that allows us to potentially handle OOM errors self::$reservedMemory = null; + $error = error_get_last(); if (!empty($error) && $error['type'] & (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING)) { + // If we did not do so already and we are allowed to increase the memory limit, we do so when we detect an OOM error + if (false === self::$didIncreaseMemoryLimit + && null !== $this->memoryLimitIncreaseOnOutOfMemoryErrorValue + && 1 === preg_match(self::OOM_MESSAGE_MATCHER, $error['message'], $matches) + ) { + $currentMemoryLimit = (int) $matches['memory_limit']; + + ini_set('memory_limit', (string) ($currentMemoryLimit + $this->memoryLimitIncreaseOnOutOfMemoryErrorValue)); + + self::$didIncreaseMemoryLimit = true; + } + $errorAsException = new FatalErrorException(self::ERROR_LEVELS_DESCRIPTION[$error['type']] . ': ' . $error['message'], 0, $error['type'], $error['file'], $error['line']); $this->exceptionReflection->setValue($errorAsException, []); @@ -369,7 +418,7 @@ private function handleException(\Throwable $exception): void // native PHP handler to prevent an infinite loop if ($exception === $previousExceptionHandlerException) { // Disable the fatal error handler or the error will be reported twice - self::$reservedMemory = null; + self::$disableFatalErrorHandler = true; throw $exception; } diff --git a/tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt new file mode 100644 index 000000000..bc6387d5a --- /dev/null +++ b/tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -0,0 +1,87 @@ +--TEST-- +Test that when handling a out of memory error the memory limit is increased with 5 MiB and the event is serialized and ready to be sent +--INI-- +memory_limit=67108864 +--FILE-- +payloadSerializer = $payloadSerializer; + } + + public function send(Event $event): PromiseInterface + { + $serialized = $this->payloadSerializer->serialize($event); + + echo 'Transport called' . PHP_EOL; + + return new FulfilledPromise(new Response(ResponseStatus::success())); + } + + public function close(?int $timeout = null): PromiseInterface + { + return new FulfilledPromise(true); + } + }; + } +}; + +$options = new Options([ + 'dsn' => 'http://public@example.com/sentry/1', +]); + +$client = (new ClientBuilder($options)) + ->setTransportFactory($transportFactory) + ->getClient(); + +SentrySdk::init()->bindClient($client); + +echo 'Before OOM memory limit: ' . ini_get('memory_limit'); + +register_shutdown_function(function () { + echo 'After OOM memory limit: ' . ini_get('memory_limit'); +}); + +$array = []; +for ($i = 0; $i < 100000000; ++$i) { + $array[] = 'sentry'; +} +?> +--EXPECTF-- +Before OOM memory limit: 67108864 +Fatal error: Allowed memory size of %d bytes exhausted (tried to allocate %d bytes) in %s on line %d +Transport called +After OOM memory limit: 72351744 diff --git a/tests/phpt/error_handler_captures_out_of_memory_fatal_error.phpt b/tests/phpt/error_handler_captures_out_of_memory_fatal_error.phpt index 0c788d88b..bcc62e32b 100644 --- a/tests/phpt/error_handler_captures_out_of_memory_fatal_error.phpt +++ b/tests/phpt/error_handler_captures_out_of_memory_fatal_error.phpt @@ -1,7 +1,7 @@ --TEST-- -Test catching out of memory fatal error +Test catching out of memory fatal error without increasing memory limit --INI-- -memory_limit=128M +memory_limit=67108864 --FILE-- addErrorHandlerListener(static function (): void { - echo 'Error listener called (it should not have been)' . PHP_EOL; -}); - -$errorHandler = ErrorHandler::registerOnceFatalErrorHandler(1024 * 1024); +$errorHandler = ErrorHandler::registerOnceFatalErrorHandler(); $errorHandler->addFatalErrorHandlerListener(static function (): void { echo 'Fatal error listener called' . PHP_EOL; -}); -$errorHandler = ErrorHandler::registerOnceExceptionHandler(); -$errorHandler->addExceptionHandlerListener(static function (): void { - echo 'Exception listener called (it should not have been)' . PHP_EOL; + echo 'After OOM memory limit: ' . ini_get('memory_limit'); }); +$errorHandler->setMemoryLimitIncreaseOnOutOfMemoryErrorInBytes(null); + +echo 'Before OOM memory limit: ' . ini_get('memory_limit'); + $foo = str_repeat('x', 1024 * 1024 * 1024); ?> --EXPECTF-- +Before OOM memory limit: 67108864 Fatal error: Allowed memory size of %d bytes exhausted (tried to allocate %d bytes) in %s on line %d Fatal error listener called +After OOM memory limit: 67108864 diff --git a/tests/phpt/error_handler_handles_exception_only_once.phpt b/tests/phpt/error_handler_handles_exception_only_once.phpt new file mode 100644 index 000000000..b57432a79 --- /dev/null +++ b/tests/phpt/error_handler_handles_exception_only_once.phpt @@ -0,0 +1,36 @@ +--TEST-- +Test that exceptions are only handled once +--FILE-- +addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called (should not happen)' . PHP_EOL; +}); + +$errorHandler = ErrorHandler::registerOnceExceptionHandler(); +$errorHandler->addExceptionHandlerListener(static function (): void { + echo 'Exception listener called' . PHP_EOL; +}); + +throw new \Exception('foo bar'); +--EXPECTF-- +Exception listener called + +Fatal error: Uncaught Exception: foo bar in %s:%d +Stack trace: +%a From 481a67163dc428a7d78431c55b0a73609d716774 Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Mon, 13 Nov 2023 12:26:02 +0100 Subject: [PATCH 2/4] Prepare 3.22.1 (#1638) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d402977..ddb571ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 3.22.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v3.22.1. + +### Bug Fixes + +- Fix capturing out-of-memory errors when memory-constrained [(#1633)](https://github.com/getsentry/sentry-php/pull/1633) + ## 3.22.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v3.22.0. From ed2f9a879f0e46bc2066ccf6d00b4b62ab30101f Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 13 Nov 2023 11:26:47 +0000 Subject: [PATCH 3/4] release: 3.22.1 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 5f1cef5fc..bfcee6275 100644 --- a/src/Client.php +++ b/src/Client.php @@ -35,7 +35,7 @@ final class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '3.22.0'; + public const SDK_VERSION = '3.22.1'; /** * @var Options The client options From 8859631ba5ab15bc1af420b0eeed19ecc6c9d81d Mon Sep 17 00:00:00 2001 From: Michael Hoffmann Date: Mon, 13 Nov 2023 12:47:28 +0100 Subject: [PATCH 4/4] Disable Codecov status --- codecov.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index 5c45b27ee..7990059fe 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,6 +6,5 @@ ignore: coverage: status: - project: - default: - threshold: 0.1% # allow for 0.1% reduction of coverage without failing + project: off + patch: off