PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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 1.1.2 All 35 releases
← All changes | includes/class-object-cache.php +218 -16 1.3.6 → 1.4.1 View file →
@@ -46,9 +46,9 @@
46 46 * persistent: bool, // ours is installed AND persisting
47 47 * class_available: array<string,bool>
48 48 * }
49 49 */
50 - public static function detect(): array {
50 + public static function detect( bool $with_owner = false ): array {
51 51 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
52 52 $has_drop_in = '' !== $dropin && file_exists( $dropin );
53 53 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
54 54 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
@@ -116,12 +116,89 @@
116 116 'wp_cache_active' => $ext_in_use,
117 117 'degraded' => $degraded,
118 118 'persistent' => $persistent,
119 119 'class_available' => $class_available,
120 + // Who owns a foreign drop-in and how a switch would go, so every
121 + // surface can offer it (or say why not) the same way. (#686)
122 + // Only on request: it reads plugin folders, and module status
123 + // calls detect() on dashboard loads.
124 + 'owner' => $with_owner && $has_drop_in && ! self::is_our_dropin_present() ? Object_Cache_Takeover::owner() : null,
125 + // The plugin a switch replaced, which Disable can put back.
126 + 'previous_owner' => self::previous_owner(),
120 127 );
121 128 }
122 129
123 130 /**
131 + * Label of the plugin xSpeed switched from, while ours is installed.
132 + *
133 + * @return array{label:string}|null
134 + */
135 + private static function previous_owner(): ?array {
136 + if ( ! self::is_our_dropin_present() ) {
137 + return null;
138 + }
139 + $record = Object_Cache_Takeover::record();
140 + return null === $record ? null : array( 'label' => $record['label'] );
141 + }
142 +
143 + /** A drop-in that is not ours sits in wp-content. */
144 + private static function foreign_dropin_present(): bool {
145 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
146 + return '' !== $dropin && ( file_exists( $dropin ) || is_link( $dropin ) ) && ! self::is_our_dropin_present();
147 + }
148 +
149 + /** Our drop-in is the object cache this request is running on. */
150 + private static function our_dropin_is_live(): bool {
151 + return isset( $GLOBALS['wp_object_cache'] ) && $GLOBALS['wp_object_cache'] instanceof \XSpeed_Object_Cache;
152 + }
153 +
154 + /**
155 + * Delete this site's keys from Redis, the same `salt:*` scope the
156 + * drop-in's flush uses. For when the drop-in is not loaded in this
157 + * request, so wp_cache_flush() would flush someone else's cache or none.
158 + * Memcached has no key enumeration; its namespace generation covers it.
159 + *
160 + * @param array $opts Settings array.
161 + * @return int Keys deleted, or -1 when nothing could be done.
162 + */
163 + public static function purge_namespace( array $opts ): int {
164 + if ( 'redis' !== (string) ( $opts['backend'] ?? 'redis' ) ) {
165 + return -1;
166 + }
167 + $salt = self::effective_salt( $opts );
168 + if ( '' === $salt ) {
169 + return -1;
170 + }
171 + $client = new Redis_Client(
172 + self::str( $opts, 'redis_host', '127.0.0.1' ),
173 + self::int( $opts, 'redis_port', 6379 ),
174 + (float) self::int( $opts, 'connection_timeout', 1 ),
175 + false
176 + );
177 + if ( ! $client->connect() ) {
178 + return -1;
179 + }
180 + $user = self::str( $opts, 'redis_user', '' );
181 + $pass = self::str( $opts, 'redis_password', '' );
182 + if ( ( '' !== $pass || '' !== $user ) && false === $client->auth( $pass, $user ) ) {
183 + $client->close();
184 + return -1;
185 + }
186 + $db = self::int( $opts, 'redis_database', 0 );
187 + if ( $db > 0 ) {
188 + $client->select( $db );
189 + }
190 + $pattern = str_replace(
191 + array( '\\', '*', '?', '[', ']' ),
192 + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
193 + $salt
194 + ) . ':*';
195 + $deleted = $client->delete_by_pattern( $pattern );
196 + $client->close();
197 + return $deleted;
198 + }
199 +
200 + /**
124 201 * Flush whatever cache backend is wired up. Works against any
125 202 * compliant drop-in OR the WP default in-memory cache.
126 203 */
127 204 public static function flush(): bool {
@@ -376,9 +453,9 @@
376 453 $prefix = self::str( $opts, 'key_prefix', '' );
377 454 if ( '' === $prefix || ! self::salt_has_glob_metachars( $prefix ) ) {
378 455 return $message;
379 456 }
380 - return $message . ' Note: the Cache Key Prefix contains one of * ? [ ] \\.'
457 + return $message . ' Note: the Cache key prefix contains one of * ? [ ] \\.'
381 458 . ' Purges stay scoped to this site, but these are wildcard characters'
382 459 . ' in Redis — check the prefix matches your host\'s key exactly.';
383 460 }
384 461
@@ -568,9 +645,9 @@
568 645 private static function write_denied_message( array $opts, string $host, int $port ): string {
569 646 $has_prefix = '' !== self::str( $opts, 'key_prefix', '' );
570 647 $hint = $has_prefix
571 648 ? 'The Redis user may lack write permission for this key prefix (NOPERM).'
572 - : 'On ACL/namespaced Redis (e.g. xCloud), set Cache Key Prefix to the host\'s "Redis Object Cache Key" so writes land in the permitted namespace.';
649 + : 'On ACL/namespaced Redis (e.g. xCloud), set Cache key prefix to the host\'s "Redis Object Cache Key" so writes land in the permitted namespace.';
573 650 return "Connected to Redis at {$host}:{$port}, but the cache could not store data. {$hint}";
574 651 }
575 652
576 653 /**
@@ -580,9 +657,56 @@
580 657 *
581 658 * @param array $opts Settings array.
582 659 * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
583 660 */
584 - public static function enable( array $opts ): array {
661 + public static function enable( array $opts, array $args = array() ): array {
662 + /*
663 + * Another plugin's object-cache.php is a switch, not an install: its
664 + * owner has to be out of the picture first or it puts its file back
665 + * (W3TC on the next admin request, LiteSpeed on its next save). The
666 + * panel always refused here while REST, CLI and MCP overwrote the
667 + * file, so every entry point now refuses unless the caller asked for
668 + * the switch. (#686)
669 + */
670 + if ( self::foreign_dropin_present() ) {
671 + if ( empty( $args['takeover'] ) ) {
672 + $owner = Object_Cache_Takeover::owner();
673 + return array(
674 + 'ok' => false,
675 + 'needs_takeover' => true,
676 + 'message' => Object_Cache_Takeover::refusal_message( $owner ),
677 + 'steps' => array(
678 + 'connection' => false,
679 + 'wp_config' => false,
680 + 'drop_in' => false,
681 + 'verified' => false,
682 + ),
683 + 'owner' => $owner,
684 + 'detect' => self::detect( true ),
685 + );
686 + }
687 + return Object_Cache_Takeover::run( $opts );
688 + }
689 +
690 + // Enabling from scratch starts a new session, so an earlier switch's
691 + // record no longer describes anything to put back. Re-running enable
692 + // over our own drop-in (a re-sync, an import) keeps it.
693 + if ( ! self::is_our_dropin_present() ) {
694 + Object_Cache_Takeover::forget();
695 + }
696 + return self::install( $opts );
697 + }
698 +
699 + /**
700 + * Test, write the wp-config block and install our drop-in. The shared
701 + * second half of enable() and of a switch from another plugin; neither
702 + * calls it while a foreign drop-in is in place.
703 + *
704 + * @param array $opts Settings array.
705 + * @param array|null $test A connection test already run moments ago.
706 + * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
707 + */
708 + public static function install( array $opts, ?array $test = null ): array {
585 709 $steps = array(
586 710 'connection' => false,
587 711 'wp_config' => false,
588 712 'drop_in' => false,
@@ -589,9 +713,9 @@
589 713 'verified' => false,
590 714 );
591 715
592 716 // 1. Don't write anything until the backend actually answers.
593 - $test = self::test_connection( $opts );
717 + $test = $test ?? self::test_connection( $opts );
594 718 if ( ! $test['ok'] ) {
595 719 return array(
596 720 'ok' => false,
597 721 'message' => 'Could not enable: ' . $test['message'],
@@ -596,13 +720,21 @@
596 720 'ok' => false,
597 721 'message' => 'Could not enable: ' . $test['message'],
598 722 'steps' => $steps,
599 723 'test' => $test,
600 - 'detect' => self::detect(),
724 + 'detect' => self::detect( true ),
601 725 );
602 726 }
603 727 $steps['connection'] = true;
604 728
729 + // Values from an earlier xSpeed session are still in Redis (keys
730 + // have no TTL, `alloptions` included), and the drop-in would read
731 + // them back as current. Only when ours is not already the live cache:
732 + // re-running enable over a working install must not cool it.
733 + if ( ! self::our_dropin_is_live() ) {
734 + self::purge_namespace( $opts );
735 + }
736 +
605 737 // 2. Write the XSPEED_OC_* constants into wp-config.php.
606 738 $steps['wp_config'] = self::write_wp_config( $opts );
607 739
608 740 // 3. Install our drop-in.
@@ -633,9 +765,9 @@
633 765 * Full reverse of enable(): remove drop-in + strip our wp-config block.
634 766 *
635 767 * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
636 768 */
637 - public static function disable(): array {
769 + public static function disable( array $args = array() ): array {
638 770 // A drop-in owned by another plugin is left in place by
639 771 // remove_dropin(), which then reports success because nothing of ours
640 772 // is there to remove. Reporting "disabled" for that is a lie: the site
641 773 // still has someone else's object cache running. Say so instead.
@@ -647,12 +779,26 @@
647 779 'steps' => array(
648 780 'drop_in' => false,
649 781 'wp_config' => false,
650 782 ),
651 - 'detect' => self::detect(),
783 + 'detect' => self::detect( true ),
652 784 );
653 785 }
654 786
787 + // Empty our namespace before the drop-in goes, so a later enable
788 + // never reads this session's values back. Our cache stays loaded
789 + // until this request ends, so whatever it writes after this point
790 + // (a restore's own option writes) is flushed again at shutdown.
791 + // (#686)
792 + if ( self::is_our_dropin_present() ) {
793 + if ( self::our_dropin_is_live() ) {
794 + self::flush();
795 + add_action( 'shutdown', array( __CLASS__, 'flush' ), PHP_INT_MAX );
796 + } elseif ( class_exists( __NAMESPACE__ . '\\Settings_Manager' ) ) {
797 + self::purge_namespace( (array) Settings_Manager::get( 'object-cache' ) );
798 + }
799 + }
800 +
655 801 $dropin_removed = self::remove_dropin();
656 802 $config_removed = self::remove_wp_config();
657 803
658 804 /*
@@ -663,18 +809,38 @@
663 809 * from a file nothing in this path had touched.
664 810 */
665 811 self::delete_sidecar();
666 812
813 + $message = $dropin_removed
814 + ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
815 + : 'Could not remove the drop-in — wp-content may not be writable.';
816 +
817 + // Put back the plugin xSpeed switched from, when asked. Without the
818 + // ask, the record describes nothing any more.
819 + $restored = null;
820 + if ( $dropin_removed && ! empty( $args['restore'] ) ) {
821 + $restored = Object_Cache_Takeover::restore();
822 + $message .= ' ' . $restored['message'];
823 + } elseif ( $dropin_removed ) {
824 + // Not put back, but its namespace is cleared all the same, so a
825 + // later manual re-enable does not start from the switch's
826 + // snapshot.
827 + Object_Cache_Takeover::discard();
828 + }
829 +
667 830 return array(
668 - 'ok' => $dropin_removed,
669 - 'message' => $dropin_removed
670 - ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
671 - : 'Could not remove the drop-in — wp-content may not be writable.',
672 - 'steps' => array(
831 + // Whether xSpeed's object cache is off. A restore that could not
832 + // complete is reported beside it, in `restored` and the message:
833 + // the disable itself still happened. (#687)
834 + 'ok' => $dropin_removed,
835 + 'restored' => null === $restored ? null : $restored['ok'],
836 + 'message' => $message,
837 + 'steps' => array(
673 838 'drop_in' => $dropin_removed,
674 839 'wp_config' => $config_removed,
840 + 'restored' => null !== $restored && $restored['ok'],
675 841 ),
676 - 'detect' => self::detect(),
842 + 'detect' => self::detect( true ),
677 843 );
678 844 }
679 845
680 846 /**
@@ -706,9 +872,11 @@
706 872 if ( $is_xspeed ) {
707 873 if ( $existing === $source_contents ) {
708 874 return true;
709 875 }
710 - return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
876 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
877 + self::invalidate_compiled( $target );
878 + return $written;
711 879 }
712 880
713 881 // Foreign drop-in — back it up before overwriting.
714 882 $upload = wp_upload_dir( null, false );
@@ -723,9 +891,11 @@
723 891 $fs->delete( $target );
724 892 }
725 893 }
726 894
727 - return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
895 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
896 + self::invalidate_compiled( $target );
897 + return $written;
728 898 }
729 899
730 900 /**
731 901 * Remove our drop-in (only if it's ours). Returns true when no xSpeed
@@ -742,8 +912,9 @@
742 912 }
743 913 $contents = $fs->get_contents( $target );
744 914 if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) {
745 915 wp_delete_file( $target );
916 + self::invalidate_compiled( $target );
746 917 return ! file_exists( $target );
747 918 }
748 919 // Not ours — leave it, but report success (nothing of ours to remove).
749 920 return true;
@@ -1076,8 +1247,9 @@
1076 1247 }
1077 1248
1078 1249 $written = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1079 1250 if ( $written ) {
1251 + self::invalidate_compiled( $wp_config );
1080 1252 // The block just changed; a memoized scan from earlier in this
1081 1253 // request would still name the previous set. (#398)
1082 1254 self::forget_our_constants();
1083 1255
@@ -1111,8 +1283,9 @@
1111 1283 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
1112 1284 $config = preg_replace( $pattern, '', $config );
1113 1285 $removed = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1114 1286 if ( $removed ) {
1287 + self::invalidate_compiled( $wp_config );
1115 1288 // A scan from earlier in this request would still name the
1116 1289 // constants we just deleted, so origins() would report a field as
1117 1290 // ours -- editable -- when a host define is now the only source
1118 1291 // and the field should read as pinned.
@@ -1281,8 +1454,37 @@
1281 1454 * (no credentials in a non-interactive context) and fatal in
1282 1455 * ftp_fget(). We only need 'direct' — these writes target wp-config.php /
1283 1456 * wp-content, both owned by the PHP user on a normal install.
1284 1457 */
1458 + /**
1459 + * Drop a file's compiled copy from OPcache after we rewrite or remove it.
1460 + *
1461 + * wp-config.php and object-cache.php are both compiled once and reused.
1462 + * With the default revalidate_freq of 2 seconds, and indefinitely where a
1463 + * host turns validate_timestamps off, requests right after a write still
1464 + * run the old code: the panel read "Off" and "Backend: unknown" just
1465 + * after a switch because the new block's constants were not defined yet,
1466 + * and the old drop-in was still the live cache. (#687)
1467 + *
1468 + * @param string $path Absolute file path.
1469 + */
1470 + public static function invalidate_compiled( string $path ): void {
1471 + if ( function_exists( 'wp_opcache_invalidate' ) ) {
1472 + wp_opcache_invalidate( $path, true );
1473 + } elseif ( function_exists( 'opcache_invalidate' ) ) {
1474 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
1475 + }
1476 + }
1477 +
1478 + /**
1479 + * The direct filesystem, for Object_Cache_Takeover's copies and deletes.
1480 + *
1481 + * @return \WP_Filesystem_Base|null
1482 + */
1483 + public static function filesystem() {
1484 + return self::fs();
1485 + }
1486 +
1285 1487 private static function fs() {
1286 1488 global $wp_filesystem;
1287 1489 if ( ! function_exists( 'WP_Filesystem' ) ) {
1288 1490 require_once ABSPATH . 'wp-admin/includes/file.php';