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-health.php +227 -4 1.1.71.3.4 View file →
@@ -99,10 +99,14 @@
99 99
100 100 // Drop-in installed (only when cache is enabled — otherwise N/A)
101 101 $cache_enabled = (bool) Settings::get()['cache_enabled'];
102 102 if ( $cache_enabled ) {
103 - $dropin_path = WP_CONTENT_DIR . '/advanced-cache.php';
104 - $dropin_match = file_exists( $dropin_path ) && false !== strpos( (string) file_get_contents( $dropin_path ), 'xspeed' );
103 + // Ask the ownership oracle, not the bytes. A loose "xspeed"
104 + // substring matched any foreign drop-in that so much as mentions
105 + // us in a compatibility note, and reported it as "owned by
106 + // xSpeed" while another plugin served every hit — the exact
107 + // loose-substring test the drop-in contract forbids.
108 + $dropin_match = Cache::DROPIN_XSPEED === Cache::dropin_owner();
105 109 $out[] = array(
106 110 'id' => 'dropin',
107 111 'tone' => $dropin_match ? self::OK : self::FAIL,
108 112 'label' => 'advanced-cache.php drop-in',
@@ -192,8 +196,19 @@
192 196 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
193 197 $probe_reason = (string) ( $probe['reason'] ?? '' );
194 198
195 199 $block_reason = Cache::static_rewrite_block_reason();
200 +
201 + // An OBSERVED refusal, from the last cacheable render. The settings
202 + // above say whether the rewrite is allowed; this says whether pages
203 + // are actually reaching the tree. They disagree whenever a page is
204 + // refused per-response — a nonce being the common one — and in that
205 + // case the settings are right and irrelevant: nginx is configured
206 + // correctly, and every hit still comes from PHP. (#372)
207 + $skip = Cache::last_static_skip();
208 + if ( '' === $block_reason && ! empty( $skip['reason'] ) ) {
209 + $block_reason = 'skipped_' . (string) $skip['reason'];
210 + }
196 211 $mobile_block = ( 'mobile_separate' === $block_reason )
197 212 ? ' Note: Separate Mobile Cache is on, which disables the device-blind static rewrite — if your site serves the same HTML to all devices, turn it off (Cache settings) for much faster cache hits.'
198 213 : '';
199 214
@@ -218,8 +233,10 @@
218 233 if ( $is_active ) {
219 234 $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
220 235 } elseif ( 'mobile_separate' === $block_reason ) {
221 236 $nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block;
237 + } elseif ( 'skipped_nonce' === $block_reason ) {
238 + $nginx_detail = self::nonce_skip_detail( $skip );
222 239 } elseif ( $inconclusive ) {
223 240 $nginx_detail = sprintf(
224 241 'Could not verify the static rewrite — the check itself did not complete, so this is not evidence that your config is wrong. If you have already pasted the snippet, it may well be working. Reason: %s',
225 242 $probe_reason
@@ -273,8 +290,14 @@
273 290 // still works via the drop-in; say so, and give the one
274 291 // step that actually changes the outcome.
275 292 $tone = self::INFO;
276 293 $detail = 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT (php), so every hit is visible and counted. The faster .htaccess fast path is off because Apache\'s mod_headers module is not loaded — without it a static hit could not be tagged or counted. Enable mod_headers (`a2enmod headers` on Debian/Ubuntu, then restart Apache) to shave roughly 20-30ms off each cache hit.';
294 + } elseif ( 'skipped_nonce' === $block_reason ) {
295 + // Before this, Apache fell through to "probe failed —
296 + // check AllowOverride", sending the admin to audit a
297 + // config that was never the problem.
298 + $tone = self::WARN;
299 + $detail = self::nonce_skip_detail( $skip );
277 300 } elseif ( ! $installed ) {
278 301 $tone = self::WARN;
279 302 $detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.';
280 303 } elseif ( $inconclusive ) {
@@ -299,8 +322,62 @@
299 322 );
300 323 }
301 324 }
302 325
326 + /*
327 + * A full-page cache owned by the WEB SERVER, in front of PHP.
328 + *
329 + * Reported only when it is actually there, because it is a fact about
330 + * the host rather than a setting the admin can act on from here — an
331 + * "absent" row would be noise on the ~99% of sites that have no such
332 + * layer. When it IS there it outranks almost everything else on this
333 + * panel: nginx answers before WordPress runs, so what a visitor sees
334 + * is decided by that cache and not by anything xSpeed reports about
335 + * its own.
336 + *
337 + * The severity is about DOUBLE full-page caching, not about the layer
338 + * existing. Two independent full-page caches stacked in front of one
339 + * site have independent TTLs, and the outer one can re-serve HTML the
340 + * inner one has already regenerated — the classic "I purged and it is
341 + * still stale" report. With xSpeed's own page cache off there is only
342 + * one layer and nothing to warn about, so that case is INFO.
343 + */
344 + $host_cache_path = Host_Page_Caches::nginx_helper_cache_path();
345 + if ( null !== $host_cache_path ) {
346 + $detail = $cache_enabled
347 + ? 'Your server is running its own full-page cache in nginx (FastCGI), managed by the Nginx Helper plugin your host installed — so this site has TWO full-page caches stacked in front of it. xSpeed forwards every Purge All to the server layer, but the two expire on their own schedules (the server side is typically an hour), so a page can still be served from nginx after xSpeed has regenerated it. If edits keep looking stale, purge from your host\'s dashboard too, or turn xSpeed\'s page cache off and let the server layer do the work — it is the faster of the two, because it answers before PHP starts.'
348 + : 'Your server is running a full-page cache in nginx (FastCGI), managed by the Nginx Helper plugin your host installed. xSpeed\'s own page cache is off, so this is the only full-page cache in front of the site — and it is the fastest kind, answering before PHP starts. Purge All in xSpeed still clears it.';
349 +
350 + // Path prefix as a fingerprint for the WORDING only — never as a
351 + // gate. Nginx Helper is not xCloud-only; other hosts and manual
352 + // installs use it with a cache directory somewhere else entirely.
353 + if ( 0 === strpos( $host_cache_path, '/etc/nginx/cache/' ) ) {
354 + $detail .= sprintf( ' Cache directory: %s (the layout xCloud provisions).', $host_cache_path );
355 + } else {
356 + $detail .= sprintf( ' Cache directory: %s.', $host_cache_path );
357 + }
358 +
359 + // The purge is a direct unlink by the PHP-FPM user against a
360 + // directory nginx owns. Whether that user can write there is a
361 + // property of the host we cannot test from here without deleting
362 + // someone's cache to find out, so say what to check rather than
363 + // claiming an outcome either way.
364 + if ( 'unlink_files' === Host_Page_Caches::nginx_helper_purge_method() ) {
365 + $detail .= ' The server cache is purged by deleting its files directly, which needs PHP to have write access to that directory — if a purge here never changes what nginx serves, that permission is the thing to check with your host.';
366 + }
367 +
368 + if ( is_multisite() ) {
369 + $detail .= ' On multisite, nginx keys one cache per install rather than per site, so this purge clears every site on the network.';
370 + }
371 +
372 + $out[] = array(
373 + 'id' => 'host_page_cache',
374 + 'tone' => $cache_enabled ? self::WARN : self::INFO,
375 + 'label' => 'Server-level page cache (nginx FastCGI)',
376 + 'detail' => $detail,
377 + );
378 + }
379 +
303 380 // Cache expiry vs preloader schedule (deterministic rule, issue #31):
304 381 // pages that expire faster than the preloader re-warms them leave the
305 382 // cache cold for most real traffic — the classic "24.8% hit ratio with
306 383 // everything on" misconfiguration. Pure logic in
@@ -309,9 +386,9 @@
309 386 $cache_opts = Settings_Manager::get( 'cache' );
310 387 $pre_opts = Settings_Manager::get( 'preloader' );
311 388 $schedule = (string) ( $pre_opts['schedule'] ?? 'manual' );
312 389 $mismatch = self::expiry_preload_check(
313 - (int) ( $cache_opts['cache_expiry'] ?? 24 ),
390 + (int) ( $cache_opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ),
314 391 $schedule,
315 392 ! empty( $pre_opts['enabled'] ),
316 393 self::schedule_interval_hours( $schedule )
317 394 );
@@ -330,8 +407,16 @@
330 407 ? 'Pretty permalinks active.'
331 408 : 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.',
332 409 );
333 410
411 + // What is in front of the site, and what we are telling it. Extracted
412 + // so it can be exercised without paying for every other probe in
413 + // checks(); see edge_check().
414 + $edge_row = self::edge_check();
415 + if ( null !== $edge_row ) {
416 + $out[] = $edge_row;
417 + }
418 +
334 419 // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
335 420 // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all
336 421 // HTML (Cloudflare never caches a response carrying Set-Cookie). Probe
337 422 // is transient-throttled inside Cookie_Inspector, same pattern as the
@@ -373,13 +458,44 @@
373 458 $out[] = array(
374 459 'id' => 'conflicts',
375 460 'tone' => empty( $conflicts ) ? self::OK : self::WARN,
376 461 'label' => 'Caching plugin conflicts',
462 + // Not "Active:" — the list now includes a drop-in left behind by a
463 + // plugin that is not running, which is exactly the case that made
464 + // this row disagree with what the enable actually does.
377 465 'detail' => empty( $conflicts )
378 466 ? 'No other caching plugins detected.'
379 - : sprintf( 'Active: %s. Deactivate before enabling xSpeed cache to avoid double-caching.', implode( ', ', $conflicts ) ),
467 + : sprintf( 'Found: %s. Another page cache must be off, and its advanced-cache.php gone, before xSpeed can enable its own.', implode( ', ', $conflicts ) ),
380 468 );
381 469
470 + /*
471 + * A migration whose source is STILL RUNNING.
472 + *
473 + * Distinct from the generic `conflicts` check above, which only says
474 + * "another caching plugin is active". This one knows the user imported
475 + * from it and chose (or was refused) to leave it on, so it can name the
476 + * plugin and the decision.
477 + *
478 + * The point is persistence: the import screen's warning disappears the
479 + * moment the user navigates away, and the risk does not. Two page
480 + * caches fighting over the drop-in is exactly what breaks caching for
481 + * both, so the warning has to outlive the screen it was raised on.
482 + * (#189 AC4)
483 + */
484 + $pending = class_exists( '\\XSpeed\\Migration' ) ? Migration::pending_source() : null;
485 + if ( null !== $pending ) {
486 + $out[] = array(
487 + 'id' => 'migration_source_active',
488 + 'tone' => self::WARN,
489 + 'label' => sprintf( '%s is still active after import', $pending['label'] ),
490 + 'detail' => sprintf(
491 + 'You imported settings from %s but left it running. Two page caches fight over the cache drop-in and can break caching for both — deactivate %s on the Plugins screen once you have checked the imported settings.',
492 + $pending['label'],
493 + $pending['label']
494 + ),
495 + );
496 + }
497 +
382 498 return $out;
383 499 }
384 500
385 501 /**
@@ -401,8 +517,38 @@
401 517 * Falls back to `wp_get_schedules()` so custom crons registered by a
402 518 * theme or another plugin are covered too, rather than silently
403 519 * skipping the check.
404 520 */
521 + /**
522 + * Explain a static-tree refusal caused by nonces.
523 + *
524 + * Says four things, because leaving any of them out is what made this
525 + * invisible: the config is FINE (so nobody re-pastes a snippet that was
526 + * never the problem), hits are coming from PHP instead, which nonce keys
527 + * caused it, and that the refusal is deliberate rather than a bug to work
528 + * around. The keys are the actionable part — they name the plugin, and it
529 + * is usually a widget the page does not use. (#372)
530 + *
531 + * @param array{reason?:string,url?:string,keys?:string[]} $skip Recorded refusal.
532 + */
533 + private static function nonce_skip_detail( array $skip ): string {
534 + $detail = 'Your nginx config is correct, but pages are not reaching the static cache, so hits are served by PHP (typically ~1s instead of ~5-15ms). '
535 + . 'They contain nonces, and a static file is served with no PHP — nothing could ever refresh them, so every anonymous form on the page would break once they expire. Keeping these pages on PHP is deliberate.';
536 +
537 + $keys = array_filter( array_map( 'strval', (array) ( $skip['keys'] ?? array() ) ) );
538 + if ( ! empty( $keys ) ) {
539 + $detail .= ' Nonces found: ' . implode( ', ', $keys ) . '.';
540 + $detail .= ' These come from plugin widgets — disabling the ones this site does not use lets its pages be served statically again.';
541 + }
542 +
543 + $url = (string) ( $skip['url'] ?? '' );
544 + if ( '' !== $url ) {
545 + $detail .= sprintf( ' Last seen on %s.', $url );
546 + }
547 +
548 + return $detail;
549 + }
550 +
405 551 public static function schedule_interval_hours( string $schedule ): ?int {
406 552 if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) {
407 553 return self::PRELOAD_INTERVALS[ $schedule ];
408 554 }
@@ -432,8 +578,74 @@
432 578 * wp_get_schedules() lookup.
433 579 * @return array{id:string,tone:string,label:string,detail:string}|null Check
434 580 * row, or null when the rule doesn't apply (preloader off/manual).
435 581 */
582 + /**
583 + * What cache is in front of the site, and what we are telling it.
584 + *
585 + * Reported whether or not anything is currently being held back, because
586 + * the useful half is the caveat rather than the header. A Cloudflare
587 + * Cache Rule set to ignore origin headers overrides everything xSpeed
588 + * sends, and someone debugging "my cart page is still being cached"
589 + * needs telling that rather than left to discover it.
590 + *
591 + * Null when nothing was detected and nothing was switched off: there is
592 + * no news in "we looked and saw nothing", and a row saying so on every
593 + * ordinary single-server site would be noise in a panel people scan for
594 + * problems.
595 + *
596 + * @return array{id:string,tone:string,label:string,detail:string}|null
597 + */
598 + public static function edge_check(): ?array {
599 + $edge = Edge_Provider::detect();
600 +
601 + if ( Edge_Provider::is_off( $edge ) ) {
602 + return array(
603 + 'id' => 'edge_hold',
604 + 'tone' => self::WARN,
605 + 'label' => 'Edge cache not being told anything',
606 + 'detail' => 'xSpeed is set not to send cache headers to the CDN in front of this site, so first renders and bypassed pages can be stored at the edge. Set "Cache In Front Of This Site" back to automatic unless you are sending your own headers.',
607 + );
608 + }
609 +
610 + if ( Edge_Provider::NONE === $edge['confidence'] ) {
611 + return null;
612 + }
613 +
614 + $named = '' !== $edge['provider'] ? $edge['provider'] : 'a cache we could not identify';
615 +
616 + // A pin outranks detection by design, so nothing re-checks it on the
617 + // site's behalf — and it is the one answer that also reaches the
618 + // drop-in and the server rules. Comparing it against the request is
619 + // the only way a site that changed CDN ever finds out.
620 + $sniffed = Edge_Provider::sniffed();
621 + if ( in_array( $edge['source'], array( 'setting', 'constant', 'filter' ), true )
622 + && '' !== $sniffed['provider']
623 + && $sniffed['provider'] !== $edge['provider'] ) {
624 + return array(
625 + 'id' => 'edge_hold',
626 + 'tone' => self::WARN,
627 + 'label' => 'Edge cache setting looks out of date',
628 + 'detail' => sprintf(
629 + 'This request looks like %s, but the provider is pinned to %s. If the site moved, update it — the pinned answer is also baked into the drop-in and the server rules.',
630 + $sniffed['provider'],
631 + $named
632 + ),
633 + );
634 + }
635 +
636 + $caveat = 'cloudflare' === $edge['provider']
637 + ? ' A Cloudflare Cache Rule whose Edge TTL is "Ignore cache-control header and use this TTL" overrides this; use "Respect origin TTL" on that rule.'
638 + : '';
639 +
640 + return array(
641 + 'id' => 'edge_hold',
642 + 'tone' => self::OK,
643 + 'label' => 'Edge cache being told what not to store',
644 + 'detail' => sprintf( 'First renders, bypassed pages and mobile-split pages are marked do-not-store for %s.%s', $named, $caveat ),
645 + );
646 + }
647 +
436 648 public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array {
437 649 if ( ! $preloader_enabled ) {
438 650 return null;
439 651 }
@@ -493,8 +705,19 @@
493 705 'writable' => self::wp_config_writable(),
494 706 ),
495 707 'permalinks_ok' => (bool) get_option( 'permalink_structure' ),
496 708 'conflicts' => Server::conflicts(),
709 + /*
710 + * The reason the enable would be refused right now, or null.
711 + *
712 + * `conflicts` is a list of plugins, and the wizard used it to
713 + * decide whether to open with page caching ticked. The two are
714 + * not the same question: an orphaned or doubly-defined WP_CACHE
715 + * refuses the enable with no plugin to name, so the wizard
716 + * offered a pre-ticked switch it already knew would fail. This
717 + * is the gate's own answer, so the box and the outcome agree.
718 + */
719 + 'page_cache_blocked' => Cache::acquisition_blocker(),
497 720 );
498 721 }
499 722
500 723 /**