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 +367 -37 1.1.5 → 1.3.6 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',
@@ -114,16 +118,56 @@
114 118
115 119 // WP_CACHE constant
116 120 $wp_cache_const = defined( 'WP_CACHE' ) && WP_CACHE;
117 121 if ( $cache_enabled ) {
118 - $out[] = array(
122 + // Why the constant is missing decides what we tell the user to
123 + // do, so the two cases can't share one sentence. An unwritable
124 + // wp-config.php (managed hosts make it read-only by design) is
125 + // the common cause and the user must paste the line by hand.
126 + // But it is NOT the only way to land here: a leftover
127 + // `define( 'WP_CACHE', false );` from a previous cache plugin,
128 + // a missing wp-config.php, or a WP_Filesystem that wants FTP
129 + // credentials all fail set_wp_cache_constant() on a perfectly
130 + // writable file. Asserting "not writable" unconditionally told
131 + // those users something demonstrably false about their own
132 + // server and sent them hand-editing a file the plugin could
133 + // have fixed by toggling the cache off and on. (#19)
134 + // The cost is real, but it is NOT "nothing is being cached": with
135 + // the constant absent, Cache::maybe_start_cache() still serves on
136 + // template_redirect and tags the response `HIT (php)`. What the
137 + // constant buys is answering from the drop-in BEFORE WordPress
138 + // boots — worth roughly an order of magnitude on TTFB, which is
139 + // what makes this a WARN worth acting on. Claiming the cache was
140 + // idle was simply false, and it is the sentence a worried user
141 + // reads first. (#19, QA on #174)
142 + //
143 + // Ask the WRITER which branch to show, not a second oracle: Health
144 + // used to run its own wp_is_writable() against its own path
145 + // resolution, and the two disagreed with set_wp_cache_constant()
146 + // in both directions — see Cache::can_write_wp_config().
147 + $wp_config_writable = Cache::can_write_wp_config();
148 + $check = array(
119 149 'id' => 'wp_cache_constant',
120 150 'tone' => $wp_cache_const ? self::OK : self::WARN,
121 151 'label' => 'WP_CACHE constant',
122 152 'detail' => $wp_cache_const
123 153 ? 'Defined and truthy in wp-config.php.'
124 - : 'Not set. Cache is configured but WordPress will not load the drop-in until WP_CACHE = true is added to wp-config.php.',
154 + : 'Not set — cached pages are being served the slow way. Without this constant WordPress boots fully before xSpeed can answer from cache, costing roughly 10ms per hit. ' . ( $wp_config_writable
155 + ? 'wp-config.php is writable, so toggling Enable Cache off and on should set it for you; if it comes back, another plugin may have left define( \'WP_CACHE\', false ) behind — add the line below by hand instead.'
156 + : 'wp-config.php is not writable here (managed hosts often make it read-only), so add the line below by hand, above the "That\'s all, stop editing!" comment.' ),
125 157 );
158 + if ( ! $wp_cache_const ) {
159 + // The line to paste, carried on the check itself so it
160 + // survives on a persistent surface. Enabling the cache
161 + // offered this only inside a toast that cleared after two
162 + // seconds with no copy button, so a user who looked away
163 + // had no way back to it anywhere in the dashboard — while
164 + // the toggle read green and the site cached nothing.
165 + // HealthCard already renders `snippet` through CopySnippet
166 + // (the nginx check proves the wiring). (#19)
167 + $check['snippet'] = "define( 'WP_CACHE', true );";
168 + }
169 + $out[] = $check;
126 170 }
127 171
128 172 // Static-rewrite probe. Active end-to-end check: writes a probe
129 173 // file under the static-cache dir, fetches it over HTTP, and
@@ -139,8 +183,14 @@
139 183 // probe there entirely — no needless self-request. Health is the
140 184 // right place to pay for the probe when we DO run it (the admin
141 185 // bootstrap reads cache-only so it never blocks); the 5-minute
142 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.
143 193 $probe = ( Server::LITESPEED === $server_type )
144 194 ? array( 'active' => false )
145 195 : Cache::probe_static_rewrite( true );
146 196 $is_active = (bool) ( $probe['active'] ?? false );
@@ -152,8 +202,19 @@
152 202 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
153 203 $probe_reason = (string) ( $probe['reason'] ?? '' );
154 204
155 205 $block_reason = Cache::static_rewrite_block_reason();
206 +
207 + // An OBSERVED refusal, from the last cacheable render. The settings
208 + // above say whether the rewrite is allowed; this says whether pages
209 + // are actually reaching the tree. They disagree whenever a page is
210 + // refused per-response — a nonce being the common one — and in that
211 + // case the settings are right and irrelevant: nginx is configured
212 + // correctly, and every hit still comes from PHP. (#372)
213 + $skip = Cache::last_static_skip();
214 + if ( '' === $block_reason && ! empty( $skip['reason'] ) ) {
215 + $block_reason = 'skipped_' . (string) $skip['reason'];
216 + }
156 217 $mobile_block = ( 'mobile_separate' === $block_reason )
157 218 ? ' 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.'
158 219 : '';
159 220
@@ -174,25 +235,36 @@
174 235 // about") instead of the WARN the block deserves.
175 236 $inconclusive = $inconclusive && ! $refused;
176 237
177 238 if ( Server::NGINX === $server_type ) {
178 - if ( $is_active ) {
179 - $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
180 - } elseif ( 'mobile_separate' === $block_reason ) {
181 - $nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block;
182 - } elseif ( $inconclusive ) {
183 - $nginx_detail = sprintf(
184 - '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',
185 - $probe_reason
186 - );
187 - } else {
188 - $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.';
189 261 }
190 262
191 263 $out[] = array(
192 264 'id' => 'static_rewrite_nginx',
193 265 // Inconclusive is INFO, not WARN — we have no finding to warn about.
194 - 'tone' => $is_active ? self::OK : ( $inconclusive ? self::INFO : self::WARN ),
266 + 'tone' => 'active' === $verdict ? self::OK : ( 'unverified' === $verdict ? self::INFO : self::WARN ),
195 267 'label' => 'Static-file rewrite (nginx server config)',
196 268 'detail' => $nginx_detail,
197 269 // Always ship the snippet — even when active, so the
198 270 // admin has it handy for re-pasting after a server
@@ -203,22 +275,66 @@
203 275 // the Cache panel disagree on what to paste.
204 276 'snippet' => Cache::full_nginx_server_block(),
205 277 );
206 278 } elseif ( Server::LITESPEED === $server_type ) {
207 - // LiteSpeed intentionally does NOT use the .htaccess static
208 - // rewrite: OpenLiteSpeed's .htaccess engine ignores
209 - // mod_headers (so we can't stamp X-XSpeed-Cache: HIT) and has
210 - // no per-rule access_log (so a static hit can't be counted).
211 - // We route LiteSpeed hits through the PHP drop-in instead, so
212 - // every hit is both visible (X-XSpeed-Cache: HIT) and counted
213 - // in the hit-ratio — see Cache::static_rewrite_allowed(). This
214 - // is the healthy, expected state on LiteSpeed, not a fallback.
215 - $out[] = array(
216 - 'id' => 'static_rewrite_litespeed',
217 - 'tone' => self::OK,
218 - 'label' => 'Cache serving (LiteSpeed)',
219 - '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.)',
220 - );
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 + }
221 337 } elseif ( Server::APACHE === $server_type ) {
222 338 $installed = Cache::rewrite_installed();
223 339 if ( $is_active ) {
224 340 $tone = self::OK;
@@ -233,8 +349,14 @@
233 349 // still works via the drop-in; say so, and give the one
234 350 // step that actually changes the outcome.
235 351 $tone = self::INFO;
236 352 $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.';
353 + } elseif ( 'skipped_nonce' === $block_reason ) {
354 + // Before this, Apache fell through to "probe failed —
355 + // check AllowOverride", sending the admin to audit a
356 + // config that was never the problem.
357 + $tone = self::WARN;
358 + $detail = self::nonce_skip_detail( $skip );
237 359 } elseif ( ! $installed ) {
238 360 $tone = self::WARN;
239 361 $detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.';
240 362 } elseif ( $inconclusive ) {
@@ -259,8 +381,62 @@
259 381 );
260 382 }
261 383 }
262 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 +
263 439 // Cache expiry vs preloader schedule (deterministic rule, issue #31):
264 440 // pages that expire faster than the preloader re-warms them leave the
265 441 // cache cold for most real traffic — the classic "24.8% hit ratio with
266 442 // everything on" misconfiguration. Pure logic in
@@ -269,9 +445,9 @@
269 445 $cache_opts = Settings_Manager::get( 'cache' );
270 446 $pre_opts = Settings_Manager::get( 'preloader' );
271 447 $schedule = (string) ( $pre_opts['schedule'] ?? 'manual' );
272 448 $mismatch = self::expiry_preload_check(
273 - (int) ( $cache_opts['cache_expiry'] ?? 24 ),
449 + (int) ( $cache_opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ),
274 450 $schedule,
275 451 ! empty( $pre_opts['enabled'] ),
276 452 self::schedule_interval_hours( $schedule )
277 453 );
@@ -290,8 +466,16 @@
290 466 ? 'Pretty permalinks active.'
291 467 : 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.',
292 468 );
293 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 +
294 478 // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
295 479 // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all
296 480 // HTML (Cloudflare never caches a response carrying Set-Cookie). Probe
297 481 // is transient-throttled inside Cookie_Inspector, same pattern as the
@@ -333,13 +517,44 @@
333 517 $out[] = array(
334 518 'id' => 'conflicts',
335 519 'tone' => empty( $conflicts ) ? self::OK : self::WARN,
336 520 'label' => 'Caching plugin conflicts',
521 + // Not "Active:" — the list now includes a drop-in left behind by a
522 + // plugin that is not running, which is exactly the case that made
523 + // this row disagree with what the enable actually does.
337 524 'detail' => empty( $conflicts )
338 525 ? 'No other caching plugins detected.'
339 - : sprintf( 'Active: %s. Deactivate before enabling xSpeed cache to avoid double-caching.', implode( ', ', $conflicts ) ),
526 + : sprintf( 'Found: %s. Another page cache must be off, and its advanced-cache.php gone, before xSpeed can enable its own.', implode( ', ', $conflicts ) ),
340 527 );
341 528
529 + /*
530 + * A migration whose source is STILL RUNNING.
531 + *
532 + * Distinct from the generic `conflicts` check above, which only says
533 + * "another caching plugin is active". This one knows the user imported
534 + * from it and chose (or was refused) to leave it on, so it can name the
535 + * plugin and the decision.
536 + *
537 + * The point is persistence: the import screen's warning disappears the
538 + * moment the user navigates away, and the risk does not. Two page
539 + * caches fighting over the drop-in is exactly what breaks caching for
540 + * both, so the warning has to outlive the screen it was raised on.
541 + * (#189 AC4)
542 + */
543 + $pending = class_exists( '\\XSpeed\\Migration' ) ? Migration::pending_source() : null;
544 + if ( null !== $pending ) {
545 + $out[] = array(
546 + 'id' => 'migration_source_active',
547 + 'tone' => self::WARN,
548 + 'label' => sprintf( '%s is still active after import', $pending['label'] ),
549 + 'detail' => sprintf(
550 + '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.',
551 + $pending['label'],
552 + $pending['label']
553 + ),
554 + );
555 + }
556 +
342 557 return $out;
343 558 }
344 559
345 560 /**
@@ -361,8 +576,38 @@
361 576 * Falls back to `wp_get_schedules()` so custom crons registered by a
362 577 * theme or another plugin are covered too, rather than silently
363 578 * skipping the check.
364 579 */
580 + /**
581 + * Explain a static-tree refusal caused by nonces.
582 + *
583 + * Says four things, because leaving any of them out is what made this
584 + * invisible: the config is FINE (so nobody re-pastes a snippet that was
585 + * never the problem), hits are coming from PHP instead, which nonce keys
586 + * caused it, and that the refusal is deliberate rather than a bug to work
587 + * around. The keys are the actionable part — they name the plugin, and it
588 + * is usually a widget the page does not use. (#372)
589 + *
590 + * @param array{reason?:string,url?:string,keys?:string[]} $skip Recorded refusal.
591 + */
592 + private static function nonce_skip_detail( array $skip ): string {
593 + $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). '
594 + . '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.';
595 +
596 + $keys = array_filter( array_map( 'strval', (array) ( $skip['keys'] ?? array() ) ) );
597 + if ( ! empty( $keys ) ) {
598 + $detail .= ' Nonces found: ' . implode( ', ', $keys ) . '.';
599 + $detail .= ' These come from plugin widgets — disabling the ones this site does not use lets its pages be served statically again.';
600 + }
601 +
602 + $url = (string) ( $skip['url'] ?? '' );
603 + if ( '' !== $url ) {
604 + $detail .= sprintf( ' Last seen on %s.', $url );
605 + }
606 +
607 + return $detail;
608 + }
609 +
365 610 public static function schedule_interval_hours( string $schedule ): ?int {
366 611 if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) {
367 612 return self::PRELOAD_INTERVALS[ $schedule ];
368 613 }
@@ -392,8 +637,74 @@
392 637 * wp_get_schedules() lookup.
393 638 * @return array{id:string,tone:string,label:string,detail:string}|null Check
394 639 * row, or null when the rule doesn't apply (preloader off/manual).
395 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 +
396 707 public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array {
397 708 if ( ! $preloader_enabled ) {
398 709 return null;
399 710 }
@@ -453,15 +764,34 @@
453 764 'writable' => self::wp_config_writable(),
454 765 ),
455 766 'permalinks_ok' => (bool) get_option( 'permalink_structure' ),
456 767 'conflicts' => Server::conflicts(),
768 + /*
769 + * The reason the enable would be refused right now, or null.
770 + *
771 + * `conflicts` is a list of plugins, and the wizard used it to
772 + * decide whether to open with page caching ticked. The two are
773 + * not the same question: an orphaned or doubly-defined WP_CACHE
774 + * refuses the enable with no plugin to name, so the wizard
775 + * offered a pre-ticked switch it already knew would fail. This
776 + * is the gate's own answer, so the box and the outcome agree.
777 + */
778 + 'page_cache_blocked' => Cache::acquisition_blocker(),
457 779 );
458 780 }
459 781
782 + /**
783 + * Cheap writability probe for the onboarding env payload only.
784 + *
785 + * Deliberately NOT the oracle behind the WP_CACHE check — that asks
786 + * Cache::can_write_wp_config(), which runs the same WP_Filesystem test
787 + * the writer runs, so advice can never contradict behaviour. This one
788 + * stays a plain filesystem read because env_payload() is documented as
789 + * making no outbound calls, and WP_Filesystem() can try to open an
790 + * FTP/SSH connection. It shares the writer's path resolution so the two
791 + * at least agree on WHICH file they are describing. (#19, QA on #174)
792 + */
460 793 private static function wp_config_writable(): bool {
461 - $path = ABSPATH . 'wp-config.php';
462 - if ( ! file_exists( $path ) ) {
463 - $path = dirname( ABSPATH ) . '/wp-config.php';
464 - }
465 - return file_exists( $path ) && wp_is_writable( $path );
794 + $path = Cache::wp_config_path();
795 + return '' !== $path && wp_is_writable( $path );
466 796 }
467 797 }