diff --git a/assets/css/litespeed.css b/assets/css/litespeed.css index 7ddb5e910..7830b72d9 100644 --- a/assets/css/litespeed.css +++ b/assets/css/litespeed.css @@ -772,6 +772,62 @@ h3 .litespeed-learn-more { padding: 0.5rem 0.75rem; } +/* Object Cache benchmark — sits inside .litespeed-block. Theme-friendly outer + border (light grey that reads on both light and dark backgrounds via + currentColor + alpha) plus a slight header tint to distinguish the head + row from data. Background stays transparent so the parent block colour + shows through (no "lighter than parent" wash). */ +.litespeed-oc-benchmark-table { + width: auto; + max-width: 100%; + margin: 0.75rem 0 0.75rem 0; + background: transparent; + border-collapse: collapse; + border: 1px solid rgba(127, 127, 127, 0.28); + border-radius: 4px; + overflow: hidden; +} + +.litespeed-oc-benchmark-table th, +.litespeed-oc-benchmark-table td { + padding: 0.45rem 0.9rem; + text-align: left; + vertical-align: middle; + background: transparent; + border-bottom: 1px solid rgba(127, 127, 127, 0.18); +} + +.litespeed-oc-benchmark-table th { + font-weight: 600; + white-space: nowrap; + background: rgba(127, 127, 127, 0.08); + border-bottom: 1px solid rgba(127, 127, 127, 0.28); +} + +.litespeed-oc-benchmark-table tbody tr:last-child td { + border-bottom: 0; +} + +.litespeed-oc-benchmark-fastest td { + font-weight: 700; + background: rgba(127, 127, 127, 0.05); +} + +.litespeed-oc-bench-spinner { + display: inline-block; + width: 0.85em; + height: 0.85em; + vertical-align: -0.15em; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: litespeed-oc-bench-spin 0.7s linear infinite; +} + +@keyframes litespeed-oc-bench-spin { + to { transform: rotate(360deg); } +} + /* ======================================= LAYOUT - block ======================================= */ diff --git a/data/const.default.json b/data/const.default.json index 9d7141f1f..08faf84fc 100644 --- a/data/const.default.json +++ b/data/const.default.json @@ -104,7 +104,7 @@ "optm-exc": "", "optm-guest_only": "1", "object": "", - "object-kind": "", + "object-kind": "2", "object-host": "localhost", "object-port": "11211", "object-life": "360", diff --git a/src/admin-display.cls.php b/src/admin-display.cls.php index 5fcf883bf..c82a12e91 100644 --- a/src/admin-display.cls.php +++ b/src/admin-display.cls.php @@ -973,6 +973,9 @@ public function form_action( $action = false, $type = false, $has_upload = false echo '
'; } else { $current = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + // Drop transient UI query args so a POST to this URL doesn't re-trigger + // page-level actions (e.g. the Object Cache benchmark) on save. + $current = remove_query_arg( 'ls_oc_benchmark', $current ); if ( $has_upload ) { echo '
'; } else { diff --git a/src/admin-settings.cls.php b/src/admin-settings.cls.php index b66a77df4..91fdafd42 100644 --- a/src/admin-settings.cls.php +++ b/src/admin-settings.cls.php @@ -66,7 +66,7 @@ public function save( $raw_data ) { // Validate $child. if ( self::O_CDN_MAPPING === $id ) { - if ( ! in_array( $child, [ self::CDN_MAPPING_URL, self::CDN_MAPPING_INC_IMG, self::CDN_MAPPING_INC_CSS, self::CDN_MAPPING_INC_JS, self::CDN_MAPPING_FILETYPE ], true ) ) { + if ( ! in_array( $child, [ self::CDN_MAPPING_URL, self::CDN_MAPPING_INC_IMG, self::CDN_MAPPING_INC_CSS, self::CDN_MAPPING_INC_JS, self::CDN_MAPPING_INC_DOCS, self::CDN_MAPPING_INC_FONTS, self::CDN_MAPPING_INC_MEDIA, self::CDN_MAPPING_FILETYPE ], true ) ) { continue; } } @@ -139,7 +139,7 @@ public function save( $raw_data ) { $v = trailingslashit( $v ); } - if ( in_array( $child, [ self::CDN_MAPPING_INC_IMG, self::CDN_MAPPING_INC_CSS, self::CDN_MAPPING_INC_JS ], true ) ) { + if ( in_array( $child, [ self::CDN_MAPPING_INC_IMG, self::CDN_MAPPING_INC_CSS, self::CDN_MAPPING_INC_JS, self::CDN_MAPPING_INC_DOCS, self::CDN_MAPPING_INC_FONTS, self::CDN_MAPPING_INC_MEDIA ], true ) ) { // Because these can't be auto detected in `config->update()`, need to format here. $v = 'false' === $v ? 0 : (bool) $v; } @@ -312,6 +312,55 @@ function ( $arr ) { } } + // When Method = Automatic AND Object Cache is being kept on, detect a + // working backend now and persist the resolved host/port (and the + // concrete backend) so the user sees what was picked. If OC is off + // or detection fails, leave Auto as-is — runtime resolution handles it. + $kind_submitted = array_key_exists( self::O_OBJECT_KIND, $the_matrix ); + $object_submitted = array_key_exists( self::O_OBJECT, $the_matrix ); + $object_will_be_on = $object_submitted + ? (bool) $the_matrix[ self::O_OBJECT ] + : (bool) $this->conf( self::O_OBJECT ); + + if ( + $kind_submitted + && Object_Cache::KIND_AUTO === (int) $the_matrix[ self::O_OBJECT_KIND ] + && $object_will_be_on + ) { + $current_host = array_key_exists( self::O_OBJECT_HOST, $the_matrix ) + ? (string) $the_matrix[ self::O_OBJECT_HOST ] + : (string) $this->conf( self::O_OBJECT_HOST ); + $current_port = array_key_exists( self::O_OBJECT_PORT, $the_matrix ) + ? (int) $the_matrix[ self::O_OBJECT_PORT ] + : (int) $this->conf( self::O_OBJECT_PORT ); + + $detected = $this->cls( 'Object_Cache' )->auto_detect( [ + 'host' => $current_host, + 'port' => $current_port, + ] ); + + if ( $detected ) { + // Persist the detected host/port for visibility in the form, but + // keep Method = Automatic so each boot re-resolves against the + // live environment. + $the_matrix[ self::O_OBJECT_HOST ] = $detected['host']; + $the_matrix[ self::O_OBJECT_PORT ] = (int) $detected['port']; + + $endpoint = '/' === substr( (string) $detected['host'], 0, 1 ) + ? $detected['host'] + : $detected['host'] . ':' . (int) $detected['port']; + + Admin_Display::success( sprintf( + /* translators: 1: backend name, 2: host:port or socket path */ + __( 'Object Cache auto-detected: %1$s at %2$s.', 'litespeed-cache' ), + $detected['kind_label'], + $endpoint + ) ); + } else { + Admin_Display::error( __( 'Object Cache auto-detection could not find a working Redis/Valkey or Memcached connection. Method has been left as Automatic; runtime will keep retrying.', 'litespeed-cache' ) ); + } + } + // id validation will be inside. $this->cls( 'Conf' )->update_confs( $the_matrix ); diff --git a/src/base.cls.php b/src/base.cls.php index 95a4dc7c4..098d3398a 100644 --- a/src/base.cls.php +++ b/src/base.cls.php @@ -893,6 +893,14 @@ protected function _conf_multi_switch( $id ) { return self::VAL_ON2; } + // Object Cache Method is a tri-state: Memcached (0), Redis/Valkey (1), + // Auto (2). Without this registration the bool-default in + // $_default_options collapses Auto to true → 1 in type_casting() and + // the saved value silently becomes Redis on every save. + if ( self::O_OBJECT_KIND === $id ) { + return self::VAL_ON2; + } + return false; } diff --git a/src/conf.cls.php b/src/conf.cls.php index 64af27d1e..f3adbb527 100644 --- a/src/conf.cls.php +++ b/src/conf.cls.php @@ -472,6 +472,14 @@ private function _define_cache_on() { */ public function update_confs( $the_matrix = [] ) { if ( $the_matrix ) { + // Drop the cached entries so update_option()'s no-op compare reads + // from MySQL — a stale alloptions blob can otherwise hide the write. + foreach ( array_keys( $the_matrix ) as $id ) { + wp_cache_delete( self::name( $id ), 'options' ); + } + wp_cache_delete( 'alloptions', 'options' ); + wp_cache_delete( 'notoptions', 'options' ); + foreach ( $the_matrix as $id => $val ) { $this->update( $id, $val ); } @@ -510,6 +518,29 @@ public function update_confs( $the_matrix = [] ) { // Update related files $this->cls( 'Activation' )->update_files(); + // Reset the Object_Cache singleton and bust its cached probes when any + // backend setting changes, so the same-request Status panel re-resolves. + $object_ids = [ + self::O_OBJECT, + self::O_OBJECT_KIND, + self::O_OBJECT_HOST, + self::O_OBJECT_PORT, + self::O_OBJECT_LIFE, + self::O_OBJECT_PERSISTENT, + self::O_OBJECT_ADMIN, + self::O_OBJECT_DB_ID, + self::O_OBJECT_USER, + self::O_OBJECT_PSWD, + self::O_OBJECT_GLOBAL_GROUPS, + self::O_OBJECT_NON_PERSISTENT_GROUPS, + ]; + if ( array_intersect( $object_ids, $this->_updated_ids ) ) { + $this->cls( 'Object_Cache', true ); + delete_transient( Object_Cache::TRANS_CONN_TEST ); + delete_transient( Object_Cache::TRANS_AUTO_RESOLVED ); + delete_transient( Object_Cache::TRANS_BENCHMARK ); + } + /** * CDN related actions - Cloudflare */ diff --git a/src/core.cls.php b/src/core.cls.php index e699f8548..0f30935da 100644 --- a/src/core.cls.php +++ b/src/core.cls.php @@ -190,6 +190,7 @@ public function init() { do_action( 'litespeed_init' ); add_action( 'wp_ajax_async_litespeed', 'LiteSpeed\Task::async_litespeed_handler' ); add_action( 'wp_ajax_nopriv_async_litespeed', 'LiteSpeed\Task::async_litespeed_handler' ); + add_action( 'wp_ajax_litespeed_oc_benchmark', 'LiteSpeed\Object_Cache::ajax_benchmark' ); // In `after_setup_theme`, before `init` hook $this->cls( 'Activation' )->auto_update(); diff --git a/src/object-cache.cls.php b/src/object-cache.cls.php index 356d9be20..fa1107b4c 100644 --- a/src/object-cache.cls.php +++ b/src/object-cache.cls.php @@ -113,6 +113,23 @@ class Object_Cache extends Root { */ const O_OBJECT_NON_PERSISTENT_GROUPS = 'object-non_persistent_groups'; + /** + * O_OBJECT_KIND values. KIND_AUTO is persisted as 2 and resolved to a + * concrete backend at runtime so the choice tracks the live environment. + */ + const KIND_MEMCACHED = 0; + const KIND_REDIS = 1; + const KIND_AUTO = 2; + + // Caches the runtime Auto-method resolution so each page load skips the candidate scan. + const TRANS_AUTO_RESOLVED = 'litespeed_oc_auto_resolved'; + + // Caches the connection-test result rendered on the Object Cache settings tab. + const TRANS_CONN_TEST = 'litespeed_oc_conn_test'; + + // Caches the last backend benchmark run; busted when Object Cache settings change. + const TRANS_BENCHMARK = 'litespeed_oc_benchmark'; + /** * Connection instance. * @@ -236,7 +253,6 @@ public function __construct( $cfg = false ) { $cfg[ Base::O_OBJECT_NON_PERSISTENT_GROUPS ] = explode( "\n", $cfg[ Base::O_OBJECT_NON_PERSISTENT_GROUPS ] ); } $this->_cfg_debug = $cfg[ Base::O_DEBUG ] ? $cfg[ Base::O_DEBUG ] : false; - $this->_cfg_method = $cfg[ Base::O_OBJECT_KIND ] ? true : false; $this->_cfg_host = $cfg[ Base::O_OBJECT_HOST ]; $this->_cfg_port = $cfg[ Base::O_OBJECT_PORT ]; $this->_cfg_persistent = $cfg[ Base::O_OBJECT_PERSISTENT ]; @@ -247,13 +263,14 @@ public function __construct( $cfg = false ) { $this->_global_groups = $cfg[ Base::O_OBJECT_GLOBAL_GROUPS ]; $this->_non_persistent_groups = $cfg[ Base::O_OBJECT_NON_PERSISTENT_GROUPS ]; + $this->_resolve_method( (int) $cfg[ Base::O_OBJECT_KIND ] ); + if ( $this->_cfg_method ) { $this->_oc_driver = 'Redis'; } $this->_cfg_enabled = $cfg[ Base::O_OBJECT ] && class_exists( $this->_oc_driver ) && $this->_cfg_host; } elseif ( defined( 'LITESPEED_CONF_LOADED' ) ) { // If OC is OFF, will hit here to init OC after conf initialized $this->_cfg_debug = $this->conf( Base::O_DEBUG ) ? $this->conf( Base::O_DEBUG ) : false; - $this->_cfg_method = $this->conf( Base::O_OBJECT_KIND ) ? true : false; $this->_cfg_host = $this->conf( Base::O_OBJECT_HOST ); $this->_cfg_port = $this->conf( Base::O_OBJECT_PORT ); $this->_cfg_persistent = $this->conf( Base::O_OBJECT_PERSISTENT ); @@ -264,6 +281,8 @@ public function __construct( $cfg = false ) { $this->_global_groups = $this->conf( Base::O_OBJECT_GLOBAL_GROUPS ); $this->_non_persistent_groups = $this->conf( Base::O_OBJECT_NON_PERSISTENT_GROUPS ); + $this->_resolve_method( (int) $this->conf( Base::O_OBJECT_KIND ) ); + if ( $this->_cfg_method ) { $this->_oc_driver = 'Redis'; } @@ -271,12 +290,13 @@ public function __construct( $cfg = false ) { } elseif ( defined( 'self::CONF_FILE' ) && file_exists( WP_CONTENT_DIR . '/' . self::CONF_FILE ) ) { // Get cfg from _data_file. // Use self::const to avoid loading more classes. - $cfg = \json_decode( file_get_contents( WP_CONTENT_DIR . '/' . self::CONF_FILE ), true ); - if ( ! empty( $cfg[ self::O_OBJECT_HOST ] ) ) { + $cfg = \json_decode( file_get_contents( WP_CONTENT_DIR . '/' . self::CONF_FILE ), true ); + $file_kind = isset( $cfg[ self::O_OBJECT_KIND ] ) ? (int) $cfg[ self::O_OBJECT_KIND ] : self::KIND_MEMCACHED; + // Auto walks its own candidate chain, so don't gate on a saved host. + if ( ! empty( $cfg[ self::O_OBJECT_HOST ] ) || self::KIND_AUTO === $file_kind ) { $this->_cfg_debug = ! empty( $cfg[ Base::O_DEBUG ] ) ? $cfg[ Base::O_DEBUG ] : false; - $this->_cfg_method = ! empty( $cfg[ self::O_OBJECT_KIND ] ) ? $cfg[ self::O_OBJECT_KIND ] : false; - $this->_cfg_host = $cfg[ self::O_OBJECT_HOST ]; - $this->_cfg_port = $cfg[ self::O_OBJECT_PORT ]; + $this->_cfg_host = isset( $cfg[ self::O_OBJECT_HOST ] ) ? $cfg[ self::O_OBJECT_HOST ] : ''; + $this->_cfg_port = isset( $cfg[ self::O_OBJECT_PORT ] ) ? $cfg[ self::O_OBJECT_PORT ] : 0; $this->_cfg_persistent = ! empty( $cfg[ self::O_OBJECT_PERSISTENT ] ) ? $cfg[ self::O_OBJECT_PERSISTENT ] : false; $this->_cfg_admin = ! empty( $cfg[ self::O_OBJECT_ADMIN ] ) ? $cfg[ self::O_OBJECT_ADMIN ] : false; $this->_cfg_db = ! empty( $cfg[ self::O_OBJECT_DB_ID ] ) ? $cfg[ self::O_OBJECT_DB_ID ] : 0; @@ -285,6 +305,8 @@ public function __construct( $cfg = false ) { $this->_global_groups = ! empty( $cfg[ self::O_OBJECT_GLOBAL_GROUPS ] ) ? $cfg[ self::O_OBJECT_GLOBAL_GROUPS ] : []; $this->_non_persistent_groups = ! empty( $cfg[ self::O_OBJECT_NON_PERSISTENT_GROUPS ] ) ? $cfg[ self::O_OBJECT_NON_PERSISTENT_GROUPS ] : []; + $this->_resolve_method( $file_kind ); + if ( $this->_cfg_method ) { $this->_oc_driver = 'Redis'; } @@ -305,6 +327,67 @@ public function __construct( $cfg = false ) { } } + /** + * Resolve O_OBJECT_KIND into $_cfg_method (true=Redis, false=Memcached), + * running auto-detection when kind is KIND_AUTO. May overwrite host/port + * with the detected values. The result is cached in a short transient + * where available; the dropin bootstrap runs inline. + * + * @since 7.8.1 + * @access private + * + * @param int $kind Saved O_OBJECT_KIND value (0/1/2). + * @return void + */ + private function _resolve_method( $kind ) { + if ( self::KIND_REDIS === $kind ) { + $this->_cfg_method = true; + return; + } + if ( self::KIND_AUTO !== $kind ) { + $this->_cfg_method = false; + return; + } + + // $wp_object_cache is null while wp_cache_init() is still building it + // (and the dropin calls us from inside that constructor), so transient + // access would fatal. Fall through to inline detection. + $transients_safe = function_exists( 'get_transient' ) && ! empty( $GLOBALS['wp_object_cache'] ); + + if ( $transients_safe ) { + $cached = get_transient( self::TRANS_AUTO_RESOLVED ); + if ( is_array( $cached ) && isset( $cached['kind'], $cached['host'] ) ) { + $this->_cfg_method = self::KIND_REDIS === (int) $cached['kind']; + $this->_cfg_host = $cached['host']; + $this->_cfg_port = (int) $cached['port']; + return; + } + } + + $detected = $this->auto_detect( [ + 'host' => $this->_cfg_host, + 'port' => $this->_cfg_port, + ] ); + + if ( $detected ) { + $this->_cfg_method = self::KIND_REDIS === (int) $detected['kind']; + $this->_cfg_host = $detected['host']; + $this->_cfg_port = (int) $detected['port']; + + if ( $transients_safe && function_exists( 'set_transient' ) ) { + set_transient( self::TRANS_AUTO_RESOLVED, [ + 'kind' => (int) $detected['kind'], + 'host' => $detected['host'], + 'port' => (int) $detected['port'], + ], 5 * MINUTE_IN_SECONDS ); + } + return; + } + + // Detection failed — keep host/port and default to Memcached. + $this->_cfg_method = false; + } + /** * Add debug. * @@ -400,15 +483,975 @@ public function del_file() { } /** - * Try to build connection. + * Run the connection test rendered on the Object Cache settings tab. + * + * Tries the configured host/port first. If that fails (or no host is set) + * and the user has at least one backend extension loaded, it falls through + * the priority chain — localhost, 127.0.0.1, then the auto-detected socket + * candidates — and reports the first working backend. + * + * Result shape: + * [ + * 'ok' => bool|null, // null => no extension installed + * 'source' => 'configured'|'detected'|'unavailable', + * 'kind' => 'Redis / Valkey'|'Memcached'|null, // user-facing label + * 'host' => string|null, + * 'port' => int|null, + * 'detail' => string, // short human-readable summary + * ] + * + * Results are cached in a short transient so reloading the settings tab + * doesn't keep scanning sockets. The transient is cleared whenever the + * Object Cache settings are saved. * * @since 1.8 + * @since 7.8.1 Returns a structured array (was bool|null) and runs the + * full multi-candidate fallback chain. * @access public * - * @return bool|null False on failure, true on success, null if unsupported. + * @return array */ public function test_connection() { - return $this->_connect(); + $cached = get_transient( self::TRANS_CONN_TEST ); + if ( is_array( $cached ) ) { + return $cached; + } + + $result = $this->_run_connection_test(); + set_transient( self::TRANS_CONN_TEST, $result, 5 * MINUTE_IN_SECONDS ); + + return $result; + } + + /** + * Build the connection-test result without touching the transient cache. + * + * @since 7.8.1 + * @access private + * + * @return array + */ + private function _run_connection_test() { + $redis_loaded = class_exists( 'Redis' ); + $mem_loaded = class_exists( 'Memcached' ); + + if ( ! $redis_loaded && ! $mem_loaded ) { + return [ + 'ok' => null, + 'source' => 'unavailable', + 'kind' => null, + 'host' => null, + 'port' => null, + 'detail' => '', + ]; + } + + // 1. Configured connection. Catch the socket+port misconfig explicitly + // so the user sees a useful error instead of a generic connect failure. + if ( $this->_cfg_host ) { + $kind = $this->_cfg_method ? 'Redis' : 'Memcached'; + + if ( $this->_is_socket_path( $this->_cfg_host ) && (int) $this->_cfg_port > 0 ) { + return [ + 'ok' => false, + 'source' => 'configured', + 'kind' => $this->_kind_label( $kind ), + 'host' => $this->_cfg_host, + 'port' => (int) $this->_cfg_port, + 'detail' => sprintf( + /* translators: %s: socket path */ + __( 'Host %s is a Unix socket but Port is not 0. Set Port to 0 when using a socket.', 'litespeed-cache' ), + $this->_cfg_host + ), + ]; + } + + if ( $this->_attempt( $kind, $this->_cfg_host, (int) $this->_cfg_port ) ) { + return [ + 'ok' => true, + 'source' => 'configured', + 'kind' => $this->_kind_label( $kind ), + 'host' => $this->_cfg_host, + 'port' => (int) $this->_cfg_port, + 'detail' => sprintf( + /* translators: 1: backend name, 2: host:port or socket path */ + __( 'Connected to %1$s at %2$s using the configured settings.', 'litespeed-cache' ), + $this->_kind_label( $kind ), + $this->_format_endpoint( $this->_cfg_host, (int) $this->_cfg_port ) + ), + ]; + } + } + + // 2. Fall back to auto-detection across both backends. We don't surface + // a detail line here because the Benchmark panel right below the + // Status block already shows the same information in richer form + // (full latency-ranked table + named fastest). Duplicating the hint + // just adds noise. + $detected = $this->auto_detect(); + if ( $detected ) { + return [ + 'ok' => true, + 'source' => 'detected', + 'kind' => $this->_kind_label( $detected['kind_label'] ), + 'host' => $detected['host'], + 'port' => $detected['port'], + 'detail' => '', + ]; + } + + return [ + 'ok' => false, + 'source' => 'unavailable', + 'kind' => null, + 'host' => null, + 'port' => null, + 'detail' => __( 'No working Redis/Valkey or Memcached connection was found.', 'litespeed-cache' ), + ]; + } + + /** + * Walk the full candidate chain for every loaded backend, attempt to + * connect to each, measure round-trip latency, then rank the survivors. + * + * For each candidate that succeeds on the first pass we re-measure 2 more + * times and average the 3 runs, so a single laggy probe (cold caches, + * GC pause, etc.) doesn't dominate the ranking. Candidates that fail the + * first attempt are not retried — connection failures don't get faster + * with repeat attempts and the UI hides them anyway. + * + * This is an explicit-action benchmark — it runs only when the admin + * clicks the link in the Status panel, never automatically, because the + * full sweep can issue many connection attempts with 1.5 s connect + * timeouts in the worst case. The result is cached for an hour in a + * transient so the UI can offer "Show benchmarks" without re-running. + * + * @since 7.8.1 + * @access public + * + * @param int $samples_per_run Probes per measurement pass (each pass + * also includes a fresh connect()). + * @return array{ + * results: array, + * fastest: array{kind:string,kind_token:string,host:string,port:int,latency_ms:float,runs:int}|null, + * ran_at: int + * } + */ + public function benchmark_candidates( $samples_per_run = 5 ) { + $results = []; + $samples_per_run = (int) $samples_per_run; + $averaging_passes = 3; + + foreach ( [ 'Redis', 'Memcached' ] as $kind ) { + if ( ! class_exists( $kind ) ) { + continue; + } + + foreach ( $this->_candidate_endpoints( $kind, (string) $this->_cfg_host, (int) $this->_cfg_port ) as $candidate ) { + $first = $this->_measure_latency( $kind, $candidate['host'], $candidate['port'], $samples_per_run ); + + if ( ! $first['ok'] ) { + $results[] = [ + 'kind' => $this->_kind_label( $kind ), + 'kind_token' => $kind, + 'host' => $candidate['host'], + 'port' => $candidate['port'], + 'ok' => false, + 'latency_ms' => null, + 'runs' => 1, + 'error' => $first['error'], + ]; + continue; + } + + // Successful first pass — re-measure to dampen jitter. + $latencies = [ $first['latency_ms'] ]; + for ( $i = 1; $i < $averaging_passes; $i++ ) { + $extra = $this->_measure_latency( $kind, $candidate['host'], $candidate['port'], $samples_per_run ); + if ( $extra['ok'] && null !== $extra['latency_ms'] ) { + $latencies[] = $extra['latency_ms']; + } + } + + // Prefer the specific product detected by INFO server + // ('Redis' vs 'Valkey') over the joint "Redis / Valkey" label. + // Falls back to the joint label if the INFO sniff was + // inconclusive (Memcached always returns 'Memcached'). + $product_label = isset( $first['product'] ) && $first['product'] + ? $first['product'] + : $this->_kind_label( $kind ); + + $results[] = [ + 'kind' => $product_label, + 'kind_token' => $kind, + 'host' => $candidate['host'], + 'port' => $candidate['port'], + 'ok' => true, + 'latency_ms' => array_sum( $latencies ) / count( $latencies ), + 'runs' => count( $latencies ), + 'error' => null, + ]; + } + } + + $fastest = null; + foreach ( $results as $r ) { + if ( ! $r['ok'] || null === $r['latency_ms'] ) { + continue; + } + if ( null === $fastest || $r['latency_ms'] < $fastest['latency_ms'] ) { + $fastest = [ + 'kind' => $r['kind'], + 'kind_token' => $r['kind_token'], + 'host' => $r['host'], + 'port' => $r['port'], + 'latency_ms' => $r['latency_ms'], + 'runs' => $r['runs'], + ]; + } + } + + $payload = [ 'results' => $results, 'fastest' => $fastest, 'ran_at' => time() ]; + set_transient( self::TRANS_BENCHMARK, $payload, HOUR_IN_SECONDS ); + + return $payload; + } + + /** + * Return the most-recent cached benchmark payload, or null if nothing + * has been run within the transient TTL. + * + * @since 7.8.1 + * @access public + * + * @return array|null + */ + public function get_benchmark_cache() { + $cached = get_transient( self::TRANS_BENCHMARK ); + return is_array( $cached ) ? $cached : null; + } + + /** + * Return the flat list of candidates to benchmark, in display order. + * Used by the async UI to render placeholder rows before any probe runs. + * + * @since 7.8.1 + * @access public + * + * @return array + */ + public function ajax_candidate_list() { + $out = []; + foreach ( [ 'Redis', 'Memcached' ] as $kind ) { + if ( ! class_exists( $kind ) ) { + continue; + } + foreach ( $this->_candidate_endpoints( $kind, (string) $this->_cfg_host, (int) $this->_cfg_port ) as $candidate ) { + $out[] = [ + 'kind' => $this->_kind_label( $kind ), + 'kind_token' => $kind, + 'host' => $candidate['host'], + 'port' => (int) $candidate['port'], + ]; + } + } + return $out; + } + + /** + * Benchmark a single candidate. Runs 3 averaging passes of 5 probes each; + * a failed first pass returns immediately without retry. + * + * @since 7.8.1 + * @access public + * + * @param string $kind_token 'Redis' or 'Memcached'. + * @param string $host Host name, IP, or Unix socket path. + * @param int $port Port number; 0 for sockets. + * @return array{ok:bool,latency_ms:?float,runs:int,product:?string,error:?string,kind:string,kind_token:string,host:string,port:int} + */ + public function benchmark_one( $kind_token, $host, $port ) { + $samples = 5; + $averaging_passes = 3; + + $first = $this->_measure_latency( $kind_token, $host, $port, $samples ); + + if ( ! $first['ok'] ) { + return [ + 'kind' => $this->_kind_label( $kind_token ), + 'kind_token' => $kind_token, + 'host' => $host, + 'port' => (int) $port, + 'ok' => false, + 'latency_ms' => null, + 'runs' => 1, + 'product' => null, + 'error' => $first['error'], + ]; + } + + $latencies = [ $first['latency_ms'] ]; + for ( $i = 1; $i < $averaging_passes; $i++ ) { + $extra = $this->_measure_latency( $kind_token, $host, $port, $samples ); + if ( $extra['ok'] && null !== $extra['latency_ms'] ) { + $latencies[] = $extra['latency_ms']; + } + } + + $product_label = isset( $first['product'] ) && $first['product'] + ? $first['product'] + : $this->_kind_label( $kind_token ); + + return [ + 'kind' => $product_label, + 'kind_token' => $kind_token, + 'host' => $host, + 'port' => (int) $port, + 'ok' => true, + 'latency_ms' => array_sum( $latencies ) / count( $latencies ), + 'runs' => count( $latencies ), + 'product' => $product_label, + 'error' => null, + ]; + } + + /** + * Persist an aggregated benchmark payload to the transient cache so the + * Status panel can render the results across page reloads. + * + * @since 7.8.1 + * @access public + * + * @param array $payload Full payload with results + fastest + ran_at. + * @return void + */ + public function commit_benchmark( $payload ) { + if ( ! is_array( $payload ) || ! isset( $payload['results'] ) ) { + return; + } + set_transient( self::TRANS_BENCHMARK, $payload, HOUR_IN_SECONDS ); + } + + /** + * Admin-AJAX entry point for the async benchmark UI. + * + * Steps: + * list -> returns the candidate list (rendered as placeholder rows). + * run -> benchmarks one candidate (kind, host, port) and returns timing. + * commit -> stores the JS-aggregated payload in the benchmark transient. + * + * @since 7.8.1 + * @access public + * + * @return void + */ + public static function ajax_benchmark() { + check_ajax_referer( 'litespeed_oc_benchmark', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 ); + } + + $step = isset( $_POST['step'] ) ? sanitize_key( wp_unslash( $_POST['step'] ) ) : ''; + $oc = self::cls(); + + if ( 'list' === $step ) { + wp_send_json_success( [ 'candidates' => $oc->ajax_candidate_list() ] ); + } + + if ( 'run' === $step ) { + $kind = isset( $_POST['kind'] ) ? sanitize_text_field( wp_unslash( $_POST['kind'] ) ) : ''; + if ( 'Redis' !== $kind && 'Memcached' !== $kind ) { + wp_send_json_error( [ 'message' => 'Invalid kind.' ], 400 ); + } + $host = isset( $_POST['host'] ) ? sanitize_text_field( wp_unslash( $_POST['host'] ) ) : ''; + $port = isset( $_POST['port'] ) ? (int) $_POST['port'] : 0; + wp_send_json_success( $oc->benchmark_one( $kind, $host, $port ) ); + } + + if ( 'commit' === $step ) { + $raw = isset( $_POST['payload'] ) ? wp_unslash( $_POST['payload'] ) : ''; + $payload = is_string( $raw ) ? json_decode( $raw, true ) : null; + if ( is_array( $payload ) ) { + $oc->commit_benchmark( $payload ); + } + wp_send_json_success(); + } + + wp_send_json_error( [ 'message' => 'Unknown step.' ], 400 ); + } + + /** + * Connect to a candidate, time N round-trips, return the average. + * Connection failures, auth failures, plus probe-protocol mismatches all + * collapse to ok=false with a short reason string the UI can show. + * + * @since 7.8.1 + * @access private + * + * @param string $kind 'Redis' or 'Memcached'. + * @param string $host Host name, IP, or Unix socket path. + * @param int $port Port; coerced to 0 for socket paths. + * @param int $samples Round-trips to average. + * @return array{ok:bool, latency_ms:?float, product:?string, error:?string} + * product: detected server product ('Redis' | 'Valkey' | 'Memcached') + * on success; null on failure. + */ + private function _measure_latency( $kind, $host, $port, $samples ) { + if ( ! class_exists( $kind ) ) { + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => $kind . ' extension missing' ]; + } + if ( '' === (string) $host ) { + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => 'empty host' ]; + } + + $is_socket = $this->_is_socket_path( $host ); + if ( $is_socket && ! @file_exists( $host ) ) { + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => 'socket file missing' ]; + } + + $probe_port = (int) $port; + $latencies = []; + $product = null; + + try { + if ( 'Redis' === $kind ) { + $conn = new \Redis(); + $ok = @$conn->connect( $host, $probe_port, 1.5 ); + if ( ! $ok ) { + $err = error_get_last(); + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => isset( $err['message'] ) ? $err['message'] : 'connect refused' ]; + } + if ( $this->_cfg_pswd ) { + $auth_ok = $this->_cfg_user + ? @$conn->auth( [ $this->_cfg_user, $this->_cfg_pswd ] ) + : @$conn->auth( $this->_cfg_pswd ); + if ( ! $auth_ok ) { + @$conn->close(); + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => 'auth failed' ]; + } + } + + // Valkey >= 7.2 still reports redis_version but adds server_name:valkey / valkey_version:. + $product = 'Redis'; + try { + $info = @$conn->rawCommand( 'INFO', 'server' ); + if ( is_string( $info ) && ( false !== stripos( $info, 'server_name:valkey' ) || false !== stripos( $info, 'valkey_version:' ) ) ) { + $product = 'Valkey'; + } + } catch ( \Throwable $e ) { + unset( $e ); // Stay with 'Redis' on INFO failure. + } + + for ( $i = 0; $i < $samples; $i++ ) { + $started = microtime( true ); + $reply = @$conn->rawCommand( 'PING' ); + $elapsed = ( microtime( true ) - $started ) * 1000.0; + if ( 'PONG' !== $reply && true !== $reply ) { + @$conn->close(); + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => 'PING returned unexpected value' ]; + } + $latencies[] = $elapsed; + } + @$conn->close(); + } else { + $conn = new \Memcached(); + @$conn->setOption( \Memcached::OPT_CONNECT_TIMEOUT, 1500 ); + @$conn->setOption( \Memcached::OPT_SEND_TIMEOUT, 1500000 ); + @$conn->setOption( \Memcached::OPT_RECV_TIMEOUT, 1500000 ); + if ( ! @$conn->addServer( $host, $probe_port ) ) { + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => 'addServer failed' ]; + } + if ( $this->_cfg_user && $this->_cfg_pswd && method_exists( $conn, 'setSaslAuthData' ) ) { + @$conn->setOption( \Memcached::OPT_BINARY_PROTOCOL, true ); + @$conn->setOption( \Memcached::OPT_COMPRESSION, false ); + @$conn->setSaslAuthData( $this->_cfg_user, $this->_cfg_pswd ); + } + $probe_key = 'litespeed_oc_bench_' . wp_generate_password( 8, false ); + for ( $i = 0; $i < $samples; $i++ ) { + $started = microtime( true ); + $set_ok = @$conn->set( $probe_key, $i, 30 ); + $got = @$conn->get( $probe_key ); + $elapsed = ( microtime( true ) - $started ) * 1000.0; + if ( ! $set_ok || (int) $got !== $i ) { + @$conn->delete( $probe_key ); + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => 'set/get probe failed (protocol mismatch?)' ]; + } + $latencies[] = $elapsed; + } + @$conn->delete( $probe_key ); + $product = 'Memcached'; + } + } catch ( \Throwable $e ) { + return [ 'ok' => false, 'latency_ms' => null, 'product' => null, 'error' => $e->getMessage() ]; + } + + return [ 'ok' => true, 'latency_ms' => array_sum( $latencies ) / count( $latencies ), 'product' => $product, 'error' => null ]; + } + + /** + * Pretty-print a host/port pair for status messages. + * + * @since 7.8.1 + * @access private + * + * @param string $host Host name, IP, or Unix socket path. + * @param int $port Port number; ignored for socket paths. + * @return string + */ + private function _format_endpoint( $host, $port ) { + if ( $this->_is_socket_path( $host ) ) { + return $host; + } + return $host . ( $port ? ':' . $port : '' ); + } + + /** + * User-facing label for an internal backend kind. Internal tokens stay as + * the PHP class names ('Redis' / 'Memcached'); the UI shows "Redis / Valkey". + * + * @since 7.8.1 + * @access private + * + * @param string $kind Internal kind token ('Redis' or 'Memcached'). + * @return string + */ + private function _kind_label( $kind ) { + return 'Redis' === $kind ? 'Redis / Valkey' : $kind; + } + + /** + * Detect a working Redis/Valkey or Memcached backend by walking the + * full priority chain for each loaded extension: + * + * 1. UNIX sockets that exist on disk + * 2. Configured host (TCP or socket the admin typed) + * 3. localhost on the backend's default port + * 4. 127.0.0.1 on the backend's default port + * 5. Common conventional hostnames (redis|valkey for Redis, + * memcached|memcache for Memcached) on the default port — these + * resolve via /etc/hosts, LAN DNS, or container DNS depending on + * the environment + * + * Redis is tried before Memcached because LiteSpeed's recommended Object + * Cache backend is Redis/Valkey. Sockets are tried before TCP because + * they are the high-performance path on LSWS / cPanel hosts. + * + * @since 7.8.1 + * @access public + * + * @param array $cfg Optional configured host/port to seed the priority + * chain. Defaults to the current instance config. + * @return array|false Detected configuration on success, false otherwise. + * Shape: [ + * 'kind' => self::KIND_REDIS|self::KIND_MEMCACHED, + * 'kind_label' => 'Redis'|'Memcached', + * 'host' => string, + * 'port' => int, + * ] + */ + public function auto_detect( $cfg = [] ) { + $cfg_host = isset( $cfg['host'] ) ? (string) $cfg['host'] : (string) $this->_cfg_host; + $cfg_port = isset( $cfg['port'] ) ? (int) $cfg['port'] : (int) $this->_cfg_port; + + // Try Redis/Valkey first (LSCache convention) then Memcached. + foreach ( [ 'Redis', 'Memcached' ] as $kind ) { + if ( ! class_exists( $kind ) ) { + continue; + } + + foreach ( $this->_candidate_endpoints( $kind, $cfg_host, $cfg_port ) as $candidate ) { + if ( $this->_attempt( $kind, $candidate['host'], $candidate['port'] ) ) { + return [ + 'kind' => 'Redis' === $kind ? self::KIND_REDIS : self::KIND_MEMCACHED, + 'kind_label' => $kind, + 'host' => $candidate['host'], + 'port' => $candidate['port'], + ]; + } + } + } + + return false; + } + + /** + * Build the ordered candidate list for one backend. + * + * @since 7.8.1 + * @access private + * + * @param string $kind 'Redis' or 'Memcached'. + * @param string $cfg_host Configured host (may be empty or a socket path). + * @param int $cfg_port Configured port. + * @return array + */ + private function _candidate_endpoints( $kind, $cfg_host, $cfg_port ) { + $default_port = 'Redis' === $kind ? 6379 : 11211; + $candidates = []; + $seen = []; + + $push = function ( $host, $port ) use ( &$candidates, &$seen ) { + if ( '' === $host ) { + return; + } + $key = $host . '|' . (int) $port; + if ( isset( $seen[ $key ] ) ) { + return; + } + $seen[ $key ] = true; + $candidates[] = [ 'host' => $host, 'port' => (int) $port ]; + }; + + // 1. UNIX sockets first — the high-performance path on LSWS / cPanel. + $paths = 'Redis' === $kind + ? $this->_redis_socket_candidates() + : $this->_memcached_socket_candidates(); + foreach ( $paths as $path ) { + if ( @file_exists( $path ) ) { + $push( $path, 0 ); + } + } + + // 2. Configured host — but only for the saved backend, so a Redis port + // doesn't leak into the Memcached probe chain. + $cfg_kind_matches = ( 'Redis' === $kind ) === (bool) $this->_cfg_method; + if ( $cfg_host && $cfg_kind_matches ) { + if ( $this->_is_socket_path( $cfg_host ) ) { + $push( $cfg_host, 0 ); + } else { + $push( $cfg_host, $cfg_port > 0 ? $cfg_port : $default_port ); + } + } + + // 3-4. Localhost / 127.0.0.1 on default port ($push de-dupes vs step 2). + $push( 'localhost', $default_port ); + $push( '127.0.0.1', $default_port ); + + // 5. Conventional hostnames (resolved via /etc/hosts, LAN DNS, or container DNS). + if ( 'Redis' === $kind ) { + $push( 'redis', $default_port ); + $push( 'valkey', $default_port ); + } else { + $push( 'memcached', $default_port ); + $push( 'memcache', $default_port ); + } + + return $candidates; + } + + /** + * Whether a string looks like a Unix socket path (absolute path). + * + * @since 7.8.1 + * @access private + * + * @param string $host Host string from the config. + * @return bool + */ + private function _is_socket_path( $host ) { + return is_string( $host ) && '' !== $host && '/' === $host[0]; + } + + /** + * Whether the host environment exposes a Unix socket we could plausibly + * connect to. Used by the status panel — surfaces a quick yes/no without + * actually opening the socket. Walks the same candidate paths the + * auto-detector tries so the status mirrors detection behaviour. + * + * @since 7.8.1 + * @access public + * + * @return bool True if at least one Redis/Valkey or Memcached socket file + * exists on disk. + */ + public function has_socket() { + foreach ( array_merge( $this->_redis_socket_candidates(), $this->_memcached_socket_candidates() ) as $path ) { + if ( @file_exists( $path ) ) { + return true; + } + } + return false; + } + + /** + * Best-effort lookup of the current process's home directory. cPanel and + * most shared hosts give each account its own /home/{user} tree, which is + * where per-user Redis/Memcached sockets live. We deliberately never scan + * other users' homes. + * + * @since 7.8.1 + * @access private + * + * @return string Absolute home directory path, or '' if unknown. + */ + private function _current_home_dir() { + if ( function_exists( 'posix_geteuid' ) && function_exists( 'posix_getpwuid' ) ) { + $info = @posix_getpwuid( posix_geteuid() ); + if ( ! empty( $info['dir'] ) ) { + return rtrim( $info['dir'], '/' ); + } + } + + $env_home = getenv( 'HOME' ); + if ( $env_home ) { + return rtrim( $env_home, '/' ); + } + + // Fall back to deriving from ABSPATH: /home/USER/public_html/... → /home/USER. + if ( defined( 'ABSPATH' ) && preg_match( '#^(/home/[^/]+)/#', ABSPATH, $m ) ) { + return $m[1]; + } + + return ''; + } + + /** + * Candidate Redis/Valkey socket paths in cPanel-likelihood order. + * /tmp/redis.sock comes first because that is the LiteSpeed Web Server / + * LSCWP convention. After that we walk the current user's home tree, then + * common cPanel container layouts, then system-wide locations. + * + * @since 7.8.1 + * @access private + * + * @return string[] + */ + private function _redis_socket_candidates() { + $paths = [ '/tmp/redis.sock' ]; + + $home = $this->_current_home_dir(); + if ( $home ) { + $paths = array_merge( $paths, [ + $home . '/.redis/redis.sock', + $home . '/redis.sock', + $home . '/tmp/redis.sock', + $home . '/redis/redis.sock', + $home . '/.applicationmanager/redis.sock', + $home . '/.kxcache/redis.sock', + $home . '/etc/redis/redis.sock', + $home . '/cache/redis.sock', + ] ); + // Container-style: /home/{user}/{container}/redis.sock — shallow wildcards only. + $paths = array_merge( $paths, $this->_glob_paths( [ + $home . '/*/redis.sock', + $home . '/*/*/redis.sock', + ] ) ); + } + + // System-wide fallbacks last. + $paths[] = '/run/redis/redis.sock'; + $paths[] = '/var/run/redis/redis.sock'; + + return $paths; + } + + /** + * Candidate Memcached socket paths in cPanel-likelihood order. + * + * @since 7.8.1 + * @access private + * + * @return string[] + */ + private function _memcached_socket_candidates() { + $paths = [ '/tmp/memcached.sock', '/tmp/memcache.sock' ]; + + $home = $this->_current_home_dir(); + if ( $home ) { + $paths = array_merge( $paths, [ + $home . '/.memcached/memcached.sock', + $home . '/.memcached/memcache.sock', + $home . '/memcached.sock', + $home . '/memcache.sock', + $home . '/tmp/memcached.sock', + $home . '/tmp/memcache.sock', + $home . '/memcached/memcached.sock', + $home . '/memcached/memcache.sock', + $home . '/.applicationmanager/memcached.sock', + $home . '/.applicationmanager/memcache.sock', + $home . '/.kxcache/memcached.sock', + $home . '/.kxcache/memcache.sock', + $home . '/etc/memcached/memcached.sock', + $home . '/etc/memcache/memcache.sock', + $home . '/cache/memcached.sock', + $home . '/cache/memcache.sock', + ] ); + $paths = array_merge( $paths, $this->_glob_paths( [ + $home . '/*/memcached.sock', + $home . '/*/memcache.sock', + $home . '/*/*/memcached.sock', + $home . '/*/*/memcache.sock', + ] ) ); + } + + $paths[] = '/run/memcached/memcached.sock'; + $paths[] = '/var/run/memcached/memcached.sock'; + + return $paths; + } + + /** + * Resolve a list of glob patterns to actual paths. Silently returns an + * empty array if glob() is disabled or the patterns match nothing — we + * never want auto-detection to surface error_log noise. + * + * @since 7.8.1 + * @access private + * + * @param string[] $patterns Glob patterns to expand. + * @return string[] + */ + private function _glob_paths( $patterns ) { + if ( ! function_exists( 'glob' ) ) { + return []; + } + $out = []; + foreach ( $patterns as $pattern ) { + $matches = @glob( $pattern, GLOB_NOSORT ); + if ( is_array( $matches ) ) { + $out = array_merge( $out, $matches ); + } + } + return $out; + } + + /** + * Attempt to connect to a single backend + endpoint and run real + * validation. Errors and exceptions are swallowed silently — the only + * signal callers receive is the boolean return value, plus debug logs + * when debug mode is on. + * + * Validation: + * - Redis: rawCommand('PING') must return 'PONG'. + * - Memcached: set/get/delete a short-lived test key. + * + * @since 7.8.1 + * @access private + * + * @param string $kind 'Redis' or 'Memcached'. + * @param string $host Host name, IP, or Unix socket path. + * @param int $port Port number; ignored for socket paths. + * @return bool + */ + private function _attempt( $kind, $host, $port ) { + if ( ! class_exists( $kind ) ) { + return false; + } + if ( '' === (string) $host ) { + return false; + } + $is_socket = $this->_is_socket_path( $host ); + if ( $is_socket && ! @file_exists( $host ) ) { + return false; + } + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler + $has_handler = function_exists( 'set_error_handler' ); + if ( $has_handler && function_exists( 'litespeed_exception_handler' ) ) { + set_error_handler( 'litespeed_exception_handler' ); + } + + $ok = false; + try { + if ( 'Redis' === $kind ) { + $ok = $this->_attempt_redis( $host, (int) $port ); + } else { + $ok = $this->_attempt_memcached( $host, (int) $port ); + } + } catch ( \Throwable $e ) { + $this->debug_oc( '[auto] ' . $kind . ' attempt threw: ' . $e->getMessage() ); + $ok = false; + } + + if ( $has_handler && function_exists( 'litespeed_exception_handler' ) ) { + restore_error_handler(); + } + + $this->debug_oc( '[auto] ' . $kind . ' @ ' . $this->_format_endpoint( $host, $port ) . ' => ' . ( $ok ? 'OK' : 'fail' ) ); + return $ok; + } + + /** + * Attempt a Redis/Valkey connection and validate with PING. + * + * @since 7.8.1 + * @access private + * + * @param string $host Host name, IP, or Unix socket path. + * @param int $port Port number; pass 0 for sockets. + * @return bool + */ + private function _attempt_redis( $host, $port ) { + $conn = new \Redis(); + // Short timeout so a misconfigured remote doesn't stall the settings page. + if ( $port ) { + $ok = @$conn->connect( $host, $port, 1.5 ); + } else { + $ok = @$conn->connect( $host, 0, 1.5 ); + } + if ( ! $ok ) { + return false; + } + + if ( $this->_cfg_pswd ) { + try { + if ( $this->_cfg_user ) { + @$conn->auth( [ $this->_cfg_user, $this->_cfg_pswd ] ); + } else { + @$conn->auth( $this->_cfg_pswd ); + } + } catch ( \Throwable $e ) { + @$conn->close(); + return false; + } + } + + try { + $pong = @$conn->rawCommand( 'PING' ); + } catch ( \Throwable $e ) { + @$conn->close(); + return false; + } + @$conn->close(); + + return 'PONG' === $pong || true === $pong; + } + + /** + * Attempt a Memcached connection and validate with set/get/delete. + * + * @since 7.8.1 + * @access private + * + * @param string $host Host name, IP, or Unix socket path. + * @param int $port Port number; pass 0 for sockets. + * @return bool + */ + private function _attempt_memcached( $host, $port ) { + $conn = new \Memcached(); + @$conn->setOption( \Memcached::OPT_CONNECT_TIMEOUT, 1500 ); // ms. + @$conn->setOption( \Memcached::OPT_SEND_TIMEOUT, 1500000 ); // µs. + @$conn->setOption( \Memcached::OPT_RECV_TIMEOUT, 1500000 ); + + if ( ! @$conn->addServer( $host, $port ) ) { + return false; + } + + if ( $this->_cfg_user && $this->_cfg_pswd && method_exists( $conn, 'setSaslAuthData' ) ) { + @$conn->setOption( \Memcached::OPT_BINARY_PROTOCOL, true ); + @$conn->setOption( \Memcached::OPT_COMPRESSION, false ); + @$conn->setSaslAuthData( $this->_cfg_user, $this->_cfg_pswd ); + } + + $probe_key = 'litespeed_oc_probe_' . wp_generate_password( 6, false ); + $set_ok = @$conn->set( $probe_key, 1, 30 ); + if ( ! $set_ok ) { + return false; + } + $get_val = @$conn->get( $probe_key ); + @$conn->delete( $probe_key ); + + return 1 === $get_val || '1' === $get_val; } /** diff --git a/tpl/cache/settings_inc.object.tpl.php b/tpl/cache/settings_inc.object.tpl.php index 6ef5bbf06..a84d4c681 100644 --- a/tpl/cache/settings_inc.object.tpl.php +++ b/tpl/cache/settings_inc.object.tpl.php @@ -15,13 +15,38 @@ $lang_enabled = '' . esc_html__( 'Enabled', 'litespeed-cache' ) . ''; $lang_disabled = '' . esc_html__( 'Disabled', 'litespeed-cache' ) . ''; -$mem_enabled = class_exists( 'Memcached' ) ? $lang_enabled : $lang_disabled; -$redis_enabled = class_exists( 'Redis' ) ? $lang_enabled : $lang_disabled; +$mem_enabled = class_exists( 'Memcached' ) ? $lang_enabled : $lang_disabled; +$redis_enabled = class_exists( 'Redis' ) ? $lang_enabled : $lang_disabled; +$socket_enabled = $this->cls( 'Object_Cache' )->has_socket() ? $lang_enabled : $lang_disabled; -$mem_conn = $this->cls( 'Object_Cache' )->test_connection(); -if ( null === $mem_conn ) { +$conn_result = $this->cls( 'Object_Cache' )->test_connection(); +$conn_ok = isset( $conn_result['ok'] ) ? $conn_result['ok'] : null; +$conn_source = isset( $conn_result['source'] ) ? $conn_result['source'] : 'unavailable'; +$conn_detail = isset( $conn_result['detail'] ) ? $conn_result['detail'] : ''; + +// Benchmark is opt-in via a query param — the full sweep is expensive. +// States: no cache → "Run"; cache present → "Show" + "Re-run"; action=run → fresh sweep. +$oc_cls = $this->cls( 'Object_Cache' ); +$bench_cache = $oc_cls->get_benchmark_cache(); +// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only UI toggle; benchmark itself is gated by the settings-page capability check upstream. +$bench_action = isset( $_GET['ls_oc_benchmark'] ) ? sanitize_key( wp_unslash( $_GET['ls_oc_benchmark'] ) ) : ''; +$bench = null; + +if ( 'run' === $bench_action ) { + $bench = $oc_cls->benchmark_candidates(); + $bench_cache = $bench; +} elseif ( 'show' === $bench_action && null !== $bench_cache ) { + $bench = $bench_cache; +} + +$bench_run_url = esc_url( add_query_arg( 'ls_oc_benchmark', 'run' ) . '#cache-object' ); +$bench_show_url = esc_url( add_query_arg( 'ls_oc_benchmark', 'show' ) . '#cache-object' ); + +// Badge reflects whether the *configured* host answered — a 'detected' fallback +// is still Failed for the saved config (the detail line below hints at the fix). +if ( null === $conn_ok ) { $mem_conn_desc = '' . esc_html__( 'Not Available', 'litespeed-cache' ) . ''; -} elseif ( $mem_conn ) { +} elseif ( $conn_ok && 'configured' === $conn_source ) { $mem_conn_desc = '' . esc_html__( 'Passed', 'litespeed-cache' ) . ''; } else { $severity = $this->conf( Base::O_OBJECT, true ) ? 'danger' : 'warning'; @@ -52,23 +77,411 @@

