PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
← All changes | includes/class-page-cache-detector.php +199 -10 1.2.4 → 1.4.2 View file →
@@ -42,8 +42,15 @@
42 42 /** More than one foreign page cache is in play. */
43 43 public const STATE_CONTESTED = 'contested';
44 44 /** A drop-in (or a live WP_CACHE) exists that we cannot attribute to anyone. */
45 45 public const STATE_UNKNOWN_OCCUPIED = 'unknown-occupied';
46 + /**
47 + * A drop-in is present that nothing is running to defend: it is empty, or
48 + * unnameable with every page-cache plugin switched off. We may take the
49 + * field, but the field is NOT clear -- a file is still there, and
50 + * is_field_clear() must keep saying so to the host plugins that ask. (#391)
51 + */
52 + public const STATE_ABANDONED = 'abandoned';
46 53 /** We could not read what we needed to decide. */
47 54 public const STATE_UNAVAILABLE = 'unavailable';
48 55
49 56 /** Drop-in owner classifications. */
@@ -50,8 +57,12 @@
50 57 public const OWNER_NONE = 'none';
51 58 public const OWNER_XSPEED = 'xspeed';
52 59 public const OWNER_FOREIGN = 'foreign';
53 60 public const OWNER_UNKNOWN = 'unknown';
61 + /** The file is present but holds nothing — empty, or whitespace only. */
62 + public const OWNER_ABANDONED = 'abandoned';
63 + /** Our own row in the plugin catalog, keyed by this exact file name. */
64 + private const SELF_PLUGIN_FILE = 'xspeed/xspeed.php';
54 65
55 66 /*
56 67 * Blocker codes, not sentences.
57 68 *
@@ -67,8 +78,14 @@
67 78 /** advanced-cache.php is there and cannot be read. */
68 79 public const BLOCKER_UNREADABLE_DROPIN = 'unreadable_dropin';
69 80 /** A page-cache-capable plugin is active with no drop-in evidence. */
70 81 public const BLOCKER_ACTIVE_PAGE_CACHE = 'active_page_cache';
82 + /**
83 + * An active plugin's own settings say its page cache is on. Unlike
84 + * `active_page_cache`, which only says it CAN cache pages, this is
85 + * proof that it does, so xSpeed's would be stacked on top of it. (#653)
86 + */
87 + public const BLOCKER_PAGE_CACHE_SWITCHED_ON = 'page_cache_switched_on';
71 88 /** More than one plugin owns, or could own, the page cache. */
72 89 public const BLOCKER_MULTIPLE_PAGE_CACHES = 'multiple_page_caches';
73 90 /** WP_CACHE is true with no drop-in to explain it. */
74 91 public const BLOCKER_WP_CACHE_ORPHANED = 'wp_cache_orphaned';
@@ -171,8 +188,10 @@
171 188 if ( ! $active && empty( $signals ) ) {
172 189 continue;
173 190 }
174 191
192 + $page_cache = in_array( Cache_Plugin_Catalog::CAP_PAGE_CACHE, $entry['capabilities'], true );
193 +
175 194 $out[] = array(
176 195 'plugin' => $file,
177 196 'label' => $entry['label'],
178 197 'active' => $active,
@@ -180,9 +199,12 @@
180 199 'site_active' => $site_active,
181 200 'network_active' => $network_active,
182 201 'activation_scope' => $site_active && $network_active ? 'site-and-network' : ( $network_active ? 'network' : ( $site_active ? 'site' : 'inactive' ) ),
183 202 'capabilities' => $entry['capabilities'],
184 - 'page_cache' => in_array( Cache_Plugin_Catalog::CAP_PAGE_CACHE, $entry['capabilities'], true ),
203 + 'page_cache' => $page_cache,
204 + // The plugin's own page-cache switch: true on, false off,
205 + // null unknown. Only asked of an active page cache.
206 + 'page_cache_on' => ( $page_cache && $active ) ? Cache_Plugin_Catalog::page_cache_state( $file ) : null,
185 207 'signals' => $signals,
186 208 );
187 209 }
188 210 return $out;
@@ -259,8 +281,22 @@
259 281 }
260 282
261 283 $state['hash'] = hash( 'sha256', $contents );
262 284
285 + /*
286 + * An EMPTY file owns nothing. WP Rocket truncates advanced-cache.php
287 + * to 0 bytes on deactivate, and a whitespace-only file is the same
288 + * nothing. Both matched no vendor signature and fell through to
289 + * OWNER_UNKNOWN below, which blocks acquisition permanently — a site
290 + * could be left with the source plugin off, xSpeed refused, and no
291 + * page cache at all, clearable only over SSH. There is nothing here
292 + * to break and nobody to ask. (#391)
293 + */
294 + if ( '' === trim( $contents ) ) {
295 + $state['owner'] = self::OWNER_ABANDONED;
296 + return $state;
297 + }
298 +
263 299 if ( self::has_xspeed_signature( $contents ) ) {
264 300 $state['owner'] = self::OWNER_XSPEED;
265 301 $state['label'] = 'xSpeed';
266 302 return $state;
@@ -402,8 +438,52 @@
402 438 * `foreign-residual` passes because the artifacts left behind are inert —
403 439 * no drop-in, no active plugin — and refusing there would strand every
404 440 * site that ever tried another cache plugin.
405 441 */
442 + /**
443 + * Is some OTHER page-cache plugin active right now?
444 + *
445 + * "Is anything running that replacing this file would break?" is the
446 + * question that decides an acquisition, and it has to exclude US. We are
447 + * in the catalog too and we are nearly always active while asking, so
448 + * counting ourselves answered yes on every site -- which is why an
449 + * unnameable drop-in could never be recognised as abandoned. (#391, #393)
450 + *
451 + * Deliberately narrow, so callers that need only this (Cache::dropin_owner())
452 + * do not have to run the whole of classify() -- which reads wp-config.php
453 + * and the drop-in, and would couple an ownership answer to failures that
454 + * have nothing to do with it.
455 + */
456 + public static function another_page_cache_is_active(): bool {
457 + foreach ( self::inspect()['plugins'] as $plugin ) {
458 + if ( self::SELF_PLUGIN_FILE === (string) $plugin['plugin'] ) {
459 + continue;
460 + }
461 + if ( self::may_be_caching_pages( $plugin ) ) {
462 + return true;
463 + }
464 + }
465 + return false;
466 + }
467 +
468 + /**
469 + * An active page-cache plugin, unless its own settings prove its page
470 + * cache is off.
471 + *
472 + * A plugin kept active for its other features, with its page cache
473 + * switched off, owns nothing here. Counting it anyway is what made
474 + * Health report LiteSpeed Cache as a conflict on GridPane's
475 + * OpenLiteSpeed servers with its page cache off. When the setting
476 + * cannot be read we still assume it may be caching. (#653)
477 + *
478 + * @param array $plugin One row from inspect_plugins().
479 + */
480 + private static function may_be_caching_pages( array $plugin ): bool {
481 + return ! empty( $plugin['page_cache'] )
482 + && ! empty( $plugin['active'] )
483 + && false !== ( $plugin['page_cache_on'] ?? null );
484 + }
485 +
406 486 public static function is_field_clear(): bool {
407 487 $verdict = self::classify();
408 488
409 489 if ( ! empty( $verdict['blockers'] ) ) {
@@ -427,9 +507,9 @@
427 507 */
428 508 public static function active_page_caches(): array {
429 509 $out = array();
430 510 foreach ( self::inspect()['plugins'] as $plugin ) {
431 - if ( $plugin['page_cache'] && $plugin['active'] ) {
511 + if ( self::may_be_caching_pages( $plugin ) ) {
432 512 $out[] = (string) $plugin['label'];
433 513 }
434 514 }
435 515 return $out;
@@ -444,8 +524,56 @@
444 524 return is_string( $dropin['label'] ) ? $dropin['label'] : null;
445 525 }
446 526
447 527 /**
528 + * What the dashboard must tell the user BEFORE it turns page caching on.
529 + *
530 + * Enabling writes wp-content/advanced-cache.php, which WordPress gives to
531 + * exactly one plugin. When a file is already there, the write REPLACES it
532 + * — so the dashboard says whose file it is BEFORE the click rather than
533 + * taking it silently:
534 + *
535 + * - `exists` a drop-in is on disk right now
536 + * - `replaceable` enabling would overwrite it
537 + * - `label` who it belongs to, when that can be named
538 + * ("WP Rocket"), null when it genuinely cannot
539 + *
540 + * Ownership is no longer what decides this. A competitor's live drop-in
541 + * used to be refused outright, which left a user who had asked for our
542 + * cache unable to get it — turning the page cache on is the instruction
543 + * to serve pages from cache, and that cannot be done without this file.
544 + * So a foreign drop-in is replaceable like any other, and the prompt is
545 + * how the user is told what they are taking over.
546 + *
547 + * Two states still disclose nothing. A drop-in we already own is a plain
548 + * re-enable with nothing to replace, and an UNREADABLE one is refused by
549 + * install_dropin() — promising a replacement that the writer will then
550 + * refuse is the split brain this method exists to avoid.
551 + *
552 + * @return array{exists:bool,replaceable:bool,owner:string,label:string|null}
553 + */
554 + public static function dropin_disclosure(): array {
555 + $dropin = self::inspect()['dropin'];
556 + $owner = (string) $dropin['owner'];
557 +
558 + /*
559 + * `replaceable` is a PROMISE, kept by install_dropin(), so this list
560 + * must stay in step with the refusals there: everything except our
561 + * own file and one we cannot read.
562 + */
563 + $replaceable = (bool) $dropin['exists']
564 + && self::OWNER_XSPEED !== $owner
565 + && $dropin['readable'];
566 +
567 + return array(
568 + 'exists' => (bool) $dropin['exists'],
569 + 'replaceable' => $replaceable,
570 + 'owner' => $owner,
571 + 'label' => is_string( $dropin['label'] ) ? $dropin['label'] : null,
572 + );
573 + }
574 +
575 + /**
448 576 * Classify the report into one ownership state plus the reasons behind it.
449 577 *
450 578 * Blockers and notes are CODES with the evidence attached, never rendered
451 579 * sentences — see the BLOCKER_* constants. Cache::ownership_blocker_message()
@@ -490,13 +618,28 @@
490 618 */
491 619 $owners = array();
492 620 $active = array();
493 621 foreach ( $report['plugins'] as $plugin ) {
494 - if ( $plugin['page_cache'] && $plugin['active'] ) {
622 + if ( self::may_be_caching_pages( $plugin ) ) {
495 623 $active[ (string) $plugin['plugin'] ] = $plugin;
496 624 $owners[ (string) $plugin['plugin'] ] = (string) $plugin['label'];
497 625 }
498 626 }
627 +
628 + /*
629 + * Proven live, not just possible. Raised before the state ladder so
630 + * it rides along with whichever state wins: two page caches serving
631 + * the same pages is the problem whoever holds the drop-in. (#653)
632 + */
633 + foreach ( $active as $file => $plugin ) {
634 + if ( self::SELF_PLUGIN_FILE !== $file && true === ( $plugin['page_cache_on'] ?? null ) ) {
635 + $blockers[] = array(
636 + 'code' => self::BLOCKER_PAGE_CACHE_SWITCHED_ON,
637 + 'plugin' => $plugin['plugin'],
638 + 'label' => $plugin['label'],
639 + );
640 + }
641 + }
499 642 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
500 643 $key = (string) ( $dropin['plugin'] ?? $dropin['label'] );
501 644 $owners[ $key ] = (string) $dropin['label'];
502 645 $blockers[] = array(
@@ -504,13 +647,30 @@
504 647 'plugin' => $dropin['plugin'],
505 648 'label' => $dropin['label'],
506 649 );
507 650 } elseif ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
508 - $blockers[] = array(
509 - 'code' => $dropin['readable'] ? self::BLOCKER_UNKNOWN_DROPIN : self::BLOCKER_UNREADABLE_DROPIN,
510 - 'plugin' => null,
511 - 'label' => null,
512 - );
651 + /*
652 + * A file we cannot name blocks only while a page cache is
653 + * actually running. With every candidate switched off there is
654 + * nothing to break by replacing it, and refusing anyway strands
655 + * the site with no cache and no route back that is not SSH.
656 + *
657 + * An UNREADABLE file is different and still blocks outright: we
658 + * cannot even see what we would destroy. (#391, #393)
659 + */
660 + if ( ! $dropin['readable'] ) {
661 + $blockers[] = array(
662 + 'code' => self::BLOCKER_UNREADABLE_DROPIN,
663 + 'plugin' => null,
664 + 'label' => null,
665 + );
666 + } elseif ( self::another_page_cache_is_active() ) {
667 + $blockers[] = array(
668 + 'code' => self::BLOCKER_UNKNOWN_DROPIN,
669 + 'plugin' => null,
670 + 'label' => null,
671 + );
672 + }
513 673 }
514 674
515 675 $wp_cache_blocker = array(
516 676 'unreadable' => self::BLOCKER_WP_CONFIG_UNREADABLE,
@@ -549,11 +709,24 @@
549 709 return self::verdict( self::STATE_CONTESTED, $blockers, $notes, $report );
550 710 }
551 711
552 712 if ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
553 - return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
713 + // Live competitor -> occupied and refused. Nothing running ->
714 + // abandoned: acquirable, but still not a clear field. Ourselves
715 + // excluded: $active counts US too, and we are active while
716 + // asking, so this could never be false. (#391/#393)
717 + return self::verdict(
718 + self::another_page_cache_is_active() ? self::STATE_UNKNOWN_OCCUPIED : self::STATE_ABANDONED,
719 + $blockers,
720 + $notes,
721 + $report
722 + );
554 723 }
555 724
725 + if ( self::OWNER_ABANDONED === $dropin['owner'] ) {
726 + return self::verdict( self::STATE_ABANDONED, $blockers, $notes, $report );
727 + }
728 +
556 729 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
557 730 return self::verdict( self::STATE_FOREIGN_LIVE, $blockers, $notes, $report );
558 731 }
559 732
@@ -713,9 +886,9 @@
713 886 }
714 887
715 888 return in_array(
716 889 $verdict['state'],
717 - array( self::STATE_UNCLAIMED, self::STATE_XSPEED_OWNED, self::STATE_FOREIGN_RESIDUAL ),
890 + array( self::STATE_UNCLAIMED, self::STATE_XSPEED_OWNED, self::STATE_FOREIGN_RESIDUAL, self::STATE_ABANDONED ),
718 891 true
719 892 );
720 893 }
721 894
@@ -759,8 +932,24 @@
759 932 * Drop the memoized report. Anything that changes plugin state or writes
760 933 * a drop-in must call this.
761 934 */
762 935 public static function invalidate(): void {
936 + /*
937 + * Drop PHP's stat cache with our own memo. Both describe the same
938 + * files, and the caller invalidating us has just changed them --
939 + * often from inside another plugin's deactivation hook, in the same
940 + * request. file_exists()/filesize()/is_readable() would otherwise
941 + * keep answering from before the change, so a drop-in truncated to
942 + * 0 bytes a moment ago still reads as the source plugin's live cache
943 + * and the handover refuses. (#391)
944 + *
945 + * No unit test: file_put_contents() clears the entry for the path it
946 + * writes, so a single-process test cannot reproduce a stale stat --
947 + * the real case is another plugin's teardown writing through a
948 + * different path string. Verified end to end against a live WP Rocket
949 + * install instead.
950 + */
951 + clearstatcache();
763 952 self::$report = null;
764 953 Cache_Plugin_Catalog::invalidate();
765 954 }
766 955