From a76a9ab0bc641b3efc547d287489ce3e20f56a35 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Fri, 10 Apr 2026 15:05:57 +0530 Subject: [PATCH 1/2] Site Health: Detect and report OPcache saturation conditions --- .../includes/class-wp-site-health.php | 104 +++++++++++++++++- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 75e046ef8ffa7..e42a055e1e74e 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -2802,21 +2802,37 @@ public function get_test_search_engine_visibility() { } /** - * Tests if opcode cache is enabled and available. + * Tests if opcode cache is enabled and available, and checks for saturation. + * + * Detects three distinct saturation conditions when OPcache is enabled: + * memory full, interned strings buffer exhausted, and key/script table full. * * @since 7.0.0 * * @return array> The test result. */ public function get_test_opcode_cache(): array { - $opcode_cache_enabled = false; + $status = false; + if ( function_exists( 'opcache_get_status' ) ) { $status = @opcache_get_status( false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warning emitted in failure case. - if ( $status && true === $status['opcache_enabled'] ) { - $opcode_cache_enabled = true; - } } + /** + * Filters the OPcache status data used by the Site Health opcode cache test. + * + * Allows overriding the array returned by opcache_get_status() for testing + * or for environments where the built-in function does not reflect reality. + * Return false to indicate OPcache is unavailable. + * + * @since x.x.x + * + * @param array|false $status The OPcache status array, or false if unavailable. + */ + $status = apply_filters( 'wp_site_health_opcache_status', $status ); + + $opcode_cache_enabled = $status && isset( $status['opcache_enabled'] ) && true === $status['opcache_enabled']; + $result = array( 'label' => __( 'Opcode cache is enabled' ), 'status' => 'good', @@ -2842,6 +2858,84 @@ public function get_test_opcode_cache(): array { $result['status'] = 'recommended'; $result['label'] = __( 'Opcode cache is not enabled' ); $result['description'] .= '

' . __( 'Enabling this cache can significantly improve the performance of your site.' ) . '

'; + return $result; + } + + if ( $status ) { + $saturation_notices = array(); + + // Check if OPcache memory is full or has been reset due to memory exhaustion. + $memory_oom = isset( $status['opcache_statistics']['oom_restarts'] ) && $status['opcache_statistics']['oom_restarts'] > 0; + if ( ! empty( $status['cache_full'] ) || $memory_oom ) { + $saturation_notices[] = sprintf( + /* translators: %s: PHP ini setting name. */ + __( 'The opcode cache memory is full and PHP is evicting scripts to make room for new ones, which reduces caching effectiveness. Consider increasing the %s PHP setting.' ), + 'opcache.memory_consumption' + ); + } elseif ( isset( + $status['memory_usage']['free_memory'], + $status['memory_usage']['used_memory'], + $status['memory_usage']['wasted_memory'] + ) ) { + $total_memory = $status['memory_usage']['free_memory'] + $status['memory_usage']['used_memory'] + $status['memory_usage']['wasted_memory']; + if ( $total_memory > 0 ) { + $used_percent = ( $status['memory_usage']['used_memory'] + $status['memory_usage']['wasted_memory'] ) / $total_memory; + if ( $used_percent > 0.9 ) { + $saturation_notices[] = sprintf( + /* translators: 1: Percentage of OPcache memory in use. 2: PHP ini setting name. */ + __( 'The opcode cache memory is nearly full (%1$d%% used). Consider increasing the %2$s PHP setting.' ), + (int) ( $used_percent * 100 ), + 'opcache.memory_consumption' + ); + } + } + } + + // Check if the interned strings buffer is nearly exhausted. + if ( isset( $status['interned_strings_usage']['free_memory'], $status['interned_strings_usage']['buffer_size'] ) + && $status['interned_strings_usage']['buffer_size'] > 0 ) { + $interned_free_ratio = $status['interned_strings_usage']['free_memory'] / $status['interned_strings_usage']['buffer_size']; + if ( $interned_free_ratio < 0.1 ) { + $saturation_notices[] = sprintf( + /* translators: 1: Percentage of OPcache interned strings buffer in use. 2: PHP ini setting name. */ + __( 'The interned strings buffer is nearly exhausted (%1$d%% used). Consider increasing the %2$s PHP setting.' ), + (int) ( ( 1 - $interned_free_ratio ) * 100 ), + 'opcache.interned_strings_buffer' + ); + } + } + + // Check if the script/key hash table has overflowed or is nearly full. + if ( ! empty( $status['opcache_statistics']['hash_restarts'] ) ) { + $saturation_notices[] = sprintf( + /* translators: %s: PHP ini setting name. */ + __( 'The opcode cache key table has overflowed and been reset. Consider increasing the %s PHP setting.' ), + 'opcache.max_accelerated_files' + ); + } elseif ( isset( $status['opcache_statistics']['num_cached_keys'], $status['opcache_statistics']['max_cached_keys'] ) + && $status['opcache_statistics']['max_cached_keys'] > 0 ) { + $key_usage = $status['opcache_statistics']['num_cached_keys'] / $status['opcache_statistics']['max_cached_keys']; + if ( $key_usage > 0.9 ) { + $saturation_notices[] = sprintf( + /* translators: 1: Percentage of OPcache key slots in use. 2: PHP ini setting name. */ + __( 'The opcode cache key table is nearly full (%1$d%% used). Consider increasing the %2$s PHP setting.' ), + (int) ( $key_usage * 100 ), + 'opcache.max_accelerated_files' + ); + } + } + + if ( ! empty( $saturation_notices ) ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'Opcode cache is enabled, but appears saturated' ); + foreach ( $saturation_notices as $notice ) { + $result['description'] .= '

' . $notice . '

'; + } + $result['description'] .= sprintf( + '

%s

', + __( 'Ask your hosting provider or server administrator about increasing these opcode cache settings.' ) + ); + } } return $result; From c9b2e47c4e17c8d4c641ad1d9f16f5a4eb092fc4 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Fri, 10 Apr 2026 15:55:49 +0530 Subject: [PATCH 2/2] Add Tests --- tests/phpunit/tests/admin/wpSiteHealth.php | 195 ++++++++++++++++++++- 1 file changed, 193 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..49341911c8274 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -682,6 +682,7 @@ public function test_get_test_opcode_cache_return_structure() { * Tests get_test_opcode_cache() result when opcode cache is enabled or not. * * Covers: opcache enabled, disabled, not available, and opcache_get_status() returns false. + * When enabled, status may be 'good' or 'recommended' if the cache appears saturated. * * @ticket 63697 * @@ -699,12 +700,202 @@ public function test_get_test_opcode_cache_result_by_environment() { } if ( $opcache_enabled ) { - $this->assertSame( 'good', $result['status'], 'When opcache is enabled, status should be "good".' ); - $this->assertSame( __( 'Opcode cache is enabled' ), $result['label'] ); + $this->assertContains( + $result['status'], + array( 'good', 'recommended' ), + 'When opcache is enabled, status should be "good" or "recommended" if saturated.' + ); + $this->assertContains( + $result['label'], + array( __( 'Opcode cache is enabled' ), __( 'Opcode cache is enabled, but appears saturated' ) ) + ); } else { $this->assertSame( 'recommended', $result['status'] ); $this->assertSame( __( 'Opcode cache is not enabled' ), $result['label'] ); $this->assertStringContainsString( __( 'Enabling this cache can significantly improve the performance of your site.' ), $result['description'] ); } } + + /** + * Returns a minimal healthy OPcache status array for use in tests. + * + * @return array A status array with generous free space in all pools. + */ + private function get_healthy_opcache_status(): array { + return array( + 'opcache_enabled' => true, + 'cache_full' => false, + 'memory_usage' => array( + 'used_memory' => 10 * MB_IN_BYTES, + 'free_memory' => 118 * MB_IN_BYTES, + 'wasted_memory' => 0, + ), + 'interned_strings_usage' => array( + 'buffer_size' => 8 * MB_IN_BYTES, + 'free_memory' => 7 * MB_IN_BYTES, + ), + 'opcache_statistics' => array( + 'num_cached_keys' => 110, + 'max_cached_keys' => 16229, + 'oom_restarts' => 0, + 'hash_restarts' => 0, + ), + ); + } + + /** + * Tests get_test_opcode_cache() with a mocked OPcache status via the filter. + * + * @ticket 64854 + * + * @dataProvider data_get_test_opcode_cache_saturation + * + * @covers ::get_test_opcode_cache() + * + * @param array $status_overrides Overrides to merge into the healthy base status. + * @param string $expected_status Expected 'status' value in the result. + * @param string $expected_label Expected 'label' value in the result. + * @param string $expected_in_description Substring expected in the description, or empty string to skip. + */ + public function test_get_test_opcode_cache_saturation( array $status_overrides, string $expected_status, string $expected_label, string $expected_in_description ) { + $base_status = $this->get_healthy_opcache_status(); + $mock_status = array_replace_recursive( $base_status, $status_overrides ); + + add_filter( + 'wp_site_health_opcache_status', + static function () use ( $mock_status ) { + return $mock_status; + } + ); + + $result = $this->instance->get_test_opcode_cache(); + + remove_all_filters( 'wp_site_health_opcache_status' ); + + $this->assertSame( $expected_status, $result['status'], "status mismatch for: $expected_label" ); + $this->assertSame( $expected_label, $result['label'] ); + + if ( '' !== $expected_in_description ) { + $this->assertStringContainsString( $expected_in_description, $result['description'] ); + } + } + + /** + * Data provider for test_get_test_opcode_cache_saturation(). + * + * Each entry provides: + * 1. array Overrides to merge into the healthy base status. + * 2. string Expected 'status' value. + * 3. string Expected 'label' value. + * 4. string Substring expected in description (empty to skip check). + * + * @return array[] + */ + public function data_get_test_opcode_cache_saturation(): array { + return array( + 'healthy opcache is good' => array( + array(), + 'good', + __( 'Opcode cache is enabled' ), + '', + ), + 'opcache disabled via filter' => array( + array( 'opcache_enabled' => false ), + 'recommended', + __( 'Opcode cache is not enabled' ), + __( 'Enabling this cache can significantly improve the performance of your site.' ), + ), + 'cache_full flag set' => array( + array( 'cache_full' => true ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.memory_consumption', + ), + 'oom_restarts greater than zero' => array( + array( 'opcache_statistics' => array( 'oom_restarts' => 3 ) ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.memory_consumption', + ), + 'memory usage above 90 percent' => array( + array( + 'memory_usage' => array( + 'used_memory' => 115 * MB_IN_BYTES, + 'free_memory' => 10 * MB_IN_BYTES, + 'wasted_memory' => 3 * MB_IN_BYTES, + ), + ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.memory_consumption', + ), + 'memory usage at 90 percent is still good' => array( + array( + 'memory_usage' => array( + 'used_memory' => 90 * MB_IN_BYTES, + 'free_memory' => 10 * MB_IN_BYTES, + 'wasted_memory' => 0, + ), + ), + 'good', + __( 'Opcode cache is enabled' ), + '', + ), + 'interned strings below 10 percent free' => array( + array( + 'interned_strings_usage' => array( + 'buffer_size' => 8 * MB_IN_BYTES, + 'free_memory' => 700 * KB_IN_BYTES, + 'used_memory' => ( 8 * MB_IN_BYTES ) - ( 700 * KB_IN_BYTES ), + ), + ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.interned_strings_buffer', + ), + 'interned strings at 10 percent free is good' => array( + array( + 'interned_strings_usage' => array( + 'buffer_size' => 10 * MB_IN_BYTES, + 'free_memory' => 1 * MB_IN_BYTES, + 'used_memory' => 9 * MB_IN_BYTES, + ), + ), + 'good', + __( 'Opcode cache is enabled' ), + '', + ), + 'hash_restarts greater than zero' => array( + array( 'opcache_statistics' => array( 'hash_restarts' => 1 ) ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.max_accelerated_files', + ), + 'key table above 90 percent full' => array( + array( + 'opcache_statistics' => array( + 'num_cached_keys' => 14900, + 'max_cached_keys' => 16229, + ), + ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.max_accelerated_files', + ), + 'multiple saturation issues at once' => array( + array( + 'cache_full' => true, + 'interned_strings_usage' => array( + 'buffer_size' => 8 * MB_IN_BYTES, + 'free_memory' => 100 * KB_IN_BYTES, + 'used_memory' => ( 8 * MB_IN_BYTES ) - ( 100 * KB_IN_BYTES ), + ), + 'opcache_statistics' => array( 'hash_restarts' => 2 ), + ), + 'recommended', + __( 'Opcode cache is enabled, but appears saturated' ), + 'opcache.interned_strings_buffer', + ), + ); + } }