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-health.php +163 -62 1.3.4 → 1.4.2 View file →
@@ -38,8 +38,70 @@
38 38 'unknown' => 'Unknown',
39 39 );
40 40
41 41 /**
42 + * The server-level page cache row, or null on a site without one. Split
43 + * out of checks() so both Nginx Helper modes can be tested. (#573)
44 + *
45 + * @param bool $cache_enabled Whether xSpeed's own page cache is on.
46 + * @return array<string,string>|null
47 + */
48 + public static function host_page_cache_row( bool $cache_enabled ): ?array {
49 + $host_cache_mode = Host_Page_Caches::nginx_helper_page_cache();
50 + if ( null === $host_cache_mode ) {
51 + return null;
52 + }
53 + // Same wording in both modes; only where the copies live differs.
54 + $where = Host_Page_Caches::MODE_REDIS === $host_cache_mode ? 'in nginx (held in Redis)' : 'in nginx (FastCGI)';
55 + $detail = $cache_enabled
56 + ? 'Your server is running its own full-page cache ' . $where . ', 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, and your host decides how long the server side keeps each page, 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.'
57 + : 'Your server is running a full-page cache ' . $where . ', 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.';
58 +
59 + $redis = Host_Page_Caches::nginx_helper_redis_target();
60 + if ( null !== $redis ) {
61 + // Server and prefix, never credentials. The prefix is what
62 + // Nginx Helper's purge deletes under, so it is the thing to
63 + // check against the host's nginx config. (#573)
64 + $detail .= sprintf( ' Redis server: %s, key prefix "%s".', $redis['server'], '' !== $redis['prefix'] ? $redis['prefix'] : '(none)' );
65 + } else {
66 + $host_cache_path = (string) Host_Page_Caches::nginx_helper_cache_path();
67 + // Path prefix as a fingerprint for the WORDING only — never as
68 + // a gate. Nginx Helper is not xCloud-only; other hosts and
69 + // manual installs use it with a cache directory elsewhere.
70 + if ( 0 === strpos( $host_cache_path, '/etc/nginx/cache/' ) ) {
71 + $detail .= sprintf( ' Cache directory: %s (the layout xCloud provisions).', $host_cache_path );
72 + } else {
73 + $detail .= sprintf( ' Cache directory: %s.', $host_cache_path );
74 + }
75 + }
76 +
77 + // The purge is a direct unlink by the PHP-FPM user against a
78 + // directory nginx owns. Whether that user can write there is a
79 + // property of the host we cannot test from here without deleting
80 + // someone's cache to find out, so say what to check rather than
81 + // claiming an outcome either way. FastCGI only: Redis has no files.
82 + if ( null === $redis && 'unlink_files' === Host_Page_Caches::nginx_helper_purge_method() ) {
83 + $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.';
84 + }
85 +
86 + if ( is_multisite() ) {
87 + // Nginx Helper's Redis purge_all() deletes only keys under this
88 + // site's scheme, host and path; FastCGI unlinks the whole
89 + // directory. (#573)
90 + $detail .= null !== $redis
91 + ? ' On multisite, Nginx Helper clears only this site\'s pages from Redis, matched by its address. Other sites on the network keep their server copies until their own purge or expiry.'
92 + : ' On multisite, nginx keys one cache per install rather than per site, so this purge clears every site on the network.';
93 + }
94 +
95 + return array(
96 + 'id' => 'host_page_cache',
97 + 'tone' => $cache_enabled ? self::WARN : self::INFO,
98 + 'label' => Host_Page_Caches::MODE_REDIS === $host_cache_mode ? 'Server-level page cache (nginx Redis)' : 'Server-level page cache (nginx FastCGI)',
99 + 'detail' => $detail,
100 + );
101 + }
102 +
103 + /**
42 104 * Full check list used by the Health module's dashboard panel.
43 105 *
44 106 * @return array<int,array{id:string,tone:string,label:string,detail:string}>
45 107 */
@@ -183,8 +245,14 @@
183 245 // probe there entirely — no needless self-request. Health is the
184 246 // right place to pay for the probe when we DO run it (the admin
185 247 // bootstrap reads cache-only so it never blocks); the 5-minute
186 248 // transient still throttles repeat runs. (FBS-82142)
249 + // LiteSpeed never pays for the probe, even with the Static Fast
250 + // Path opt-in on (#509): OLS can't stamp the HIT header, so the
251 + // probe can't distinguish "static-served" from "PHP-served"
252 + // there and no LiteSpeed card below consumes its verdict — the
253 + // card reports the installed state instead. A probe nothing
254 + // reads is just a needless loopback self-request per paint.
187 255 $probe = ( Server::LITESPEED === $server_type )
188 256 ? array( 'active' => false )
189 257 : Cache::probe_static_rewrite( true );
190 258 $is_active = (bool) ( $probe['active'] ?? false );
@@ -229,27 +297,36 @@
229 297 // about") instead of the WARN the block deserves.
230 298 $inconclusive = $inconclusive && ! $refused;
231 299
232 300 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.';
301 + // One verdict for both surfaces: this panel and the Site
302 + // Health test must answer from the SAME ordering, or they
303 + // drift apart again — the whole point of #480.
304 + $verdict = \XSpeed\Modules\Health\HealthModule::nginx_rewrite_verdict( $probe, $block_reason );
305 + switch ( $verdict ) {
306 + case 'active':
307 + $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
308 + break;
309 + case 'mobile_separate':
310 + $nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block;
311 + break;
312 + case 'skipped_nonce':
313 + $nginx_detail = self::nonce_skip_detail( $skip );
314 + break;
315 + case 'unverified':
316 + $nginx_detail = sprintf(
317 + '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',
318 + $probe_reason
319 + );
320 + break;
321 + default: // 'required'.
322 + $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 323 }
247 324
248 325 $out[] = array(
249 326 'id' => 'static_rewrite_nginx',
250 327 // Inconclusive is INFO, not WARN — we have no finding to warn about.
251 - 'tone' => $is_active ? self::OK : ( $inconclusive ? self::INFO : self::WARN ),
328 + 'tone' => 'active' === $verdict ? self::OK : ( 'unverified' === $verdict ? self::INFO : self::WARN ),
252 329 'label' => 'Static-file rewrite (nginx server config)',
253 330 'detail' => $nginx_detail,
254 331 // Always ship the snippet — even when active, so the
255 332 // admin has it handy for re-pasting after a server
@@ -260,22 +337,66 @@
260 337 // the Cache panel disagree on what to paste.
261 338 'snippet' => Cache::full_nginx_server_block(),
262 339 );
263 340 } 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 - );
341 + // LiteSpeed defaults to the PHP drop-in — its .htaccess engine
342 + // ignores mod_headers (no X-XSpeed-Cache stamp) and has no
343 + // per-rule access_log, so a static hit would be invisible. The
344 + // LiteSpeed Static Fast Path setting (#509) lets the user opt
345 + // into web-server serving anyway; each state gets its own card
346 + // so the trade the user made (or can make) is always stated.
347 + if ( 'litespeed_dropin' === $block_reason ) {
348 + // The intended default — healthy, not a fallback.
349 + $out[] = array(
350 + 'id' => 'static_rewrite_litespeed',
351 + 'tone' => self::OK,
352 + 'label' => 'Cache serving (LiteSpeed)',
353 + '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.',
354 + );
355 + } elseif ( 'mobile_separate' === $block_reason ) {
356 + $out[] = array(
357 + 'id' => 'static_rewrite_litespeed',
358 + 'tone' => self::WARN,
359 + 'label' => 'Static-file rewrite (LiteSpeed)',
360 + 'detail' => 'LiteSpeed Static Fast Path is on, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block,
361 + );
362 + } elseif ( 'skipped_nonce' === $block_reason ) {
363 + $out[] = array(
364 + 'id' => 'static_rewrite_litespeed',
365 + 'tone' => self::WARN,
366 + 'label' => 'Static-file rewrite (LiteSpeed)',
367 + 'detail' => self::nonce_skip_detail( $skip ),
368 + );
369 + } elseif ( ! Cache::rewrite_installed() ) {
370 + // Say WHY it is missing when we can tell. "Re-save to
371 + // reinstall" on a read-only .htaccess is advice that
372 + // cannot work — the same write that failed just fails
373 + // again — and meanwhile the save itself succeeded
374 + // silently, so this card is the only surface that can
375 + // explain the state. (QA on #513)
376 + $htaccess = ABSPATH . '.htaccess';
377 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only diagnostic; mirrors install_rewrite()'s own pre-flight.
378 + $writable = file_exists( $htaccess ) ? is_writable( $htaccess ) : is_writable( ABSPATH );
379 + $out[] = array(
380 + 'id' => 'static_rewrite_litespeed',
381 + 'tone' => self::WARN,
382 + 'label' => 'Static-file rewrite (LiteSpeed)',
383 + 'detail' => $writable
384 + ? 'LiteSpeed Static Fast Path is on, but the block is missing from .htaccess. Re-save any Cache setting to reinstall it.'
385 + : '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.',
386 + );
387 + } else {
388 + // Opt-in on and the block is installed. The probe can't
389 + // confirm "active" the way it does elsewhere (LiteSpeed
390 + // serves the probe file but stamps no header), so report
391 + // the installed state and restate the accounting trade.
392 + $out[] = array(
393 + 'id' => 'static_rewrite_litespeed',
394 + 'tone' => self::OK,
395 + 'label' => 'Static-file rewrite (LiteSpeed)',
396 + '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.',
397 + );
398 + }
278 399 } elseif ( Server::APACHE === $server_type ) {
279 400 $installed = Cache::rewrite_installed();
280 401 if ( $is_active ) {
281 402 $tone = self::OK;
@@ -340,42 +461,11 @@
340 461 * inner one has already regenerated — the classic "I purged and it is
341 462 * still stale" report. With xSpeed's own page cache off there is only
342 463 * one layer and nothing to warn about, so that case is INFO.
343 464 */
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 - );
465 + $host_row = self::host_page_cache_row( $cache_enabled );
466 + if ( null !== $host_row ) {
467 + $out[] = $host_row;
378 468 }
379 469
380 470 // Cache expiry vs preloader schedule (deterministic rule, issue #31):
381 471 // pages that expire faster than the preloader re-warms them leave the
@@ -413,8 +503,19 @@
413 503 // checks(); see edge_check().
414 504 $edge_row = self::edge_check();
415 505 if ( null !== $edge_row ) {
416 506 $out[] = $edge_row;
507 + }
508 +
509 + // Whether that edge actually obeys "do not store". Read from the
510 + // last background probe only; a cold one schedules a run and shows
511 + // nothing this paint. See Edge_Mode_Probe.
512 + $mode_row = Edge_Mode_Probe::health_row(
513 + Edge_Mode_Probe::cached()['verdict'],
514 + defined( 'XCLOUD_CFE_PURGE_API_URL' ) || defined( 'XCLOUD_CFE_PURGE_VERSION' )
515 + );
516 + if ( null !== $mode_row ) {
517 + $out[] = $mode_row;
417 518 }
418 519
419 520 // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
420 521 // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all