PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/class-health.php +215 -28 1.3.0 → 1.3.6 View file →
@@ -183,8 +183,14 @@
183 183 // probe there entirely — no needless self-request. Health is the
184 184 // right place to pay for the probe when we DO run it (the admin
185 185 // bootstrap reads cache-only so it never blocks); the 5-minute
186 186 // transient still throttles repeat runs. (FBS-82142)
187 + // LiteSpeed never pays for the probe, even with the Static Fast
188 + // Path opt-in on (#509): OLS can't stamp the HIT header, so the
189 + // probe can't distinguish "static-served" from "PHP-served"
190 + // there and no LiteSpeed card below consumes its verdict — the
191 + // card reports the installed state instead. A probe nothing
192 + // reads is just a needless loopback self-request per paint.
187 193 $probe = ( Server::LITESPEED === $server_type )
188 194 ? array( 'active' => false )
189 195 : Cache::probe_static_rewrite( true );
190 196 $is_active = (bool) ( $probe['active'] ?? false );
@@ -229,27 +235,36 @@
229 235 // about") instead of the WARN the block deserves.
230 236 $inconclusive = $inconclusive && ! $refused;
231 237
232 238 if ( Server::NGINX === $server_type ) {
233 - if ( $is_active ) {
234 - $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
235 - } elseif ( 'mobile_separate' === $block_reason ) {
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 );
239 - } elseif ( $inconclusive ) {
240 - $nginx_detail = sprintf(
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',
242 - $probe_reason
243 - );
244 - } else {
245 - $nginx_detail = 'nginx detected but not yet routing to the cache. Paste the snippet below into your site\'s server { } block, then reload nginx.';
239 + // One verdict for both surfaces: this panel and the Site
240 + // Health test must answer from the SAME ordering, or they
241 + // drift apart again — the whole point of #480.
242 + $verdict = \XSpeed\Modules\Health\HealthModule::nginx_rewrite_verdict( $probe, $block_reason );
243 + switch ( $verdict ) {
244 + case 'active':
245 + $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
246 + break;
247 + case 'mobile_separate':
248 + $nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block;
249 + break;
250 + case 'skipped_nonce':
251 + $nginx_detail = self::nonce_skip_detail( $skip );
252 + break;
253 + case 'unverified':
254 + $nginx_detail = sprintf(
255 + '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',
256 + $probe_reason
257 + );
258 + break;
259 + default: // 'required'.
260 + $nginx_detail = 'nginx detected but not yet routing to the cache. Paste the snippet below into your site\'s server { } block, then reload nginx.';
246 261 }
247 262
248 263 $out[] = array(
249 264 'id' => 'static_rewrite_nginx',
250 265 // Inconclusive is INFO, not WARN — we have no finding to warn about.
251 - 'tone' => $is_active ? self::OK : ( $inconclusive ? self::INFO : self::WARN ),
266 + 'tone' => 'active' === $verdict ? self::OK : ( 'unverified' === $verdict ? self::INFO : self::WARN ),
252 267 'label' => 'Static-file rewrite (nginx server config)',
253 268 'detail' => $nginx_detail,
254 269 // Always ship the snippet — even when active, so the
255 270 // admin has it handy for re-pasting after a server
@@ -260,22 +275,66 @@
260 275 // the Cache panel disagree on what to paste.
261 276 'snippet' => Cache::full_nginx_server_block(),
262 277 );
263 278 } elseif ( Server::LITESPEED === $server_type ) {
264 - // LiteSpeed intentionally does NOT use the .htaccess static
265 - // rewrite: OpenLiteSpeed's .htaccess engine ignores
266 - // mod_headers (so we can't stamp X-XSpeed-Cache: HIT) and has
267 - // no per-rule access_log (so a static hit can't be counted).
268 - // We route LiteSpeed hits through the PHP drop-in instead, so
269 - // every hit is both visible (X-XSpeed-Cache: HIT) and counted
270 - // in the hit-ratio — see Cache::static_rewrite_allowed(). This
271 - // is the healthy, expected state on LiteSpeed, not a fallback.
272 - $out[] = array(
273 - 'id' => 'static_rewrite_litespeed',
274 - 'tone' => self::OK,
275 - 'label' => 'Cache serving (LiteSpeed)',
276 - 'detail' => 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT — so every hit is visible and counted in your hit-ratio. (LiteSpeed\'s .htaccess can\'t add that header or log static hits, so xSpeed serves them itself for accurate reporting.)',
277 - );
279 + // LiteSpeed defaults to the PHP drop-in — its .htaccess engine
280 + // ignores mod_headers (no X-XSpeed-Cache stamp) and has no
281 + // per-rule access_log, so a static hit would be invisible. The
282 + // LiteSpeed Static Fast Path setting (#509) lets the user opt
283 + // into web-server serving anyway; each state gets its own card
284 + // so the trade the user made (or can make) is always stated.
285 + if ( 'litespeed_dropin' === $block_reason ) {
286 + // The intended default — healthy, not a fallback.
287 + $out[] = array(
288 + 'id' => 'static_rewrite_litespeed',
289 + 'tone' => self::OK,
290 + 'label' => 'Cache serving (LiteSpeed)',
291 + 'detail' => 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT — so every hit is visible and counted in your hit-ratio. (LiteSpeed\'s .htaccess can\'t add that header or log static hits, so xSpeed serves them itself for accurate reporting.) Prefer raw speed over hit accounting? Turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server with no PHP — how much that saves depends on how quickly PHP answers on this host.',
292 + );
293 + } elseif ( 'mobile_separate' === $block_reason ) {
294 + $out[] = array(
295 + 'id' => 'static_rewrite_litespeed',
296 + 'tone' => self::WARN,
297 + 'label' => 'Static-file rewrite (LiteSpeed)',
298 + 'detail' => 'LiteSpeed Static Fast Path is on, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block,
299 + );
300 + } elseif ( 'skipped_nonce' === $block_reason ) {
301 + $out[] = array(
302 + 'id' => 'static_rewrite_litespeed',
303 + 'tone' => self::WARN,
304 + 'label' => 'Static-file rewrite (LiteSpeed)',
305 + 'detail' => self::nonce_skip_detail( $skip ),
306 + );
307 + } elseif ( ! Cache::rewrite_installed() ) {
308 + // Say WHY it is missing when we can tell. "Re-save to
309 + // reinstall" on a read-only .htaccess is advice that
310 + // cannot work — the same write that failed just fails
311 + // again — and meanwhile the save itself succeeded
312 + // silently, so this card is the only surface that can
313 + // explain the state. (QA on #513)
314 + $htaccess = ABSPATH . '.htaccess';
315 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only diagnostic; mirrors install_rewrite()'s own pre-flight.
316 + $writable = file_exists( $htaccess ) ? is_writable( $htaccess ) : is_writable( ABSPATH );
317 + $out[] = array(
318 + 'id' => 'static_rewrite_litespeed',
319 + 'tone' => self::WARN,
320 + 'label' => 'Static-file rewrite (LiteSpeed)',
321 + 'detail' => $writable
322 + ? 'LiteSpeed Static Fast Path is on, but the block is missing from .htaccess. Re-save any Cache setting to reinstall it.'
323 + : 'LiteSpeed Static Fast Path is on, but the block could not be written because .htaccess is not writable. Hits are still served (and counted) by the PHP drop-in. Make .htaccess writable and re-save any Cache setting, or turn the fast path off.',
324 + );
325 + } else {
326 + // Opt-in on and the block is installed. The probe can't
327 + // confirm "active" the way it does elsewhere (LiteSpeed
328 + // serves the probe file but stamps no header), so report
329 + // the installed state and restate the accounting trade.
330 + $out[] = array(
331 + 'id' => 'static_rewrite_litespeed',
332 + 'tone' => self::OK,
333 + 'label' => 'Static-file rewrite (LiteSpeed)',
334 + 'detail' => 'LiteSpeed Static Fast Path is on: the .htaccess block is installed and cache hits are served by the web server with no PHP. These responses carry no X-XSpeed-Cache header and are not counted in the hit ratio — that is the trade this setting makes. Turn it off in Cache settings to return every hit to the visible, counted PHP path.',
335 + );
336 + }
278 337 } elseif ( Server::APACHE === $server_type ) {
279 338 $installed = Cache::rewrite_installed();
280 339 if ( $is_active ) {
281 340 $tone = self::OK;
@@ -322,8 +381,62 @@
322 381 );
323 382 }
324 383 }
325 384
385 + /*
386 + * A full-page cache owned by the WEB SERVER, in front of PHP.
387 + *
388 + * Reported only when it is actually there, because it is a fact about
389 + * the host rather than a setting the admin can act on from here — an
390 + * "absent" row would be noise on the ~99% of sites that have no such
391 + * layer. When it IS there it outranks almost everything else on this
392 + * panel: nginx answers before WordPress runs, so what a visitor sees
393 + * is decided by that cache and not by anything xSpeed reports about
394 + * its own.
395 + *
396 + * The severity is about DOUBLE full-page caching, not about the layer
397 + * existing. Two independent full-page caches stacked in front of one
398 + * site have independent TTLs, and the outer one can re-serve HTML the
399 + * inner one has already regenerated — the classic "I purged and it is
400 + * still stale" report. With xSpeed's own page cache off there is only
401 + * one layer and nothing to warn about, so that case is INFO.
402 + */
403 + $host_cache_path = Host_Page_Caches::nginx_helper_cache_path();
404 + if ( null !== $host_cache_path ) {
405 + $detail = $cache_enabled
406 + ? '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 clears the server layer too on a Purge All, and on the changes that affect every page — settings, updates, themes, plugins, menus. It leaves the routine purge after a post or comment edit to Nginx Helper, which clears just the pages that changed, as long as Nginx Helper\'s own Enable Purge setting is on. With it off, xSpeed clears the server layer after those edits too. The two caches 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.'
407 + : '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, as do settings changes and plugin, theme or core updates. The routine purge after editing a post or approving a comment is left to Nginx Helper, which clears just the pages that changed rather than all of them, as long as Nginx Helper\'s own Enable Purge setting is on. With it off, xSpeed clears the server layer after those edits too.';
408 +
409 + // Path prefix as a fingerprint for the WORDING only — never as a
410 + // gate. Nginx Helper is not xCloud-only; other hosts and manual
411 + // installs use it with a cache directory somewhere else entirely.
412 + if ( 0 === strpos( $host_cache_path, '/etc/nginx/cache/' ) ) {
413 + $detail .= sprintf( ' Cache directory: %s (the layout xCloud provisions).', $host_cache_path );
414 + } else {
415 + $detail .= sprintf( ' Cache directory: %s.', $host_cache_path );
416 + }
417 +
418 + // The purge is a direct unlink by the PHP-FPM user against a
419 + // directory nginx owns. Whether that user can write there is a
420 + // property of the host we cannot test from here without deleting
421 + // someone's cache to find out, so say what to check rather than
422 + // claiming an outcome either way.
423 + if ( 'unlink_files' === Host_Page_Caches::nginx_helper_purge_method() ) {
424 + $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.';
425 + }
426 +
427 + if ( is_multisite() ) {
428 + $detail .= ' On multisite, nginx keys one cache per install rather than per site, so this purge clears every site on the network.';
429 + }
430 +
431 + $out[] = array(
432 + 'id' => 'host_page_cache',
433 + 'tone' => $cache_enabled ? self::WARN : self::INFO,
434 + 'label' => 'Server-level page cache (nginx FastCGI)',
435 + 'detail' => $detail,
436 + );
437 + }
438 +
326 439 // Cache expiry vs preloader schedule (deterministic rule, issue #31):
327 440 // pages that expire faster than the preloader re-warms them leave the
328 441 // cache cold for most real traffic — the classic "24.8% hit ratio with
329 442 // everything on" misconfiguration. Pure logic in
@@ -353,8 +466,16 @@
353 466 ? 'Pretty permalinks active.'
354 467 : 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.',
355 468 );
356 469
470 + // What is in front of the site, and what we are telling it. Extracted
471 + // so it can be exercised without paying for every other probe in
472 + // checks(); see edge_check().
473 + $edge_row = self::edge_check();
474 + if ( null !== $edge_row ) {
475 + $out[] = $edge_row;
476 + }
477 +
357 478 // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
358 479 // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all
359 480 // HTML (Cloudflare never caches a response carrying Set-Cookie). Probe
360 481 // is transient-throttled inside Cookie_Inspector, same pattern as the
@@ -516,8 +637,74 @@
516 637 * wp_get_schedules() lookup.
517 638 * @return array{id:string,tone:string,label:string,detail:string}|null Check
518 639 * row, or null when the rule doesn't apply (preloader off/manual).
519 640 */
641 + /**
642 + * What cache is in front of the site, and what we are telling it.
643 + *
644 + * Reported whether or not anything is currently being held back, because
645 + * the useful half is the caveat rather than the header. A Cloudflare
646 + * Cache Rule set to ignore origin headers overrides everything xSpeed
647 + * sends, and someone debugging "my cart page is still being cached"
648 + * needs telling that rather than left to discover it.
649 + *
650 + * Null when nothing was detected and nothing was switched off: there is
651 + * no news in "we looked and saw nothing", and a row saying so on every
652 + * ordinary single-server site would be noise in a panel people scan for
653 + * problems.
654 + *
655 + * @return array{id:string,tone:string,label:string,detail:string}|null
656 + */
657 + public static function edge_check(): ?array {
658 + $edge = Edge_Provider::detect();
659 +
660 + if ( Edge_Provider::is_off( $edge ) ) {
661 + return array(
662 + 'id' => 'edge_hold',
663 + 'tone' => self::WARN,
664 + 'label' => 'Edge cache not being told anything',
665 + '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.',
666 + );
667 + }
668 +
669 + if ( Edge_Provider::NONE === $edge['confidence'] ) {
670 + return null;
671 + }
672 +
673 + $named = '' !== $edge['provider'] ? $edge['provider'] : 'a cache we could not identify';
674 +
675 + // A pin outranks detection by design, so nothing re-checks it on the
676 + // site's behalf — and it is the one answer that also reaches the
677 + // drop-in and the server rules. Comparing it against the request is
678 + // the only way a site that changed CDN ever finds out.
679 + $sniffed = Edge_Provider::sniffed();
680 + if ( in_array( $edge['source'], array( 'setting', 'constant', 'filter' ), true )
681 + && '' !== $sniffed['provider']
682 + && $sniffed['provider'] !== $edge['provider'] ) {
683 + return array(
684 + 'id' => 'edge_hold',
685 + 'tone' => self::WARN,
686 + 'label' => 'Edge cache setting looks out of date',
687 + 'detail' => sprintf(
688 + '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.',
689 + $sniffed['provider'],
690 + $named
691 + ),
692 + );
693 + }
694 +
695 + $caveat = 'cloudflare' === $edge['provider']
696 + ? ' 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.'
697 + : '';
698 +
699 + return array(
700 + 'id' => 'edge_hold',
701 + 'tone' => self::OK,
702 + 'label' => 'Edge cache being told what not to store',
703 + 'detail' => sprintf( 'First renders, bypassed pages and mobile-split pages are marked do-not-store for %s.%s', $named, $caveat ),
704 + );
705 + }
706 +
520 707 public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array {
521 708 if ( ! $preloader_enabled ) {
522 709 return null;
523 710 }