diff --git a/inc/manager.php b/inc/manager.php index 587fe756..3281c551 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -142,6 +142,13 @@ final class Optml_Manager { */ private static $ob_processed = false; + /** + * Reads the last PHP error; tests swap it to simulate a fatal error. + * + * @var callable(): (array{type: int, message: string, file: string, line: int}|null) + */ + private static $error_reader = 'error_get_last'; + /** * Class instance method. * @@ -983,6 +990,9 @@ public function handle_buffer_fallback( $content, $phase = 0 ) { if ( self::$ob_processed || $content === '' ) { return $content; } + if ( self::request_had_fatal_error() ) { + return $content; + } if ( ! $this->captures_at_shutdown() ) { try { return $this->replace_content( $content, self::is_ajax_request() ); @@ -1049,6 +1059,36 @@ public function close_final_buffer() { $this->capture_and_process_buffer( false ); } + /** + * Whether the request already died with a fatal error. + * + * A fatal error (memory exhausted, time limit) that hits while MySQL is still + * sending a query's rows leaves the connection out of sync, so every later + * query in the request fails with "Commands out of sync". The page is then + * passed through unprocessed, without the profiler and cache reads that would + * each log that error. + * + * @param array|null $error The last error, from error_get_last(). + * + * @return bool + */ + public static function is_fatal_error( $error ) { + if ( ! is_array( $error ) || ! isset( $error['type'] ) ) { + return false; + } + + return in_array( (int) $error['type'], [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ], true ); + } + + /** + * Whether the current request already hit a fatal error. + * + * @return bool + */ + private static function request_had_fatal_error() { + return self::is_fatal_error( call_user_func( self::$error_reader ) ); + } + /** * Capture our buffer, process it outside the display-handler context and echo the result. * @@ -1085,6 +1125,10 @@ private function capture_and_process_buffer( $is_page = true ) { */ $html = apply_filters( 'optml_captured_page_html', $html ); } + if ( self::request_had_fatal_error() ) { + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, passed through unprocessed. + return true; + } echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page. } return true; diff --git a/tests/test-fatal-error-passthrough.php b/tests/test-fatal-error-passthrough.php new file mode 100644 index 00000000..ef05c13e --- /dev/null +++ b/tests/test-fatal-error-passthrough.php @@ -0,0 +1,87 @@ +assertTrue( Optml_Manager::is_fatal_error( [ 'type' => $type, 'message' => 'Allowed memory size exhausted' ] ) ); + } + + /** + * @return array + */ + public function fatal_error_types(): array { + return [ + 'E_ERROR' => [ E_ERROR ], + 'E_PARSE' => [ E_PARSE ], + 'E_CORE_ERROR' => [ E_CORE_ERROR ], + 'E_COMPILE_ERROR' => [ E_COMPILE_ERROR ], + ]; + } + + /** + * Warnings, notices and deprecations don't stop the page from being processed. + * + * @dataProvider non_fatal_errors + * + * @param array|null $error The last error. + */ + public function test_other_errors_are_not_fatal( $error ): void { + $this->assertFalse( Optml_Manager::is_fatal_error( $error ) ); + } + + /** + * @return array|null}> + */ + public function non_fatal_errors(): array { + return [ + 'no error' => [ null ], + 'E_WARNING' => [ [ 'type' => E_WARNING ] ], + 'E_NOTICE' => [ [ 'type' => E_NOTICE ] ], + 'E_DEPRECATED' => [ [ 'type' => E_DEPRECATED ] ], + 'no type' => [ [ 'message' => 'x' ] ], + ]; + } + + /** + * A warning earlier in the request still lets the output handler process the page. + */ + public function test_output_handler_processes_page_after_a_warning(): void { + $settings = new Optml_Settings(); + $settings->update( + 'service_data', + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.org' ], + ] + ); + $settings->update( 'lazyload', 'disabled' ); + Optml_Url_Replacer::instance()->init(); + Optml_Tag_Replacer::instance()->init(); + Optml_Manager::instance()->init(); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- sets error_get_last() to a warning. + @trigger_error( 'earlier warning', E_USER_WARNING ); + $html = ''; + + $result = Optml_Manager::instance()->handle_buffer_fallback( $html ); + + $this->assertStringContainsString( 'i.optimole.com', $result ); + } +} diff --git a/tests/test-zz-buffer.php b/tests/test-zz-buffer.php index a7f8e2fa..b6b595a8 100644 --- a/tests/test-zz-buffer.php +++ b/tests/test-zz-buffer.php @@ -56,6 +56,7 @@ public function tearDown(): void { } } $this->reset_buffer_state(); + $this->set_error_reader( 'error_get_last' ); remove_filter( 'optml_capture_at_shutdown', '__return_true' ); remove_filter( 'optml_capture_at_shutdown', '__return_false' ); parent::tearDown(); @@ -75,6 +76,70 @@ private function reset_buffer_state( $processed = false ) { } } + /** + * Swap how Optml_Manager reads the last PHP error. + * + * @param callable(): (array{type: int, message: string, file: string, line: int}|null) $reader Returns what error_get_last() would. + */ + private function set_error_reader( $reader ) { + $prop = new ReflectionProperty( Optml_Manager::class, 'error_reader' ); + $prop->setAccessible( true ); + $prop->setValue( null, $reader ); + } + + /** + * Simulate a request that already died with a fatal error. + */ + private function simulate_fatal_error() { + $this->set_error_reader( + function () { + return [ + 'type' => E_ERROR, + 'message' => 'Allowed memory size of 8388608 bytes exhausted', + 'file' => __FILE__, + 'line' => __LINE__, + ]; + } + ); + } + + /** + * After a fatal error the default in-handler mode passes the page through unprocessed. + */ + public function test_default_mode_passes_page_through_after_fatal_error() { + remove_filter( 'optml_capture_at_shutdown', '__return_true' ); + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $this->simulate_fatal_error(); + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( self::IMG_TAGS, $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * After a fatal error the shutdown-capture mode echoes the page once, unprocessed, + * and late shutdown output is passed through too. + */ + public function test_capture_mode_passes_page_through_after_fatal_error() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $this->simulate_fatal_error(); + $manager->close_buffer(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( self::IMG_TAGS . self::IMG_TAGS, $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + /** * Callbacks on our filters may use output buffering without fataling. *