+ :
- :
+ :
- :
+ :
: + +
+ + + +
+ + +  |  + + — + + + — + + + — + +
+ +
> + + + + + + + + + + + + + + + > + + + + + + + +
+ + + + + ms
+ + +
+ ' . esc_html( $render_bench['fastest']['host'] ) . '' + ) + : sprintf( + /* translators: 1: hostname, 2: port number */ + esc_html__( 'host %1$s on port %2$s', 'litespeed-cache' ), + '' . esc_html( $render_bench['fastest']['host'] ) . '', + '' . (int) $render_bench['fastest']['port'] . '' + ); + printf( + /* translators: 1: backend name, 2: host/port or socket descriptor, 3: latency in milliseconds */ + esc_html__( 'Fastest working object cache: %1$s at %2$s (%3$s ms). Set the method below to Automatic and save to apply these settings.', 'litespeed-cache' ), + '' . esc_html( $render_bench['fastest']['kind'] ) . '', + $fastest_location, + esc_html( number_format( $render_bench['fastest']['latency_ms'], 2 ) ) + ); + ?> +
+ +
+ +
+ + +
+ +
@@ -81,7 +494,17 @@ title( $option_id ); ?> - build_switch( $option_id, array( 'Memcached', 'Redis' ) ); ?> + build_switch( $option_id, array( + 2 => esc_html__( 'Automatic', 'litespeed-cache' ), + 1 => 'Redis / Valkey', + 0 => 'Memcached', + ) ); + ?> +
+ +
@@ -97,17 +520,17 @@ printf( /* translators: %s: Object cache name */ esc_html__( 'Your %s Hostname or IP address.', 'litespeed-cache' ), - 'Memcached/LSMCD/Redis' + 'Memcached/LSMCD/Redis/Valkey' ); ?>
/path/to/memcached.sock' + '/tmp/redis.sock' ); ?> @@ -268,13 +691,23 @@