| @@ -21,8 +21,16 @@ | ||
| 21 | 21 | const UNKNOWN = 'unknown'; |
| 22 | 22 | |
| 23 | 23 | const OPT_CACHED_TYPE = 'xspeed_server_type'; |
| 24 | 24 | |
| 25 | + /** | |
| 26 | + * Last authoritative mod_headers answer, captured under mod_php where | |
| 27 | + * apache_get_modules() actually exists. Read by SAPIs that cannot | |
| 28 | + * detect (WP-CLI, FPM) so one host gives one answer. See | |
| 29 | + * apache_has_mod_headers(). | |
| 30 | + */ | |
| 31 | + const OPT_CACHED_MOD_HEADERS = 'xspeed_apache_mod_headers'; | |
| 32 | + | |
| 25 | 33 | public static function type() { |
| 26 | 34 | $detected = self::detect(); |
| 27 | 35 | if ( self::UNKNOWN !== $detected ) { |
| 28 | 36 | // Persist whenever we have a real answer so future CLI / |
| @@ -31,20 +39,36 @@ | ||
| 31 | 39 | $cached = get_option( self::OPT_CACHED_TYPE, null ); |
| 32 | 40 | if ( $cached !== $detected ) { |
| 33 | 41 | update_option( self::OPT_CACHED_TYPE, $detected, false ); |
| 34 | 42 | } |
| 35 | - return $detected; | |
| 43 | + $type = $detected; | |
| 44 | + } else { | |
| 45 | + // No definitive signal this request (typically WP-CLI, where | |
| 46 | + // SERVER_SOFTWARE is empty). Read whatever was cached the last | |
| 47 | + // time we ran from a real HTTP request. | |
| 48 | + $cached = get_option( self::OPT_CACHED_TYPE, null ); | |
| 49 | + $type = ( is_string( $cached ) && '' !== $cached ) ? $cached : self::UNKNOWN; | |
| 36 | 50 | } |
| 37 | 51 | |
| 38 | - // No definitive signal this request (typically WP-CLI, where | |
| 39 | - // SERVER_SOFTWARE is empty). Read whatever was cached the last | |
| 40 | - // time we ran from a real HTTP request. | |
| 41 | - $cached = get_option( self::OPT_CACHED_TYPE, null ); | |
| 42 | - if ( is_string( $cached ) && '' !== $cached ) { | |
| 43 | - return $cached; | |
| 44 | - } | |
| 45 | - | |
| 46 | - return self::UNKNOWN; | |
| 52 | + /** | |
| 53 | + * Filters the resolved server type. | |
| 54 | + * | |
| 55 | + * The override point for contexts that cannot detect. Detection | |
| 56 | + * reads SERVER_SOFTWARE, which the web server supplies and WP-CLI | |
| 57 | + * therefore never has; the cached option covers CLI runs on a site | |
| 58 | + * some request has already reached, but a site provisioned entirely | |
| 59 | + * over WP-CLI has nothing cached and resolves to `unknown` even on | |
| 60 | + * nginx. `wp xspeed cache nginx-config --server=` hooks this so | |
| 61 | + * every gate downstream — Cache::nginx_snippet(), each module's own | |
| 62 | + * nginx_directives() — agrees on one answer, rather than each | |
| 63 | + * re-deciding and emitting a half-built config. | |
| 64 | + * | |
| 65 | + * Filtering does NOT write the cached option: an assumption stated | |
| 66 | + * for one command must not become this site's persisted answer. | |
| 67 | + * | |
| 68 | + * @param string $type One of apache|litespeed|nginx|iis|unknown. | |
| 69 | + */ | |
| 70 | + return apply_filters( 'xspeed_server_type', $type ); | |
| 47 | 71 | } |
| 48 | 72 | |
| 49 | 73 | /** |
| 50 | 74 | * Live detection — never reads the cache. Used by type() and by |
| @@ -102,26 +126,47 @@ | ||
| 102 | 126 | * the user, nothing for the hit counter. That is exactly the |
| 103 | 127 | * "cache works, dashboard says 0%" report this check exists to |
| 104 | 128 | * prevent. (Cache::static_rewrite_allowed() consumes it.) |
| 105 | 129 | * |
| 106 | - * Detection is best-effort by necessity: | |
| 107 | - * - mod_php exposes apache_get_modules() — authoritative. | |
| 108 | - * - Under PHP-FPM that function doesn't exist. Assume the module | |
| 109 | - * IS present, matching Apache's own default build (mod_headers | |
| 110 | - * ships enabled in every mainstream distro package). Guessing | |
| 111 | - * "absent" there would push every FPM site onto the slower | |
| 112 | - * drop-in path over a detection limitation rather than a real | |
| 113 | - * capability gap; the loopback probe in | |
| 130 | + * Detection is best-effort by necessity, and MUST NOT vary by SAPI: | |
| 131 | + * - mod_php exposes apache_get_modules() — authoritative. Persist | |
| 132 | + * that answer so other SAPIs can inherit it. | |
| 133 | + * - Under PHP-FPM / WP-CLI the function doesn't exist. Read the | |
| 134 | + * stored mod_php answer; only when nothing was ever stored do we | |
| 135 | + * assume the module IS present, matching Apache's own default | |
| 136 | + * build (mod_headers ships enabled in every mainstream distro | |
| 137 | + * package). Guessing "absent" there would push every FPM site | |
| 138 | + * onto the slower drop-in path over a detection limitation | |
| 139 | + * rather than a real capability gap; the loopback probe in | |
| 114 | 140 | * Cache::probe_static_rewrite() is what catches a genuinely |
| 115 | 141 | * header-less FPM host. |
| 116 | 142 | * |
| 143 | + * Returning a different answer per SAPI is not merely inaccurate: it | |
| 144 | + * makes static_rewrite_allowed() disagree with the on-disk .htaccess, | |
| 145 | + * so every WP-CLI bootstrap "corrects" what the last web request | |
| 146 | + * wrote and vice versa — an endless rewrite/purge ping-pong that | |
| 147 | + * keeps the hit ratio pinned near zero. (#138) | |
| 148 | + * | |
| 117 | 149 | * @return bool |
| 118 | 150 | */ |
| 119 | 151 | public static function apache_has_mod_headers(): bool { |
| 120 | 152 | if ( function_exists( 'apache_get_modules' ) ) { |
| 121 | 153 | $has = in_array( 'mod_headers', apache_get_modules(), true ); |
| 154 | + | |
| 155 | + // Authoritative — persist so CLI/FPM inherit it instead of | |
| 156 | + // guessing. Non-autoloaded; only read when needed. | |
| 157 | + $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null ); | |
| 158 | + $want = $has ? '1' : '0'; | |
| 159 | + if ( (string) $cached !== $want ) { | |
| 160 | + update_option( self::OPT_CACHED_MOD_HEADERS, $want, false ); | |
| 161 | + } | |
| 122 | 162 | } else { |
| 123 | - $has = true; // FPM: undetectable, assume the distro default. | |
| 163 | + // Cannot detect here. Prefer the last known real answer over | |
| 164 | + // an optimistic guess that would flip static_rewrite_allowed(). | |
| 165 | + $cached = get_option( self::OPT_CACHED_MOD_HEADERS, null ); | |
| 166 | + $has = ( null === $cached || '' === $cached ) | |
| 167 | + ? true // never detected: assume the distro default. | |
| 168 | + : (bool) (int) $cached; | |
| 124 | 169 | } |
| 125 | 170 | |
| 126 | 171 | /** |
| 127 | 172 | * Filter: xspeed_apache_has_mod_headers |
| @@ -627,18 +672,34 @@ | ||
| 627 | 672 | : ''; |
| 628 | 673 | } |
| 629 | 674 | |
| 630 | 675 | /** |
| 631 | - * Detect active caching plugins that would conflict with xSpeed. Returns | |
| 632 | - * a list of human-readable labels for any conflicting plugin currently | |
| 633 | - * active; empty array means the field is clear. Used by the onboarding | |
| 634 | - * wizard's Step 1 health check and (Phase 2.1) the main dashboard's | |
| 635 | - * Health card. | |
| 676 | + * Active PAGE-CACHING plugins that would fight xSpeed over the cache | |
| 677 | + * drop-in. Returns human-readable labels; an empty array means the field | |
| 678 | + * is clear. Used by the onboarding wizard's Step 1 health check and the | |
| 679 | + * dashboard's Health card, both of which tell the user to deactivate what | |
| 680 | + * is listed "to avoid double-caching". | |
| 636 | 681 | * |
| 637 | - * The detection key is the plugin's main file path relative to the | |
| 638 | - * plugins directory — the same value WordPress uses internally in | |
| 639 | - * `active_plugins`. Folder-only checks (`is_plugin_active('foo/')`) | |
| 640 | - * would false-positive on disabled plugins still on disk. | |
| 682 | + * Which is why the list is filtered on the page-cache capability rather | |
| 683 | + * than "is it a performance plugin": Autoptimize only minifies, so naming | |
| 684 | + * it here made the health row give advice that was flatly wrong. | |
| 685 | + * Minification overlap is still caught — by Conflict_Registry, per feature. | |
| 686 | + * | |
| 687 | + * The detection key is the plugin's main file path relative to the plugins | |
| 688 | + * directory — the same value WordPress uses internally in `active_plugins`. | |
| 689 | + * Folder-only checks (`is_plugin_active('foo/')`) would false-positive on | |
| 690 | + * disabled plugins still on disk. | |
| 691 | + * | |
| 692 | + * Membership comes from Cache_Plugin_Catalog, so a plugin is added in one | |
| 693 | + * place and shows up in both this list and the conflict matrix. | |
| 694 | + * | |
| 695 | + * Activation is not the whole test, though. What actually stops xSpeed | |
| 696 | + * enabling its cache is who holds advanced-cache.php, and a drop-in left | |
| 697 | + * behind by an uninstalled plugin holds it just as firmly as a running | |
| 698 | + * one. Checking only active_plugins let the wizard say "No other caching | |
| 699 | + * plugins detected" on the environment step and then refuse the enable on | |
| 700 | + * the very next step, for a file it had just looked past. So a foreign | |
| 701 | + * drop-in is listed too, named where we can name it. | |
| 641 | 702 | */ |
| 642 | 703 | public static function conflicts() { |
| 643 | 704 | if ( ! function_exists( 'is_plugin_active' ) ) { |
| 644 | 705 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| @@ -643,29 +704,53 @@ | ||
| 643 | 704 | if ( ! function_exists( 'is_plugin_active' ) ) { |
| 644 | 705 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 645 | 706 | } |
| 646 | 707 | |
| 647 | - $known = array( | |
| 648 | - 'wp-rocket/wp-rocket.php' => 'WP Rocket', | |
| 649 | - 'w3-total-cache/w3-total-cache.php' => 'W3 Total Cache', | |
| 650 | - 'wp-super-cache/wp-cache.php' => 'WP Super Cache', | |
| 651 | - 'wp-fastest-cache/wpFastestCache.php' => 'WP Fastest Cache', | |
| 652 | - 'litespeed-cache/litespeed-cache.php' => 'LiteSpeed Cache', | |
| 653 | - 'cache-enabler/cache-enabler.php' => 'Cache Enabler', | |
| 654 | - 'comet-cache/comet-cache.php' => 'Comet Cache', | |
| 655 | - 'hummingbird-performance/wp-hummingbird.php' => 'Hummingbird', | |
| 656 | - 'sg-cachepress/sg-cachepress.php' => 'SG Optimizer', | |
| 657 | - 'breeze/breeze.php' => 'Breeze', | |
| 658 | - 'autoptimize/autoptimize.php' => 'Autoptimize', | |
| 659 | - 'flying-press/flying-press.php' => 'FlyingPress', | |
| 660 | - 'nitropack/main.php' => 'NitroPack', | |
| 661 | - ); | |
| 662 | - | |
| 663 | - $active = array(); | |
| 664 | - foreach ( $known as $file => $label ) { | |
| 708 | + $found = array(); | |
| 709 | + foreach ( Cache_Plugin_Catalog::with_capability( Cache_Plugin_Catalog::CAP_PAGE_CACHE ) as $file => $entry ) { | |
| 710 | + // xSpeed is in the catalog — it is a page cache, and the detector | |
| 711 | + // needs to be able to name our own drop-in. It is not a conflict | |
| 712 | + // with itself, and listing it told every site running us to | |
| 713 | + // deactivate us to avoid double-caching. | |
| 714 | + if ( 'xspeed/xspeed.php' === $file ) { | |
| 715 | + continue; | |
| 716 | + } | |
| 665 | 717 | if ( is_plugin_active( $file ) ) { |
| 666 | - $active[] = $label; | |
| 718 | + $found[] = $entry['label']; | |
| 667 | 719 | } |
| 668 | 720 | } |
| 669 | - return $active; | |
| 721 | + | |
| 722 | + $dropin = self::foreign_dropin_label(); | |
| 723 | + if ( null !== $dropin && ! in_array( $dropin, $found, true ) ) { | |
| 724 | + $found[] = $dropin; | |
| 725 | + } | |
| 726 | + | |
| 727 | + return array_values( array_unique( $found ) ); | |
| 728 | + } | |
| 729 | + | |
| 730 | + /** | |
| 731 | + * The name of whoever owns advanced-cache.php, when it is not xSpeed. | |
| 732 | + * | |
| 733 | + * Null when the file is absent or ours. An owner we cannot identify still | |
| 734 | + * blocks the enable, so it is reported under a generic name rather than | |
| 735 | + * being silently dropped — "we could not tell" and "there is nothing | |
| 736 | + * there" are different answers. | |
| 737 | + */ | |
| 738 | + private static function foreign_dropin_label(): ?string { | |
| 739 | + $owner = Cache::dropin_owner(); | |
| 740 | + if ( Cache::DROPIN_XSPEED === $owner || Cache::DROPIN_NONE === $owner ) { | |
| 741 | + return null; | |
| 742 | + } | |
| 743 | + if ( Cache::DROPIN_UNREADABLE === $owner ) { | |
| 744 | + return __( 'an unreadable advanced-cache.php', 'xspeed' ); | |
| 745 | + } | |
| 746 | + | |
| 747 | + $contents = @file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- read-only inspection of a drop-in we may not own; failure is reported as an unidentified owner. | |
| 748 | + $named = is_string( $contents ) ? Cache_Plugin_Catalog::identify_dropin( $contents ) : null; | |
| 749 | + if ( null !== $named ) { | |
| 750 | + $entry = Cache_Plugin_Catalog::get( $named ); | |
| 751 | + return (string) ( $entry['label'] ?? $named ); | |
| 752 | + } | |
| 753 | + | |
| 754 | + return __( 'an unidentified advanced-cache.php', 'xspeed' ); | |
| 670 | 755 | } |
| 671 | 756 | } |