PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/class-page-cache-detector.php +152 -7 1.2.41.3.4 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 *
@@ -259,8 +270,22 @@
259 270 }
260 271
261 272 $state['hash'] = hash( 'sha256', $contents );
262 273
274 + /*
275 + * An EMPTY file owns nothing. WP Rocket truncates advanced-cache.php
276 + * to 0 bytes on deactivate, and a whitespace-only file is the same
277 + * nothing. Both matched no vendor signature and fell through to
278 + * OWNER_UNKNOWN below, which blocks acquisition permanently — a site
279 + * could be left with the source plugin off, xSpeed refused, and no
280 + * page cache at all, clearable only over SSH. There is nothing here
281 + * to break and nobody to ask. (#391)
282 + */
283 + if ( '' === trim( $contents ) ) {
284 + $state['owner'] = self::OWNER_ABANDONED;
285 + return $state;
286 + }
287 +
263 288 if ( self::has_xspeed_signature( $contents ) ) {
264 289 $state['owner'] = self::OWNER_XSPEED;
265 290 $state['label'] = 'xSpeed';
266 291 return $state;
@@ -402,8 +427,34 @@
402 427 * `foreign-residual` passes because the artifacts left behind are inert —
403 428 * no drop-in, no active plugin — and refusing there would strand every
404 429 * site that ever tried another cache plugin.
405 430 */
431 + /**
432 + * Is some OTHER page-cache plugin active right now?
433 + *
434 + * "Is anything running that replacing this file would break?" is the
435 + * question that decides an acquisition, and it has to exclude US. We are
436 + * in the catalog too and we are nearly always active while asking, so
437 + * counting ourselves answered yes on every site -- which is why an
438 + * unnameable drop-in could never be recognised as abandoned. (#391, #393)
439 + *
440 + * Deliberately narrow, so callers that need only this (Cache::dropin_owner())
441 + * do not have to run the whole of classify() -- which reads wp-config.php
442 + * and the drop-in, and would couple an ownership answer to failures that
443 + * have nothing to do with it.
444 + */
445 + public static function another_page_cache_is_active(): bool {
446 + foreach ( self::inspect()['plugins'] as $plugin ) {
447 + if ( self::SELF_PLUGIN_FILE === (string) $plugin['plugin'] ) {
448 + continue;
449 + }
450 + if ( ! empty( $plugin['page_cache'] ) && ! empty( $plugin['active'] ) ) {
451 + return true;
452 + }
453 + }
454 + return false;
455 + }
456 +
406 457 public static function is_field_clear(): bool {
407 458 $verdict = self::classify();
408 459
409 460 if ( ! empty( $verdict['blockers'] ) ) {
@@ -444,8 +495,56 @@
444 495 return is_string( $dropin['label'] ) ? $dropin['label'] : null;
445 496 }
446 497
447 498 /**
499 + * What the dashboard must tell the user BEFORE it turns page caching on.
500 + *
501 + * Enabling writes wp-content/advanced-cache.php, which WordPress gives to
502 + * exactly one plugin. When a file is already there, the write REPLACES it
503 + * — so the dashboard says whose file it is BEFORE the click rather than
504 + * taking it silently:
505 + *
506 + * - `exists` a drop-in is on disk right now
507 + * - `replaceable` enabling would overwrite it
508 + * - `label` who it belongs to, when that can be named
509 + * ("WP Rocket"), null when it genuinely cannot
510 + *
511 + * Ownership is no longer what decides this. A competitor's live drop-in
512 + * used to be refused outright, which left a user who had asked for our
513 + * cache unable to get it — turning the page cache on is the instruction
514 + * to serve pages from cache, and that cannot be done without this file.
515 + * So a foreign drop-in is replaceable like any other, and the prompt is
516 + * how the user is told what they are taking over.
517 + *
518 + * Two states still disclose nothing. A drop-in we already own is a plain
519 + * re-enable with nothing to replace, and an UNREADABLE one is refused by
520 + * install_dropin() — promising a replacement that the writer will then
521 + * refuse is the split brain this method exists to avoid.
522 + *
523 + * @return array{exists:bool,replaceable:bool,owner:string,label:string|null}
524 + */
525 + public static function dropin_disclosure(): array {
526 + $dropin = self::inspect()['dropin'];
527 + $owner = (string) $dropin['owner'];
528 +
529 + /*
530 + * `replaceable` is a PROMISE, kept by install_dropin(), so this list
531 + * must stay in step with the refusals there: everything except our
532 + * own file and one we cannot read.
533 + */
534 + $replaceable = (bool) $dropin['exists']
535 + && self::OWNER_XSPEED !== $owner
536 + && $dropin['readable'];
537 +
538 + return array(
539 + 'exists' => (bool) $dropin['exists'],
540 + 'replaceable' => $replaceable,
541 + 'owner' => $owner,
542 + 'label' => is_string( $dropin['label'] ) ? $dropin['label'] : null,
543 + );
544 + }
545 +
546 + /**
448 547 * Classify the report into one ownership state plus the reasons behind it.
449 548 *
450 549 * Blockers and notes are CODES with the evidence attached, never rendered
451 550 * sentences — see the BLOCKER_* constants. Cache::ownership_blocker_message()
@@ -504,13 +603,30 @@
504 603 'plugin' => $dropin['plugin'],
505 604 'label' => $dropin['label'],
506 605 );
507 606 } 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 - );
607 + /*
608 + * A file we cannot name blocks only while a page cache is
609 + * actually running. With every candidate switched off there is
610 + * nothing to break by replacing it, and refusing anyway strands
611 + * the site with no cache and no route back that is not SSH.
612 + *
613 + * An UNREADABLE file is different and still blocks outright: we
614 + * cannot even see what we would destroy. (#391, #393)
615 + */
616 + if ( ! $dropin['readable'] ) {
617 + $blockers[] = array(
618 + 'code' => self::BLOCKER_UNREADABLE_DROPIN,
619 + 'plugin' => null,
620 + 'label' => null,
621 + );
622 + } elseif ( self::another_page_cache_is_active() ) {
623 + $blockers[] = array(
624 + 'code' => self::BLOCKER_UNKNOWN_DROPIN,
625 + 'plugin' => null,
626 + 'label' => null,
627 + );
628 + }
513 629 }
514 630
515 631 $wp_cache_blocker = array(
516 632 'unreadable' => self::BLOCKER_WP_CONFIG_UNREADABLE,
@@ -549,11 +665,24 @@
549 665 return self::verdict( self::STATE_CONTESTED, $blockers, $notes, $report );
550 666 }
551 667
552 668 if ( self::OWNER_UNKNOWN === $dropin['owner'] ) {
553 - return self::verdict( self::STATE_UNKNOWN_OCCUPIED, $blockers, $notes, $report );
669 + // Live competitor -> occupied and refused. Nothing running ->
670 + // abandoned: acquirable, but still not a clear field. Ourselves
671 + // excluded: $active counts US too, and we are active while
672 + // asking, so this could never be false. (#391/#393)
673 + return self::verdict(
674 + self::another_page_cache_is_active() ? self::STATE_UNKNOWN_OCCUPIED : self::STATE_ABANDONED,
675 + $blockers,
676 + $notes,
677 + $report
678 + );
554 679 }
555 680
681 + if ( self::OWNER_ABANDONED === $dropin['owner'] ) {
682 + return self::verdict( self::STATE_ABANDONED, $blockers, $notes, $report );
683 + }
684 +
556 685 if ( self::OWNER_FOREIGN === $dropin['owner'] ) {
557 686 return self::verdict( self::STATE_FOREIGN_LIVE, $blockers, $notes, $report );
558 687 }
559 688
@@ -713,9 +842,9 @@
713 842 }
714 843
715 844 return in_array(
716 845 $verdict['state'],
717 - array( self::STATE_UNCLAIMED, self::STATE_XSPEED_OWNED, self::STATE_FOREIGN_RESIDUAL ),
846 + array( self::STATE_UNCLAIMED, self::STATE_XSPEED_OWNED, self::STATE_FOREIGN_RESIDUAL, self::STATE_ABANDONED ),
718 847 true
719 848 );
720 849 }
721 850
@@ -759,8 +888,24 @@
759 888 * Drop the memoized report. Anything that changes plugin state or writes
760 889 * a drop-in must call this.
761 890 */
762 891 public static function invalidate(): void {
892 + /*
893 + * Drop PHP's stat cache with our own memo. Both describe the same
894 + * files, and the caller invalidating us has just changed them --
895 + * often from inside another plugin's deactivation hook, in the same
896 + * request. file_exists()/filesize()/is_readable() would otherwise
897 + * keep answering from before the change, so a drop-in truncated to
898 + * 0 bytes a moment ago still reads as the source plugin's live cache
899 + * and the handover refuses. (#391)
900 + *
901 + * No unit test: file_put_contents() clears the entry for the path it
902 + * writes, so a single-process test cannot reproduce a stale stat --
903 + * the real case is another plugin's teardown writing through a
904 + * different path string. Verified end to end against a live WP Rocket
905 + * install instead.
906 + */
907 + clearstatcache();
763 908 self::$report = null;
764 909 Cache_Plugin_Catalog::invalidate();
765 910 }
766 911