From ef765fadc079a065ae4e4a0262b3c1b74d16cc23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:57:55 +0000 Subject: [PATCH 1/3] fix: pass the page through unprocessed after a fatal error A fatal error that hits while MySQL is still sending rows leaves the connection out of sync, so every profiler and cache read at shutdown logged "Commands out of sync". Skip processing in that case. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p --- inc/manager.php | 28 +++++++++ tests/test-fatal-error-passthrough.php | 87 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 tests/test-fatal-error-passthrough.php diff --git a/inc/manager.php b/inc/manager.php index 587fe756..f3fe7dd4 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -983,6 +983,9 @@ public function handle_buffer_fallback( $content, $phase = 0 ) { if ( self::$ob_processed || $content === '' ) { return $content; } + if ( self::is_fatal_error( error_get_last() ) ) { + return $content; + } if ( ! $this->captures_at_shutdown() ) { try { return $this->replace_content( $content, self::is_ajax_request() ); @@ -1049,6 +1052,27 @@ 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 ); + } + /** * Capture our buffer, process it outside the display-handler context and echo the result. * @@ -1085,6 +1109,10 @@ private function capture_and_process_buffer( $is_page = true ) { */ $html = apply_filters( 'optml_captured_page_html', $html ); } + if ( self::is_fatal_error( error_get_last() ) ) { + 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 ); + } +} From 08d08a520313e9fda0a76799768008a5de6f6abc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:47:32 +0000 Subject: [PATCH 2/3] test: cover both fatal-error pass-through paths Read the last PHP error through a private seam so tests can simulate a fatal error. New buffer tests check that the default in-handler mode and the shutdown-capture mode both pass the page through unprocessed; each fails when its guard is removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p --- inc/manager.php | 20 +++++++++++-- tests/test-zz-buffer.php | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/inc/manager.php b/inc/manager.php index f3fe7dd4..2f770624 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 + */ + private static $error_reader = 'error_get_last'; + /** * Class instance method. * @@ -983,7 +990,7 @@ public function handle_buffer_fallback( $content, $phase = 0 ) { if ( self::$ob_processed || $content === '' ) { return $content; } - if ( self::is_fatal_error( error_get_last() ) ) { + if ( self::request_had_fatal_error() ) { return $content; } if ( ! $this->captures_at_shutdown() ) { @@ -1073,6 +1080,15 @@ public static function is_fatal_error( $error ) { 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. * @@ -1109,7 +1125,7 @@ private function capture_and_process_buffer( $is_page = true ) { */ $html = apply_filters( 'optml_captured_page_html', $html ); } - if ( self::is_fatal_error( error_get_last() ) ) { + if ( self::request_had_fatal_error() ) { echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, passed through unprocessed. return true; } diff --git a/tests/test-zz-buffer.php b/tests/test-zz-buffer.php index a7f8e2fa..a74b9353 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 $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. * From a29cee7957de711b504b975d17e20fd6351ac0be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:59:41 +0000 Subject: [PATCH 3/3] docs: give the error reader seam a precise callable type Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p --- inc/manager.php | 2 +- tests/test-zz-buffer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/manager.php b/inc/manager.php index 2f770624..3281c551 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -145,7 +145,7 @@ final class Optml_Manager { /** * Reads the last PHP error; tests swap it to simulate a fatal error. * - * @var callable + * @var callable(): (array{type: int, message: string, file: string, line: int}|null) */ private static $error_reader = 'error_get_last'; diff --git a/tests/test-zz-buffer.php b/tests/test-zz-buffer.php index a74b9353..b6b595a8 100644 --- a/tests/test-zz-buffer.php +++ b/tests/test-zz-buffer.php @@ -79,7 +79,7 @@ private function reset_buffer_state( $processed = false ) { /** * Swap how Optml_Manager reads the last PHP error. * - * @param callable $reader Returns what error_get_last() would. + * @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' );